292 lines
8.5 KiB
TypeScript
292 lines
8.5 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
|
|
import ts from "typescript";
|
|
|
|
const schemaPath = join(process.cwd(), "convex", "schema.ts");
|
|
const auditsPath = join(process.cwd(), "convex", "audits.ts");
|
|
const schemaSource = readFileSync(schemaPath, "utf8");
|
|
const auditsSource = readFileSync(auditsPath, "utf8");
|
|
|
|
const sourceFile = ts.createSourceFile(
|
|
"audits.ts",
|
|
auditsSource,
|
|
ts.ScriptTarget.ES2022,
|
|
true,
|
|
);
|
|
|
|
function extractTableSection(tableName: string) {
|
|
const marker = `${tableName}: defineTable({`;
|
|
const markerIndex = schemaSource.indexOf(marker);
|
|
assert.notEqual(
|
|
markerIndex,
|
|
-1,
|
|
`Expected schema table definition for ${tableName}.`,
|
|
);
|
|
|
|
const objectStart = schemaSource.indexOf("{", markerIndex);
|
|
let depth = 0;
|
|
let objectEnd = -1;
|
|
|
|
for (let index = objectStart; index < schemaSource.length; index += 1) {
|
|
if (schemaSource[index] === "{") {
|
|
depth += 1;
|
|
} else if (schemaSource[index] === "}") {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
objectEnd = index;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert.notEqual(
|
|
objectEnd,
|
|
-1,
|
|
`Could not parse schema object for ${tableName}.`,
|
|
);
|
|
const objectBlock = schemaSource.slice(objectStart, objectEnd + 1);
|
|
|
|
return { objectBlock };
|
|
}
|
|
|
|
function extractExportSource(name: string) {
|
|
const marker = `export const ${name} = `;
|
|
const declarationIndex = auditsSource.indexOf(marker);
|
|
assert.notEqual(
|
|
declarationIndex,
|
|
-1,
|
|
`Expected declaration for ${name}.`,
|
|
);
|
|
|
|
const openBraceIndex = auditsSource.indexOf("{", declarationIndex);
|
|
let depth = 0;
|
|
let end = -1;
|
|
|
|
for (let index = openBraceIndex; index < auditsSource.length; index += 1) {
|
|
const char = auditsSource[index];
|
|
if (char === "{") {
|
|
depth += 1;
|
|
} else if (char === "}") {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
end = index;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert.notEqual(
|
|
end,
|
|
-1,
|
|
`Expected balanced braces for export ${name}.`,
|
|
);
|
|
return auditsSource.slice(openBraceIndex, end + 1);
|
|
}
|
|
|
|
function extractFieldSection(source: string, fieldName: string, nextFieldName: string) {
|
|
const match = source.match(
|
|
new RegExp(
|
|
`${fieldName}:\\s*v\\.optional\\([\\s\\S]*?(?=\\s*${nextFieldName}:)`,
|
|
),
|
|
);
|
|
|
|
assert.notEqual(
|
|
match,
|
|
null,
|
|
`Expected ${fieldName} field with expected object structure in schema.`,
|
|
);
|
|
|
|
return match![0];
|
|
}
|
|
|
|
function hasPattern(source: string, pattern: RegExp, message: string) {
|
|
assert.equal(pattern.test(source), true, message);
|
|
}
|
|
|
|
test("audits schema stores compact usedSkills metadata", () => {
|
|
const { objectBlock } = extractTableSection("audits");
|
|
const usedSkillsSection = extractFieldSection(
|
|
objectBlock,
|
|
"usedSkills",
|
|
"skillSummaries",
|
|
);
|
|
const skillSummariesSection = extractFieldSection(
|
|
objectBlock,
|
|
"skillSummaries",
|
|
"multimodalSummary",
|
|
);
|
|
|
|
hasPattern(usedSkillsSection, /usedSkills:\s*v\.optional\(/, "usedSkills should be optional.");
|
|
hasPattern(
|
|
usedSkillsSection,
|
|
/name:\s*v\.string\(\)/,
|
|
"usedSkills.name should be string.",
|
|
);
|
|
hasPattern(
|
|
usedSkillsSection,
|
|
/id:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
|
"usedSkills.id should be optional string.",
|
|
);
|
|
hasPattern(
|
|
usedSkillsSection,
|
|
/category:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
|
"usedSkills.category should be optional string.",
|
|
);
|
|
hasPattern(
|
|
usedSkillsSection,
|
|
/version:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
|
"usedSkills.version should be optional string.",
|
|
);
|
|
hasPattern(
|
|
usedSkillsSection,
|
|
/source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
|
"usedSkills.source should be optional string.",
|
|
);
|
|
hasPattern(
|
|
usedSkillsSection,
|
|
/v\.array\(/,
|
|
"usedSkills should be an optional array of objects.",
|
|
);
|
|
hasPattern(
|
|
usedSkillsSection,
|
|
/v\.object\(/,
|
|
"usedSkills should be defined with v.object fields.",
|
|
);
|
|
|
|
hasPattern(skillSummariesSection, /skillSummaries:/, "skillSummaries should still exist.");
|
|
hasPattern(
|
|
skillSummariesSection,
|
|
/name:\s*v\.string\(\)/,
|
|
"skillSummaries.name should stay string.",
|
|
);
|
|
hasPattern(
|
|
skillSummariesSection,
|
|
/purpose:\s*v\.string\(\)/,
|
|
"skillSummaries.purpose should stay string.",
|
|
);
|
|
hasPattern(
|
|
skillSummariesSection,
|
|
/summary:\s*v\.string\(\)/,
|
|
"skillSummaries.summary should stay string.",
|
|
);
|
|
});
|
|
|
|
test("audits.create accepts usedSkills validator and persists metadata payloads", () => {
|
|
const createSource = extractExportSource("create");
|
|
|
|
hasPattern(
|
|
auditsSource,
|
|
/const usedSkillsValidator\s*=\s*v\.array\(/,
|
|
"audits.ts should define a reusable usedSkillsValidator.",
|
|
);
|
|
hasPattern(
|
|
auditsSource,
|
|
/v\.object\([\s\S]*?id:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?name:\s*v\.string\(\)[\s\S]*?category:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?version:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
|
"audits.ts should define reusable v3-compatible usedSkillsValidator fields.",
|
|
);
|
|
|
|
hasPattern(
|
|
createSource,
|
|
/usedSkills:\s*v\.optional\(usedSkillsValidator\)/,
|
|
"create args should include optional usedSkills field.",
|
|
);
|
|
hasPattern(
|
|
createSource,
|
|
/ctx\.db\.insert\(\s*["']audits["'][\s\S]*?args[\s\S]*\}/,
|
|
"create should persist audit payload from args (so usedSkills is stored when provided).",
|
|
);
|
|
});
|
|
|
|
test("audits.getDetail returns audit + lead context with null-safe lead lookup", () => {
|
|
const getDetailSource = extractExportSource("getDetail");
|
|
|
|
hasPattern(
|
|
getDetailSource,
|
|
/args:\s*{[\s\S]*id:\s*v\.id\(["']audits["']\)[\s\S]*}/,
|
|
"getDetail should require id argument for audits.",
|
|
);
|
|
hasPattern(
|
|
getDetailSource,
|
|
/const\s+audit\s*=\s*await\s+ctx\.db\.get\s*\(\s*args\.id\s*\)/,
|
|
"getDetail should load audit by id.",
|
|
);
|
|
hasPattern(
|
|
getDetailSource,
|
|
/if\s*\(\s*!audit\s*\)\s*{\s*return null;\s*}/,
|
|
"getDetail should return null when audit is missing.",
|
|
);
|
|
hasPattern(
|
|
getDetailSource,
|
|
/const\s+lead\s*=\s*await\s+ctx\.db\.get\s*\(\s*audit\.leadId\s*\)/,
|
|
"getDetail should load lead by leadId from the audit.",
|
|
);
|
|
hasPattern(
|
|
getDetailSource,
|
|
/return\s*{[\s\S]*audit,[\s\S]*lead,[\s\S]*sourceSummaries:[\s\S]*}/,
|
|
"getDetail should return audit, lead, and sourceSummaries.",
|
|
);
|
|
hasPattern(
|
|
getDetailSource,
|
|
/query\("auditFindings"\)[\s\S]*withIndex\("by_auditId"[\s\S]*eq\("auditId",\s*audit\._id\)[\s\S]*take\(DETAIL_EVIDENCE_LIMIT\)/,
|
|
"getDetail should load persisted findings by auditId.",
|
|
);
|
|
hasPattern(
|
|
getDetailSource,
|
|
/return\s*{[\s\S]*audit,[\s\S]*lead,[\s\S]*findings,[\s\S]*sourceSummaries:[\s\S]*}/,
|
|
"getDetail should return top-level findings for the detail UI.",
|
|
);
|
|
hasPattern(
|
|
sourceFile.getFullText(),
|
|
/export const getDetail = query\(/,
|
|
"audits.ts should export a getDetail query.",
|
|
);
|
|
});
|
|
|
|
test("audits.getDetail joins compact checked-page evidence from latest successful enrichment", () => {
|
|
const getDetailSource = extractExportSource("getDetail");
|
|
|
|
hasPattern(
|
|
getDetailSource,
|
|
/query\("agentRuns"\)[\s\S]*withIndex\("by_type_and_status_and_leadId"[\s\S]*eq\("type",\s*"website_enrichment"\)[\s\S]*eq\("status",\s*"succeeded"\)[\s\S]*eq\("leadId",\s*audit\.leadId\)[\s\S]*order\("desc"\)[\s\S]*take\(1\)/,
|
|
"getDetail should locate the latest successful website_enrichment run for the audit lead.",
|
|
);
|
|
|
|
for (const table of [
|
|
"websiteCrawlPages",
|
|
"websiteTechnicalChecks",
|
|
"websiteCrawlScreenshots",
|
|
]) {
|
|
hasPattern(
|
|
getDetailSource,
|
|
new RegExp(
|
|
`query\\("${table}"\\)[\\s\\S]*withIndex\\("by_runId"[\\s\\S]*eq\\("runId",\\s*enrichmentRunId\\)[\\s\\S]*take\\(DETAIL_EVIDENCE_LIMIT\\)`,
|
|
),
|
|
`${table} should be loaded from the bounded enrichment run evidence window.`,
|
|
);
|
|
}
|
|
|
|
hasPattern(
|
|
getDetailSource,
|
|
/audit\.checkedPages\.map\(/,
|
|
"getDetail should preserve audit.checkedPages as the canonical display order.",
|
|
);
|
|
hasPattern(
|
|
getDetailSource,
|
|
/fallbackCheckedPageEvidence/,
|
|
"getDetail should return checked-page fallback rows when enrichment evidence is missing.",
|
|
);
|
|
hasPattern(
|
|
getDetailSource,
|
|
/ctx\.storage\.getUrl\(screenshot\.storageId\)/,
|
|
"getDetail should resolve screenshot storage ids to display URLs.",
|
|
);
|
|
hasPattern(
|
|
getDetailSource,
|
|
/sourceSummaries:\s*{\s*checkedPages/,
|
|
"getDetail should expose checked page summaries under sourceSummaries.checkedPages.",
|
|
);
|
|
});
|