Fix audit generation and enrichment fallback

This commit is contained in:
2026-06-07 23:03:57 +02:00
parent e9463e8ef2
commit 470fb0f348
10 changed files with 2190 additions and 138 deletions

View File

@@ -8,15 +8,19 @@ import {
auditSummarySchema,
qualityReviewSchema,
publicAuditTextSchema,
auditClassificationSchema,
internalFindingsSchema,
auditGenerationResultSchema,
type CallScript,
type EmailDraft,
type EmailSubject,
type FollowUpDraft,
type AuditSummary,
type PublicAuditText,
type AuditClassification,
type QualityReview,
type InternalFindings,
type AuditGenerationResult,
} from "../lib/ai/schemas";
test("internal findings schema accepts task-focused evidence", () => {
@@ -35,6 +39,270 @@ test("internal findings schema accepts task-focused evidence", () => {
assert.equal(parsed.findings[0].section, "UX");
});
test("audit generation result schema accepts v3 findings and aggregate outreach fields", () => {
const parsed = auditGenerationResultSchema.parse({
findings: [
{
skill_id: "contact-conversion",
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
public_phrasing:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
severity: 3,
evidence: "screenshot_mobile",
applies: true,
},
],
usedSkills: ["contact-conversion", "mobile-usability"],
publicAuditText:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
finalSummary: "Hohe Priorität: mobile Kontaktaufnahme sichtbarer machen.",
emailSubject: "Kurzer Blick auf euren Webauftritt",
emailBody: "Hallo, ich habe mir eure Website angesehen...",
phoneScript: "Ich habe mir kurz eure mobile Kontaktstrecke angesehen.",
ctaType: "anruf",
});
assert.equal(parsed.findings[0].skill_id, "contact-conversion");
assert.equal(parsed.findings[0].severity, 3);
assert.equal(parsed.findings[0].applies, true);
assert.deepEqual(parsed.usedSkills, ["contact-conversion", "mobile-usability"]);
});
test("audit classification schema accepts v3 findings and required used skills", () => {
const parsed = auditClassificationSchema.parse({
findings: [
{
skill_id: "contact-conversion",
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
public_phrasing:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
severity: 3,
evidence: "screenshot_mobile",
applies: true,
},
],
summary: "Kontaktaufnahme hat die höchste Priorität.",
usedSkills: ["contact-conversion"],
});
assert.equal(parsed.findings[0].skill_id, "contact-conversion");
assert.deepEqual(parsed.usedSkills, ["contact-conversion"]);
});
test("structured output schemas avoid optional top-level fields for OpenAI strict mode", () => {
const classificationPayload = {
findings: [
{
skill_id: "contact-conversion",
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
public_phrasing:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
severity: 3,
evidence: "screenshot_mobile",
applies: true,
},
],
summary: "Kontaktaufnahme hat die höchste Priorität.",
} as const;
assert.throws(
() => auditClassificationSchema.parse(classificationPayload),
/usedSkills|invalid|required/i,
);
assert.equal(
auditClassificationSchema.parse({
...classificationPayload,
usedSkills: null,
}).usedSkills,
null,
);
assert.throws(
() =>
followUpDraftSchema.parse({
message: "Kurzer Follow-up-Hinweis für nächste Woche.",
}),
/followInDays|goals|invalid|required/i,
);
const followParsed = followUpDraftSchema.parse({
message: "Kurzer Follow-up-Hinweis für nächste Woche.",
followInDays: null,
goals: null,
});
assert.equal(followParsed.followInDays, null);
assert.equal(followParsed.goals, null);
assert.throws(
() =>
qualityReviewSchema.parse({
isValid: true,
issues: [],
suggestions: [],
}),
/notes|invalid|required/i,
);
assert.equal(
qualityReviewSchema.parse({
isValid: true,
issues: [],
suggestions: [],
notes: null,
}).notes,
null,
);
});
test("audit classification schema rejects legacy-only finding payloads", () => {
assert.throws(
() =>
auditClassificationSchema.parse({
findings: [
{
section: "UX",
finding: "Landingpage is not responsive on mobile viewport.",
suggestion: "Add responsive breakpoints for cards and typography.",
},
],
summary: "Legacy payload.",
}),
/invalid|expected|required/i,
);
});
test("v3 finding severity only accepts internal priority levels 1 through 3", () => {
assert.throws(
() =>
auditGenerationResultSchema.parse({
findings: [
{
skill_id: "visual-design",
observation: "Kontrast ist gering.",
customer_benefit: "Bessere Lesbarkeit stärkt den ersten Eindruck.",
public_phrasing: "Ein staerkerer Kontrast wuerde die Lesbarkeit verbessern.",
severity: 4,
evidence: "screenshot_desktop",
applies: true,
},
],
usedSkills: ["visual-design"],
publicAuditText: "Ein staerkerer Kontrast wuerde die Lesbarkeit verbessern.",
finalSummary: "Kontrast priorisieren.",
emailSubject: "Kurzer Website-Hinweis",
emailBody: "Hallo...",
phoneScript: "Kurzer Gespraechseinstieg.",
ctaType: "anruf",
}),
/invalid input/i,
);
});
test("audit generation result schema rejects blank text fields and empty collections", () => {
const validPayload = {
findings: [
{
skill_id: "contact-conversion",
observation: "Die Telefonnummer ist mobil erst nach langem Scrollen sichtbar.",
customer_benefit: "Ein sichtbarer Kontaktweg senkt Reibung und erhöht Anfragen.",
public_phrasing:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
severity: 2,
evidence: "screenshot_mobile",
applies: true,
},
],
usedSkills: ["contact-conversion"],
publicAuditText:
"Mir ist aufgefallen, dass der Kontaktweg am Smartphone noch schneller erreichbar sein könnte.",
finalSummary: "Mobile Kontaktaufnahme sichtbarer machen.",
emailSubject: "Kurzer Blick auf euren Webauftritt",
emailBody: "Hallo, ich habe mir eure Website angesehen...",
phoneScript: "Ich habe mir kurz eure mobile Kontaktstrecke angesehen.",
ctaType: "termin",
};
assert.throws(
() =>
auditGenerationResultSchema.parse({
...validPayload,
publicAuditText: " ",
}),
/too small|invalid/i,
);
assert.throws(
() =>
auditGenerationResultSchema.parse({
...validPayload,
findings: [],
}),
/too small|invalid/i,
);
assert.throws(
() =>
auditGenerationResultSchema.parse({
...validPayload,
usedSkills: [],
}),
/too small|invalid/i,
);
assert.throws(
() =>
auditGenerationResultSchema.parse({
...validPayload,
findings: [
{
...validPayload.findings[0],
observation: "",
},
],
}),
/too small|invalid/i,
);
});
test("audit generation result schema only accepts documented cta types", () => {
const basePayload = {
findings: [
{
skill_id: "visual-design",
observation: "Die Schrift ist mobil klein.",
customer_benefit: "Lesbare Inhalte halten Besucher laenger auf der Seite.",
public_phrasing: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
severity: 1,
evidence: "screenshot_mobile",
applies: true,
},
],
usedSkills: ["visual-design"],
publicAuditText: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
finalSummary: "Mobile Lesbarkeit verbessern.",
emailSubject: "Kurzer Website-Hinweis",
emailBody: "Hallo...",
phoneScript: "Kurzer Gespraechseinstieg.",
};
for (const ctaType of ["anruf", "termin", "rueckruf"] as const) {
assert.equal(
auditGenerationResultSchema.parse({
...basePayload,
ctaType,
}).ctaType,
ctaType,
);
}
assert.throws(
() =>
auditGenerationResultSchema.parse({
...basePayload,
ctaType: "angebot",
}),
/invalid/i,
);
});
test("audit summary and public text schemas remain intentionally lightweight", () => {
const summaryParsed = auditSummarySchema.parse({
summary: "Kurze Zusammenfassung mit den wichtigsten Verbesserungen.",
@@ -72,6 +340,7 @@ test("outreach schemas parse German customer-facing payloads", () => {
isValid: true,
issues: [],
suggestions: ["Mehr Kundennutzen konkret beschreiben."],
notes: null,
});
assert.equal(typeof emailDraftParsed.body, "string");
@@ -118,12 +387,52 @@ test("schema-inferred types are exported for Convex action wiring", () => {
const typedFollowUp: FollowUpDraft = {
message: "Kurzes Follow-up ohne harte Floskel.",
followInDays: null,
goals: null,
};
const typedQuality: QualityReview = {
isValid: true,
issues: [],
suggestions: [],
notes: null,
};
const typedAuditGeneration: AuditGenerationResult = {
findings: [
{
skill_id: "visual-design",
observation: "Schrift ist mobil klein.",
customer_benefit: "Lesbare Inhalte halten Besucher laenger auf der Seite.",
public_phrasing: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
severity: 2,
evidence: "screenshot_mobile",
applies: true,
},
],
usedSkills: ["visual-design"],
publicAuditText: "Die mobile Schrift koennte an einigen Stellen lesbarer sein.",
finalSummary: "Mobile Lesbarkeit verbessern.",
emailSubject: "Kurzer Website-Hinweis",
emailBody: "Hallo...",
phoneScript: "Kurzer Gespraechseinstieg.",
ctaType: "anruf",
};
const typedClassification: AuditClassification = {
findings: [
{
skill_id: "contact-conversion",
observation: "Kontakt ist mobil spaet sichtbar.",
customer_benefit: "Schneller Kontakt senkt Reibung.",
public_phrasing: "Der Kontaktweg koennte mobil schneller sichtbar sein.",
severity: 2,
evidence: "screenshot_mobile",
applies: true,
},
],
summary: "Kontaktweg priorisieren.",
usedSkills: ["contact-conversion"],
};
assert.equal(typedFindings.findings.length, 1);
@@ -134,4 +443,6 @@ test("schema-inferred types are exported for Convex action wiring", () => {
assert.equal(typedCall.callScript.length, 1);
assert.equal(typedFollowUp.message.length > 0, true);
assert.equal(typedQuality.isValid, true);
assert.equal(typedAuditGeneration.usedSkills.length, 1);
assert.equal(typedClassification.findings.length, 1);
});

View File

@@ -32,6 +32,39 @@ function hasStageCall(schema: string) {
);
}
function extractFunctionSource(functionName: string) {
const marker = `function ${functionName}`;
const asyncMarker = `async function ${functionName}`;
const declarationIndex = actionSource.indexOf(marker) === -1
? actionSource.indexOf(asyncMarker)
: actionSource.indexOf(marker);
assert.notEqual(
declarationIndex,
-1,
`Expected function ${functionName} to exist.`,
);
const openBraceIndex = actionSource.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < actionSource.length; index += 1) {
const char = actionSource[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(end, -1, `Expected balanced braces for ${functionName}.`);
return actionSource.slice(declarationIndex, end + 1);
}
test("auditGenerationAction module exists and is a Node action file", () => {
assert.equal(existsSync(actionPath), true, "auditGenerationAction.ts should exist");
assert.equal(
@@ -130,7 +163,7 @@ test("action handles post-start failure paths in action-level catch", () => {
test("action calls generateObject with required schemas", () => {
const requiredSchemas = [
"internalFindingsSchema",
"auditClassificationSchema",
"auditSummarySchema",
"publicAuditTextSchema",
"emailDraftSchema",
@@ -149,6 +182,155 @@ test("action calls generateObject with required schemas", () => {
}
});
test("action loads v3 skill registry from v2 source for evidence input", () => {
assert.equal(
hasPattern(actionSource, /import\s*{[\s\S]*loadSkillsRegistry[\s\S]*}\s*from\s*["']\.\.\/lib\/skills-registry["']/),
true,
"Action should import loadSkillsRegistry from the shared registry parser.",
);
assert.equal(
hasPattern(actionSource, /loadSkillsRegistry\(\s*(?:join\()?[\s\S]*v2_elemente[\s\S]*skills\.md[\s\S]*\)/),
true,
"Action should load the v3 registry from v2_elemente/skills.md.",
);
assert.equal(
hasPattern(actionSource, /skillRegistry:\s*\[\s*\]/),
false,
"Action should not pass an always-empty skillRegistry to buildAuditEvidenceInput.",
);
});
test("registry load warning logging is isolated from fallback return", () => {
const loadRegistrySource = extractFunctionSource("loadAuditSkillRegistry");
assert.equal(
hasPattern(
loadRegistrySource,
/catch\s*\(error\)\s*{[\s\S]*try\s*{[\s\S]*appendRunEvent[\s\S]*}\s*catch\s*{[\s\S]*}\s*return\s*\[\s*\]/,
),
true,
"Registry load fallback should return [] even when warning event logging fails.",
);
});
test("persistAuditStage omits undefined fields from Convex mutation args", () => {
const persistSource = extractFunctionSource("persistAuditStage");
const mutationPayloadSource = persistSource.slice(
persistSource.indexOf("await ctx.runMutation"),
);
assert.doesNotMatch(
actionSource,
/persistAuditStage\(\s*{(?:(?!\n\s*}\s*\);)[\s\S])*(?:parsedJson|rawResponse|usage|finishReason|errorSummary):\s*undefined/,
"Call sites should not pass explicit undefined stage payload fields.",
);
assert.doesNotMatch(
persistSource,
/usage:\s*usage\s*\?\s*toPersistedUsage\(usage\)\s*:\s*undefined/,
"persistAuditStage should not emit usage: undefined.",
);
for (const field of [
"systemPrompt",
"rawResponse",
"parsedJson",
"finishReason",
"errorSummary",
]) {
assert.doesNotMatch(
mutationPayloadSource,
new RegExp(`\\n\\s*${field},`),
`persistAuditStage should conditionally spread ${field}.`,
);
}
});
test("OpenRouter usage payloads omit undefined token fields", () => {
const recordUsageSource = extractFunctionSource("recordOpenRouterUsage");
assert.match(
actionSource,
/function toPersistedUsage[\s\S]*usage\.inputTokens\s*!==\s*undefined[\s\S]*promptTokens:\s*usage\.inputTokens/,
"toPersistedUsage should omit promptTokens when inputTokens is undefined.",
);
assert.doesNotMatch(
recordUsageSource,
/tokens:\s*{[\s\S]*inputTokens:\s*args\.usage\.inputTokens/,
"recordOpenRouterUsage should not build token payloads with undefined properties.",
);
});
test("appendRunEvent omits undefined details from Convex mutation args", () => {
assert.doesNotMatch(
actionSource,
/ctx\.runMutation\(internal\.runs\.appendEventInternal,\s*{[\s\S]*\n\s*details:\s*args\.details,\n/,
"appendRunEvent should conditionally include details only when defined.",
);
});
test("success finishAuditGenerationRun omits undefined errorSummary", () => {
assert.doesNotMatch(
actionSource,
/finishAuditGenerationRun,\s*{[\s\S]*status:\s*["']succeeded["'][\s\S]*errorSummary:\s*qualityPassed\s*\?\s*undefined/,
"Succeeded finishAuditGenerationRun payload should not send errorSummary: undefined.",
);
});
test("quality review stage does not pass explicit undefined optional fields", () => {
assert.doesNotMatch(
actionSource,
/persistAuditStage\(\s*{[\s\S]*stage:\s*["']qualityReview["'][\s\S]*errorSummary:\s*qualityPassed\s*\?\s*undefined/,
"Quality persistAuditStage callsite should conditionally include errorSummary.",
);
});
test("persistAuditStage callsites conditionally include optional auditId", () => {
assert.doesNotMatch(
actionSource,
/await\s+persistAuditStage\(\s*{(?:(?!\n\s*}\s*\);)[\s\S])*\n\s*auditId,\n/,
"persistAuditStage callsites should spread auditId only when defined.",
);
});
test("audit generation helper callsites conditionally include optional auditId", () => {
assert.doesNotMatch(
actionSource,
/(?:recordOpenRouterUsage|captureExternalAuditArtifacts)\(\s*ctx,\s*{(?:(?!\n\s*}\s*\);)[\s\S])*\n\s*auditId,\n/,
"Helper callsites should spread auditId only when defined.",
);
assert.doesNotMatch(
actionSource,
/recordAuditUsageEvent\(\s*ctx,\s*{(?:(?!\n\s*}\s*\);)[\s\S])*\n\s*auditId:\s*args\.auditId,\n/,
"recordAuditUsageEvent callsites should spread args.auditId only when defined.",
);
});
test("persistAuditStage callsites avoid nested maybe-undefined usage objects", () => {
assert.doesNotMatch(
actionSource,
/persistAuditStage\(\s*{(?:(?!\n\s*}\s*\);)[\s\S])*usage:\s*{[\s\S]*?(?:inputTokens|outputTokens|totalTokens|cacheReadTokens):/,
"persistAuditStage callsites should use a usage helper or conditional spreads, not inline maybe-undefined usage objects.",
);
});
test("classification stage uses v3 audit classification schema", () => {
assert.equal(
hasPattern(actionSource, /auditClassificationSchema/),
true,
"Action should reference the v3 auditClassificationSchema.",
);
assert.equal(
hasStageCall("auditClassificationSchema"),
true,
"Classification generateObject call should validate v3 finding payloads.",
);
assert.equal(
hasStageCall("internalFindingsSchema"),
false,
"Classification should no longer validate against legacy-only internalFindingsSchema.",
);
});
test("action uses multimodal file parts with mediaType image/* when screenshots are available", () => {
assert.equal(
hasPattern(
@@ -190,14 +372,23 @@ test("action runs german copy guard and blocks outreach-ready on validation fail
assert.equal(
hasPattern(
actionSource,
/guardResult\.passed|qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
/qualityPassed\s*=\s*guardResult\.passed/,
),
true,
"Only deterministic German copy guard failures should hard-block the audit run.",
);
assert.equal(
hasPattern(actionSource, /api\.leads\.reviewUpdate/),
hasPattern(
actionSource,
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
),
false,
"Subjective model QA warnings should not be combined with guardResult for terminal failure.",
);
assert.equal(
hasPattern(actionSource, /internal\.leads\.reviewUpdateInternal/),
true,
"Action should patch lead via api.leads.reviewUpdate",
"Action should patch lead via internal.leads.reviewUpdateInternal",
);
assert.equal(
hasPattern(

View File

@@ -204,6 +204,34 @@ test("validateCustomerFacingCopy enforces observation + suggestion style", () =>
);
});
test("validateCustomerFacingCopy accepts live audit copy with noun suggestion and collaborative close", () => {
const result = validateCustomerFacingCopy({
auditSummary:
"Ich habe beobachtet, dass die Website von Diehl & Pape Rechtsanwälte zwar durch ihre klare Spezialisierung und umfassenden Kontaktinformationen überzeugt, jedoch durch langsame Ladezeiten und sichtbare Inhaltsverschiebungen beim Laden an Nutzerkomfort verliert. Ich schlage vor, gezielt die Ladegeschwindigkeit zu optimieren und das Seitenlayout stabil zu gestalten, um das Vertrauen potenzieller Mandanten zu stärken und die Nutzerbindung nachhaltig zu erhöhen.",
auditBody:
"Ich habe die Website von Diehl & Pape Rechtsanwälte genau betrachtet und festgestellt, dass die langsamen Ladezeiten und die sichtbaren Inhaltsverschiebungen beim Laden den ersten Eindruck deutlich beeinträchtigen. Mir ist aufgefallen, wie wichtig gerade für eine erfahrene Kanzlei mit klarer Spezialisierung ein reibungsloses Nutzererlebnis ist, um Vertrauen bei potenziellen Mandanten aufzubauen. Deshalb schlage ich vor, gezielt die Ladegeschwindigkeit zu optimieren und die Stabilität des Seitenlayouts zu verbessern.",
emailSubject:
"Ich habe beobachtet, dass die Website von Diehl & Pape Rechtsanwälte durch langsame Ladezeiten und sichtbare Inhaltsverschiebungen die Nutzererfahrung beeinträchtigt.",
emailBody:
"Ich habe die Website von Diehl & Pape Rechtsanwälte genau unter die Lupe genommen und festgestellt, dass die langsamen Ladezeiten und die sichtbaren Inhaltsverschiebungen beim Laden den ersten Eindruck deutlich trüben. Mein konkreter Vorschlag: Eine gezielte Optimierung der Ladegeschwindigkeit und eine Stabilisierung des Seitenlayouts könnten die Nutzerzufriedenheit erheblich steigern.",
callScript: {
openingLine:
"Ich habe die Website von Diehl & Pape Rechtsanwälte genau unter die Lupe genommen und dabei ein wichtiges Verbesserungspotenzial entdeckt.",
callScript: [
"Mir ist aufgefallen, dass die Seite beim Laden deutlich sichtbare Inhaltsverschiebungen zeigt.",
"Ich schlage vor, gezielt die Ladegeschwindigkeit zu optimieren und die Stabilität des Seitenlayouts zu verbessern.",
],
closeLine:
"Lassen Sie uns gemeinsam diese technischen Hürden beseitigen und Ihre Website zu einem überzeugenden Aushängeschild Ihrer Expertise machen.",
},
followUp:
"Ich habe beobachtet, dass die Website von Diehl & Pape Rechtsanwälte durch langsame Ladezeiten an Nutzerkomfort verliert. Mein konkreter Vorschlag ist, die Ladegeschwindigkeit gezielt zu optimieren und die Stabilität des Seitenlayouts sicherzustellen.",
});
assert.equal(result.passed, true);
assert.deepEqual(result.issues, []);
});
test("validateCustomerFacingCopy is permissive for phone numbers and date values", () => {
const result = validateCustomerFacingCopy({
auditSummary:

View File

@@ -195,7 +195,7 @@ test("queueLeadEnrichment uses lead-aware run index and does not use fixed-size
assert.equal(hasPattern(queueBody, /take\(50\)/), false, "No fixed-size .take(50) window in dedupe queries.");
});
test("website enrichment action uses Chromium desktop/mobile devices and runtime Playwright import", () => {
test("website enrichment action can still use Chromium desktop/mobile devices when configured", () => {
assert.equal(
hasPattern(
actionSource,
@@ -224,14 +224,6 @@ test("website enrichment action uses Chromium desktop/mobile devices and runtime
true,
"Action should reference TASK8_BROWSER_ASSET_URL when loading browser assets",
);
assert.equal(
hasPattern(
actionSource,
/TASK8_BROWSER_ASSET_URL[\s\S]{0,240}(throw|Error|required|missing|not configured|configured|konfiguriert|setze)/i,
),
true,
"Action should surface a clear error when the browser asset URL is not configured",
);
assert.equal(
hasPattern(actionSource, /import\("@sparticuz\/chromium"\)/),
false,
@@ -271,6 +263,84 @@ test("website enrichment action uses Chromium desktop/mobile devices and runtime
);
});
test("processLeadEnrichment uses browserless enrichment when Chromium source is missing", () => {
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
const fallbackGuardIndex = processBody.indexOf("if (!getChromiumExecutableSource())");
const playwrightLoadIndex = processBody.indexOf("loadPlaywrightModules()");
assert.notEqual(
fallbackGuardIndex,
-1,
"processLeadEnrichment should branch before Playwright bootstrap when no Chromium source is configured.",
);
assert.equal(
fallbackGuardIndex < playwrightLoadIndex,
true,
"The missing-Chromium fallback should run before loadPlaywrightModules().",
);
assert.equal(
hasPattern(
processBody,
/if \(!getChromiumExecutableSource\(\)\)\s*\{[\s\S]*processLeadEnrichmentWithoutBrowser\(/,
),
true,
"Missing browser asset config should call the browserless enrichment path instead of throwing.",
);
assert.equal(
hasPattern(actionSource, /async function processLeadEnrichmentWithoutBrowser\(/),
true,
"Action should expose a dedicated browserless enrichment helper.",
);
assert.equal(
hasPattern(
actionSource,
/Chromium ist nicht konfiguriert; Website-Enrichment nutzt browserlosen Fetch-Fallback\./,
),
true,
"The fallback should make the degraded mode visible in run events.",
);
});
test("browserless website enrichment persists crawl evidence without screenshots", () => {
const fallbackSource = actionSource.slice(
actionSource.indexOf("async function processLeadEnrichmentWithoutBrowser("),
);
assert.equal(
hasPattern(fallbackSource, /crawlPageWithoutBrowser\(/),
true,
"Browserless enrichment should fetch pages directly instead of launching Playwright.",
);
assert.equal(
hasPattern(
actionSource,
/function crawlPageWithoutBrowser[\s\S]*extractContactSignalsFromHtmlLikeText\(/,
),
true,
"Browserless enrichment should still extract contact signals from fetched page content.",
);
assert.equal(
hasPattern(fallbackSource, /internal\.websiteEnrichment\.persistLeadEnrichmentResult/),
true,
"Browserless enrichment should persist pages, links, email candidates, and technical checks.",
);
assert.equal(
hasPattern(fallbackSource, /screenshots:\s*\[\]/),
true,
"Browserless enrichment should not pretend screenshots exist.",
);
assert.equal(
hasPattern(fallbackSource, /status:\s*"succeeded"/),
true,
"A successful browserless crawl should finish the enrichment run as succeeded.",
);
assert.equal(
hasPattern(fallbackSource, /internal\.pageSpeed\.queueLeadPageSpeedAudit/),
true,
"Browserless enrichment should keep the downstream PageSpeed handoff.",
);
});
test("website enrichment action invalidates stale @sparticuz/chromium-min cache when source changes", () => {
assert.equal(
hasPattern(actionSource, /CHROMIUM_SOURCE_MARKER_FILE/),
@@ -518,7 +588,7 @@ test("failure handling marks run as failed and writes lead-facing reason", () =>
assert.equal(
hasPattern(
actionSource,
/runMutation\(\s*api\.runs\.appendEvent[\s\S]*?level:\s*"error"[\s\S]*?message:\s*"Website-Enrichment fehlgeschlagen/,
/runMutation\(\s*internal\.runs\.appendEventInternal[\s\S]*?level:\s*"error"[\s\S]*?message:\s*"Website-Enrichment fehlgeschlagen/,
),
true,
"Action should append a visible error event on failure",
@@ -541,6 +611,66 @@ test("failure handling marks run as failed and writes lead-facing reason", () =>
);
});
test("website enrichment action treats Playwright close operations as best-effort cleanup", () => {
assert.equal(
hasPattern(actionSource, /function isPlaywrightTargetClosedError\(/),
true,
"Action should centralize recognition of Playwright target/page/context/browser closed errors.",
);
assert.equal(
hasPattern(actionSource, /async function closePlaywrightResourceSafely\(/),
true,
"Action should centralize best-effort Playwright resource cleanup.",
);
assert.equal(
hasPattern(
actionSource,
/isPlaywrightTargetClosedError[\s\S]*Target page, context or browser has been closed/,
),
true,
"Target page/context/browser closed errors should be recognized explicitly.",
);
assert.equal(
hasPattern(
actionSource,
/closePlaywrightResourceSafely[\s\S]*console\.warn\(/,
),
true,
"Unexpected Playwright close failures should be swallowed with a warning.",
);
const directUnsafeClosePatterns = [
/finally\s*\{\s*await page\.close\(\);?\s*\}/,
/finally\s*\{[\s\S]*await desktopContext\.close\(\);/,
/finally\s*\{[\s\S]*await mobileContext\.close\(\);/,
/finally\s*\{[\s\S]*await browser\.close\(\);/,
];
for (const pattern of directUnsafeClosePatterns) {
assert.equal(
hasPattern(actionSource, pattern),
false,
`Playwright cleanup should not await close() directly in finally: ${pattern}`,
);
}
const safeCloseCalls = [
/closePlaywrightResourceSafely\(\s*page,\s*"homepage screenshot page"/,
/closePlaywrightResourceSafely\(\s*page,\s*"crawl page"/,
/closePlaywrightResourceSafely\(\s*desktopContext,\s*"desktop browser context"/,
/closePlaywrightResourceSafely\(\s*mobileContext,\s*"mobile browser context"/,
/closePlaywrightResourceSafely\(\s*browser,\s*"browser"/,
];
for (const pattern of safeCloseCalls) {
assert.equal(
hasPattern(actionSource, pattern),
true,
`Expected Playwright cleanup to use safe close helper: ${pattern}`,
);
}
});
test("website enrichment enforces TASK-8 crawler limits and runtime timeboxes", () => {
assert.equal(
hasPattern(actionSource, /TASK8_CRAWL_TIMEOUT_MS/g),
@@ -666,7 +796,7 @@ test("processLeadEnrichment records warning on PageSpeed queue failure and conti
assert.equal(
hasPattern(
processBody,
/try\s*\{[\s\S]*internal\.pageSpeed\.queueLeadPageSpeedAudit[\s\S]*\}\s*catch\s*\([^)]*\)\s*\{[\s\S]*api\.runs\.appendEvent[\s\S]*level:\s*"warning"/,
/try\s*\{[\s\S]*internal\.pageSpeed\.queueLeadPageSpeedAudit[\s\S]*\}\s*catch\s*\([^)]*\)\s*\{[\s\S]*internal\.runs\.appendEventInternal[\s\S]*level:\s*"warning"/,
),
true,
"Queueing PageSpeed should be wrapped in warning-safe try/catch",