test(ci): close browser tiering evidence gaps

This commit is contained in:
2026-09-14 12:31:32 +08:00
parent aa9fa083df
commit d44ac7eef6
6 changed files with 323 additions and 60 deletions
@@ -36,9 +36,7 @@ jobs:
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" != "pull_request" ]]; then
echo "run_browser=true" >> "$GITHUB_OUTPUT"
echo "browser_tier=FULL" >> "$GITHUB_OUTPUT"
echo "browser_domain=" >> "$GITHUB_OUTPUT"
node scripts/classify-browser-impact.mjs </dev/null >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -67,39 +65,13 @@ jobs:
if: ${{ steps.scope.outputs.run_browser == 'true' }}
run: npx playwright install --with-deps chromium
- name: Browser E2E (FULL)
if: ${{ steps.scope.outputs.run_browser == 'true' && steps.scope.outputs.browser_tier == 'FULL' }}
env:
CI: "true"
run: npm run test:e2e
- name: Browser E2E (DOMAIN)
if: ${{ steps.scope.outputs.run_browser == 'true' && steps.scope.outputs.browser_tier == 'DOMAIN' }}
- name: Browser E2E
if: ${{ steps.scope.outputs.run_browser == 'true' }}
env:
CI: "true"
BROWSER_TIER: ${{ steps.scope.outputs.browser_tier }}
BROWSER_DOMAIN: ${{ steps.scope.outputs.browser_domain }}
shell: bash
run: |
set -euo pipefail
core=(tests/e2e/app-shell.spec.js)
case "$BROWSER_DOMAIN" in
ai)
domain=(tests/e2e/ai-*.spec.js tests/e2e/closure-ai-followup.spec.js)
;;
assessment)
domain=(tests/e2e/assessment-*.spec.js)
;;
workbench)
domain=(tests/e2e/workbench-*.spec.js)
;;
*)
echo "Unmapped DOMAIN browser suite: $BROWSER_DOMAIN" >&2
exit 1
;;
esac
npx playwright test "${core[@]}" "${domain[@]}"
run: node scripts/run-browser-verification.mjs
- name: Preview HTTP smoke
if: ${{ steps.scope.outputs.run_browser == 'true' }}
+71
View File
@@ -0,0 +1,71 @@
export const CORE_BROWSER_SUITES = Object.freeze([
"tests/e2e/app-shell.spec.js",
]);
export const DOMAIN_BROWSER_SUITES = Object.freeze({
ai: Object.freeze([
"tests/e2e/ai-assistant.spec.js",
"tests/e2e/ai-compaction.spec.js",
"tests/e2e/ai-cross-unit-history.spec.js",
"tests/e2e/ai-gateway-compaction.spec.js",
"tests/e2e/ai-markdown-rendering.spec.js",
"tests/e2e/ai-product-closure.spec.js",
"tests/e2e/ai-stream-termination.spec.js",
"tests/e2e/closure-ai-followup.spec.js",
"tests/e2e/conversation-rename-accessibility.spec.js",
"tests/e2e/deepseek-browser-settings.spec.js",
"tests/e2e/product-closure-ai.spec.js",
]),
assessment: Object.freeze([
"tests/e2e/assessment-lifecycle.spec.js",
"tests/e2e/assessment-management.spec.js",
"tests/e2e/product-closure-assessment.spec.js",
]),
workbench: Object.freeze([
"tests/e2e/accessibility.spec.js",
"tests/e2e/chapter-checkpoints.spec.js",
"tests/e2e/code-viewer.spec.js",
"tests/e2e/effects-cleanup.spec.js",
"tests/e2e/responsive.spec.js",
"tests/e2e/surface-boundaries.spec.js",
"tests/e2e/workbench-final-acceptance.spec.js",
"tests/e2e/workbench-integration.spec.js",
]),
});
export const SUPPORTED_BROWSER_DOMAINS = Object.freeze(
Object.keys(DOMAIN_BROWSER_SUITES).sort(),
);
export const ALL_BROWSER_SUITES = Object.freeze([
...CORE_BROWSER_SUITES,
...Object.values(DOMAIN_BROWSER_SUITES).flat(),
].sort());
export function getBrowserSuiteSelection(tier, domain = null) {
if (tier === "NONE") {
return {
executedSuites: [],
skippedSuites: [...ALL_BROWSER_SUITES],
};
}
if (tier === "FULL") {
return {
executedSuites: [...ALL_BROWSER_SUITES],
skippedSuites: [],
};
}
const domainSuites = DOMAIN_BROWSER_SUITES[domain];
if (tier !== "DOMAIN" || !domainSuites) {
throw new Error(`Unsupported browser verification selection: ${tier}:${domain ?? "none"}`);
}
const executedSuites = [...CORE_BROWSER_SUITES, ...domainSuites].sort();
const executedSuiteSet = new Set(executedSuites);
return {
executedSuites,
skippedSuites: ALL_BROWSER_SUITES.filter((suite) => !executedSuiteSet.has(suite)),
};
}
+92 -19
View File
@@ -1,6 +1,11 @@
import { appendFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { getBrowserImpactOwnership } from "../architecture/ownership-manifest.mjs";
import {
getBrowserSuiteSelection,
SUPPORTED_BROWSER_DOMAINS,
} from "./browser-verification-plan.mjs";
const DOC_ONLY_PREFIXES = ["docs/", ".codex/"];
const DOC_ONLY_ROOT_FILES = new Set([
@@ -8,7 +13,7 @@ const DOC_ONLY_ROOT_FILES = new Set([
"README.md",
"issue-rule.md",
]);
const DOMAIN_BROWSER_SUITES = new Set(["ai", "assessment", "workbench"]);
const DOMAIN_BROWSER_SUITES = new Set(SUPPORTED_BROWSER_DOMAINS);
function normalizePath(file) {
return String(file ?? "")
@@ -25,44 +30,64 @@ export function isDocumentationOnlyPath(file) {
return normalized.endsWith(".md") && !normalized.startsWith("src/");
}
function fullResult({ reason, triggeringPath = null }) {
function resultWithSuites(result) {
return {
runBrowser: true,
tier: "FULL",
domain: null,
reason,
triggeringPath,
...result,
...getBrowserSuiteSelection(result.tier, result.domain),
};
}
function fullResult({ changedFiles, detectedDomains, reason, triggeringPath = null }) {
return resultWithSuites({
runBrowser: true,
tier: "FULL",
domain: null,
changedFiles,
detectedDomains,
reason,
fallbackReason: reason,
triggeringPath,
});
}
export function classifyBrowserImpact(files) {
const normalized = files.map(normalizePath).filter(Boolean);
const normalized = [...new Set(files.map(normalizePath).filter(Boolean))].sort();
if (normalized.length === 0) {
return fullResult({ reason: "empty-or-unknown-change-set" });
return fullResult({
changedFiles: [],
detectedDomains: [],
reason: "empty-or-unknown-change-set",
});
}
const runtimePaths = normalized.filter((file) => !isDocumentationOnlyPath(file));
if (runtimePaths.length === 0) {
return {
return resultWithSuites({
runBrowser: false,
tier: "NONE",
domain: null,
changedFiles: normalized,
detectedDomains: [],
reason: "documentation-only",
fallbackReason: null,
triggeringPath: null,
};
});
}
const ownership = runtimePaths.map((file) => ({
file,
...getBrowserImpactOwnership(file),
}));
const detectedDomains = [...new Set(ownership.map(({ owner }) => owner))].sort();
const fullFallback = ownership.find(({ owner, browserImpact }) => (
browserImpact !== "domain" || !DOMAIN_BROWSER_SUITES.has(owner)
));
if (fullFallback) {
return fullResult({
changedFiles: normalized,
detectedDomains,
reason: `full-fallback:${fullFallback.owner}:${fullFallback.browserImpact}`,
triggeringPath: fullFallback.file,
});
@@ -71,19 +96,68 @@ export function classifyBrowserImpact(files) {
const domains = new Set(ownership.map(({ owner }) => owner));
if (domains.size !== 1) {
return fullResult({
changedFiles: normalized,
detectedDomains,
reason: "cross-domain-change-set",
triggeringPath: runtimePaths[0],
});
}
const [domain] = domains;
return {
return resultWithSuites({
runBrowser: true,
tier: "DOMAIN",
domain,
changedFiles: normalized,
detectedDomains,
reason: `domain-local:${domain}`,
fallbackReason: null,
triggeringPath: runtimePaths[0],
};
});
}
export function browserEvidenceLines(result) {
return [
`[browser-impact] changed_files=${JSON.stringify(result.changedFiles)}`,
`[browser-impact] detected_domains=${JSON.stringify(result.detectedDomains)}`,
`[browser-impact] selected_tier=${result.tier}`,
`[browser-impact] executed_suites=${JSON.stringify(result.executedSuites)}`,
`[browser-impact] skipped_suites=${JSON.stringify(result.skippedSuites)}`,
`[browser-impact] fallback_reason=${result.fallbackReason ?? "none"}`,
];
}
export function browserOutputLines(result) {
return [
`run_browser=${result.runBrowser}`,
`browser_tier=${result.tier}`,
`browser_domain=${result.domain ?? ""}`,
`browser_detected_domains=${JSON.stringify(result.detectedDomains)}`,
`browser_executed_suites=${JSON.stringify(result.executedSuites)}`,
`browser_skipped_suites=${JSON.stringify(result.skippedSuites)}`,
`browser_fallback_reason=${result.fallbackReason ?? ""}`,
];
}
function evidenceSummary(result) {
const displayList = (values) => (
values.length > 0
? values.map((value) => `\`${value}\``).join("<br>")
: "None"
);
return [
"### Browser verification decision",
"",
"| Evidence | Value |",
"| --- | --- |",
`| Changed files | ${displayList(result.changedFiles)} |`,
`| Detected domain(s) | ${displayList(result.detectedDomains)} |`,
`| Selected tier | \`${result.tier}\` |`,
`| Executed suites | ${displayList(result.executedSuites)} |`,
`| Skipped suites | ${displayList(result.skippedSuites)} |`,
`| Fallback reason | ${result.fallbackReason ? `\`${result.fallbackReason}\`` : "None"} |`,
"",
].join("\n");
}
async function readChangedFilesFromStdin() {
@@ -95,12 +169,11 @@ async function readChangedFilesFromStdin() {
async function main() {
const files = await readChangedFilesFromStdin();
const result = classifyBrowserImpact(files);
process.stdout.write(`run_browser=${result.runBrowser}\n`);
process.stdout.write(`browser_tier=${result.tier}\n`);
process.stdout.write(`browser_domain=${result.domain ?? ""}\n`);
process.stderr.write(
`[browser-impact] ${result.tier} ${result.reason}${result.triggeringPath ? `: ${result.triggeringPath}` : ""}\n`,
);
process.stdout.write(`${browserOutputLines(result).join("\n")}\n`);
process.stderr.write(`${browserEvidenceLines(result).join("\n")}\n`);
if (process.env.GITHUB_STEP_SUMMARY) {
await appendFile(process.env.GITHUB_STEP_SUMMARY, evidenceSummary(result));
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+39
View File
@@ -0,0 +1,39 @@
import { spawn } from "node:child_process";
import { getBrowserSuiteSelection } from "./browser-verification-plan.mjs";
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: "inherit" });
child.once("error", reject);
child.once("exit", (code, signal) => {
if (signal) {
reject(new Error(`Browser verification terminated by ${signal}`));
return;
}
resolve(code ?? 1);
});
});
}
const tier = process.env.BROWSER_TIER;
const domain = process.env.BROWSER_DOMAIN || null;
const { executedSuites } = getBrowserSuiteSelection(tier, domain);
console.log(`[browser-verification] tier=${tier}`);
console.log(`[browser-verification] domain=${domain ?? "none"}`);
console.log(`[browser-verification] suites=${JSON.stringify(executedSuites)}`);
if (tier === "FULL") {
process.exitCode = await run("npm", ["run", "test:e2e"]);
} else if (tier === "DOMAIN") {
process.exitCode = await run("npm", [
"exec",
"--",
"playwright",
"test",
...executedSuites,
]);
} else {
throw new Error(`Browser runner cannot execute tier ${tier ?? "missing"}`);
}
+115 -7
View File
@@ -1,25 +1,48 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { readFile, readdir } from "node:fs/promises";
import test from "node:test";
import {
ALL_BROWSER_SUITES,
CORE_BROWSER_SUITES,
DOMAIN_BROWSER_SUITES,
getBrowserSuiteSelection,
} from "../scripts/browser-verification-plan.mjs";
import {
browserEvidenceLines,
browserOutputLines,
classifyBrowserImpact,
isDocumentationOnlyPath,
} from "../scripts/classify-browser-impact.mjs";
function assertDecision(result, expected) {
for (const [key, value] of Object.entries(expected)) {
assert.deepEqual(result[key], value, `unexpected ${key}`);
}
}
test("AI, Assessment and Workbench owner-local changes select DOMAIN tier", () => {
for (const [file, domain] of [
["src/ai/useAiLearningAssistant.js", "ai"],
["src/assessment/application/useAssessmentApplication.js", "assessment"],
["src/workbench/WorkbenchShell.jsx", "workbench"],
]) {
assert.deepEqual(classifyBrowserImpact([file]), {
const result = classifyBrowserImpact([file]);
assertDecision(result, {
runBrowser: true,
tier: "DOMAIN",
domain,
changedFiles: [file],
detectedDomains: [domain],
reason: `domain-local:${domain}`,
fallbackReason: null,
triggeringPath: file,
});
assert.ok(result.executedSuites.includes("tests/e2e/app-shell.spec.js"));
assert.deepEqual(
result.executedSuites,
getBrowserSuiteSelection("DOMAIN", domain).executedSuites,
);
}
});
@@ -40,6 +63,9 @@ test("shared, high-risk, unmapped domain and unknown runtime surfaces fail close
assert.equal(result.runBrowser, true, `${file} must trigger browser verification`);
assert.equal(result.tier, "FULL", `${file} must conservatively use FULL`);
assert.equal(result.domain, null);
assert.equal(result.fallbackReason, result.reason);
assert.deepEqual(result.executedSuites, ALL_BROWSER_SUITES);
assert.deepEqual(result.skippedSuites, []);
}
});
@@ -49,11 +75,17 @@ test("cross-domain changes fall back to FULL even when both domains have focused
"src/assessment/application/useAssessmentApplication.js",
]);
assert.deepEqual(result, {
assertDecision(result, {
runBrowser: true,
tier: "FULL",
domain: null,
changedFiles: [
"src/ai/useAiLearningAssistant.js",
"src/assessment/application/useAssessmentApplication.js",
],
detectedDomains: ["ai", "assessment"],
reason: "cross-domain-change-set",
fallbackReason: "cross-domain-change-set",
triggeringPath: "src/ai/useAiLearningAssistant.js",
});
});
@@ -68,12 +100,17 @@ test("documentation-only changes keep the safe NONE fast path", () => {
];
assert.equal(files.every(isDocumentationOnlyPath), true);
assert.deepEqual(classifyBrowserImpact(files), {
assertDecision(classifyBrowserImpact(files), {
runBrowser: false,
tier: "NONE",
domain: null,
changedFiles: [...files].sort(),
detectedDomains: [],
reason: "documentation-only",
fallbackReason: null,
triggeringPath: null,
executedSuites: [],
skippedSuites: ALL_BROWSER_SUITES,
});
});
@@ -89,27 +126,98 @@ test("documentation mixed with one mapped domain preserves DOMAIN tier", () => {
});
test("empty or unavailable diff fails closed to FULL", () => {
assert.deepEqual(classifyBrowserImpact([]), {
assertDecision(classifyBrowserImpact([]), {
runBrowser: true,
tier: "FULL",
domain: null,
changedFiles: [],
detectedDomains: [],
reason: "empty-or-unknown-change-set",
fallbackReason: "empty-or-unknown-change-set",
triggeringPath: null,
executedSuites: ALL_BROWSER_SUITES,
skippedSuites: [],
});
});
test("classification is independent of changed-file ordering and duplicate paths", () => {
const first = classifyBrowserImpact([
"src/assessment/application/useAssessmentApplication.js",
"src/ai/useAiLearningAssistant.js",
]);
const second = classifyBrowserImpact([
"./src/ai/useAiLearningAssistant.js",
"src/assessment/application/useAssessmentApplication.js",
"src/ai/useAiLearningAssistant.js",
]);
assert.deepEqual(second, first);
});
test("every browser spec has exactly one explicit core or domain suite owner", async () => {
const discoveredSuites = (await readdir(new URL("../tests/e2e/", import.meta.url)))
.filter((file) => file.endsWith(".spec.js"))
.map((file) => `tests/e2e/${file}`)
.sort();
const declaredSuites = [
...CORE_BROWSER_SUITES,
...Object.values(DOMAIN_BROWSER_SUITES).flat(),
];
assert.equal(new Set(declaredSuites).size, declaredSuites.length, "suite ownership must be unique");
assert.deepEqual([...declaredSuites].sort(), discoveredSuites);
assert.deepEqual(ALL_BROWSER_SUITES, discoveredSuites);
});
test("classifier CLI emits complete machine and human-readable decision evidence", () => {
const decision = classifyBrowserImpact(["src/App.jsx"]);
const outputLines = browserOutputLines(decision);
const evidenceLines = browserEvidenceLines(decision);
for (const output of [
"run_browser=true",
"browser_tier=FULL",
"browser_domain=",
"browser_detected_domains=",
"browser_executed_suites=",
"browser_skipped_suites=[]",
"browser_fallback_reason=full-fallback:app-integration:full",
]) {
assert.ok(outputLines.some((line) => line.startsWith(output)), output);
}
for (const evidence of [
"changed_files=",
"detected_domains=",
"selected_tier=FULL",
"executed_suites=",
"skipped_suites=[]",
"fallback_reason=full-fallback:app-integration:full",
]) {
assert.ok(
evidenceLines.some((line) => line.startsWith(`[browser-impact] ${evidence}`)),
evidence,
);
}
});
test("Workbench required workflow consumes tier and domain outputs without changing check identity", async () => {
const workflow = await readFile(
new URL("../.github/workflows/workbench-integration-verify.yml", import.meta.url),
"utf8",
);
const runner = await readFile(
new URL("../scripts/run-browser-verification.mjs", import.meta.url),
"utf8",
);
assert.match(workflow, /name: Workbench Integration Verify \/ verify/);
assert.match(workflow, /node scripts\/classify-browser-impact\.mjs/);
assert.match(workflow, /steps\.scope\.outputs\.run_browser == 'true'/);
assert.match(workflow, /steps\.scope\.outputs\.browser_tier/);
assert.match(workflow, /steps\.scope\.outputs\.browser_domain/);
assert.match(workflow, /tests\/e2e\/app-shell\.spec\.js/);
assert.match(workflow, /npm run test:e2e/);
assert.match(workflow, /node scripts\/run-browser-verification\.mjs/);
assert.match(runner, /\["run", "test:e2e"\]/);
assert.match(runner, /getBrowserSuiteSelection/);
assert.doesNotMatch(workflow, /pull_request:\s*\n\s*paths:/);
assert.doesNotMatch(workflow, /continue-on-error:/);
assert.doesNotMatch(workflow, /playwright test .*--grep-invert/);
});
+1 -1
View File
@@ -51,5 +51,5 @@ test("browser verification keeps a code-side canonical entry aligned with Workbe
assert.match(packageJson.scripts["test:e2e:mock"], /npm run build/);
assert.match(packageJson.scripts["test:e2e:mock"], /npm run test:e2e/);
assert.match(workbenchWorkflow, /run: npm run build/);
assert.match(workbenchWorkflow, /run: npm run test:e2e/);
assert.match(workbenchWorkflow, /run: node scripts\/run-browser-verification\.mjs/);
});