fix: 补齐候选诊断环境与证据保护(09-06-release-gate-integrity)

这个提交包含在:
2026-09-08 07:21:19 +08:00
父节点 66f25b1fa8
当前提交 99167127b5
共修改 33 个文件,包含 2915 行新增和 67 行删除
+109
查看文件
@@ -0,0 +1,109 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import { atomicEnvironmentJson, namespaceFingerprint, verifyManagedEnvironment } from "./full-local-environment.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export function guestProbeFailures(observed) {
const failures = [];
if (!observed.freshContext || observed.initialSessionHint !== false) failures.push("context_not_fresh");
if (!observed.listenerBeforeNavigation || !observed.noRouteMocks) failures.push("observation_setup_invalid");
if (!observed.loginFormVisible) failures.push("login_form_not_observed");
if (observed.refreshRequestCount !== 0) failures.push("refresh_attempt_observed");
const samples = [observed.nativeOnlineSamples?.beforeNavigation, observed.nativeOnlineSamples?.documentStart, observed.nativeOnlineSamples?.afterForm];
if (samples.some(value => value !== true && value !== false)) failures.push("native_online_observation_missing");
if (samples.includes(false)) failures.push("native_online_observed_false");
if (observed.loginRequestCount !== 0) failures.push("unexpected_login_request");
return failures;
}
export async function observeGuestEntry(context, baseURL, timeout = 30000) {
const target = new URL("/login", baseURL);
if (target.protocol !== "http:" || target.hostname !== "127.0.0.1") throw new Error("environment_probe_requires_loopback");
const state = await context.storageState();
const observed = {
scope: "environment-probe", observationPoint: "guest-entry-black-box",
freshContext: state.cookies.length === 0 && state.origins.length === 0,
initialSessionHint: state.cookies.some(cookie => cookie.name === "has_session" && cookie.value === "1"),
listenerBeforeNavigation: false, noRouteMocks: true, loginFormVisible: false,
refreshRequestCount: 0, loginRequestCount: 0, refreshFailedCount: 0,
nativeOnlineSamples: { beforeNavigation: null, documentStart: null, afterForm: null },
documentPath: "/login", startedAt: new Date().toISOString(), valid: false,
};
const page = await context.newPage();
try {
page.on("request", request => {
const pathname = new URL(request.url()).pathname;
if (pathname === "/api/auth/refresh") observed.refreshRequestCount += 1;
if (pathname === "/api/auth/login") observed.loginRequestCount += 1;
});
page.on("requestfailed", request => { if (new URL(request.url()).pathname === "/api/auth/refresh") observed.refreshFailedCount += 1; });
page.on("console", message => {
if (observed.nativeOnlineSamples.documentStart !== null) return;
if (message.text() === "QA_ENV_DOCUMENT_ONLINE:true") observed.nativeOnlineSamples.documentStart = true;
if (message.text() === "QA_ENV_DOCUMENT_ONLINE:false") observed.nativeOnlineSamples.documentStart = false;
});
await page.addInitScript(() => { if (window === window.top) console.info(`QA_ENV_DOCUMENT_ONLINE:${navigator.onLine}`); });
observed.listenerBeforeNavigation = true;
observed.nativeOnlineSamples.beforeNavigation = await page.evaluate(() => navigator.onLine);
if (!observed.freshContext || observed.nativeOnlineSamples.beforeNavigation !== true) throw new Error("environment_guest_precondition_invalid");
const response = await page.goto(target.href, { waitUntil: "load", timeout });
observed.documentStatus = response?.status() ?? null;
for (const testId of ["login-identifier", "login-password", "login-submit"]) await page.getByTestId(testId).waitFor({ state: "visible", timeout });
observed.loginFormVisible = true;
observed.nativeOnlineSamples.afterForm = await page.evaluate(() => navigator.onLine);
observed.stayedOnLogin = new URL(page.url()).pathname === "/login";
observed.failures = guestProbeFailures(observed);
if (observed.documentStatus !== 200 || !observed.stayedOnLogin) observed.failures.push("candidate_login_document_invalid");
observed.valid = observed.failures.length === 0;
} catch (error) {
observed.failures = guestProbeFailures(observed);
observed.errorKind = error.name === "TimeoutError" ? "probe_timeout" : "probe_precondition_or_navigation_failed";
} finally {
observed.endedAt = new Date().toISOString();
await page.close();
}
return observed;
}
async function main(mode, baseURL) {
if (!["native", "guest"].includes(mode)) throw new Error("environment_probe_mode_invalid");
const { context: owner } = verifyManagedEnvironment();
const require = createRequire(path.join(root, "client/package.json"));
const { chromium } = require("@playwright/test");
const server = await chromium.launchServer({ headless: true, host: "127.0.0.1" });
let browser;
let context;
let tracing = false;
let result = { valid: false, scope: "environment-probe", unknown: true };
const output = path.join(owner.environmentDir, mode === "native" ? "native-online.json" : "guest-entry.json");
try {
const browserNamespace = namespaceFingerprint(server.process().pid);
const browserMatchesOwnedChild = browserNamespace === owner.ownedNamespace;
if (!browserMatchesOwnedChild) throw new Error("environment_browser_namespace_mismatch");
browser = await chromium.connect(server.wsEndpoint());
context = await browser.newContext({ serviceWorkers: "block" });
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
tracing = true;
if (mode === "native") {
const page = await context.newPage();
const online = await page.evaluate(() => navigator.onLine);
result = { valid: online === true, scope: "blank-page-native-probe", online, errorKind: online === true ? null : online === false ? "native_online_observed_false" : "native_online_observation_missing", browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true };
} else result = { ...await observeGuestEntry(context, baseURL), browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true };
verifyManagedEnvironment();
} catch (error) {
result.valid = false;
result.errorKind = error.message.startsWith("environment_") ? error.message : "environment_probe_failed";
} finally {
if (tracing) { try { await context.tracing.stop({ path: path.join(owner.environmentDir, `${mode}-trace.zip`) }); } catch { result.valid = false; result.traceFailed = true; } }
await context?.close().catch(() => {});
await browser?.close().catch(() => {});
await server.close();
atomicEnvironmentJson(output, result);
}
console.log(`[environment] ${mode} probe ${result.valid ? "PASS" : "FAIL"}`);
if (!result.valid) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main(process.argv[2], process.argv[3]).catch(error => { console.error(`[environment] ${error.message.startsWith("environment_") ? error.message : "environment_probe_failed"}`); process.exitCode = 1; });
}
+39
查看文件
@@ -1,11 +1,47 @@
#!/usr/bin/env node
import fs from "node:fs";
import { createHash } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = process.cwd();
const registeredEvidence = [{
document: ".trellis/tasks/archive/2026-07/07-18-small-team-internal-readiness/evidence.md",
directory: "output/playwright/07-18-small-team-internal-readiness",
count: 14,
}];
export function validateRegisteredEvidence(root = repoRoot, registrations = registeredEvidence) {
const result = { checked: 0, errors: [] };
for (const registration of registrations) {
let lines;
try { lines = fs.readFileSync(path.join(root, registration.document), "utf8").split(/\r?\n/); }
catch { result.errors.push(`evidence_document_missing:${registration.document}`); continue; }
const header = lines.findIndex(line => /^\|\s*截图\s*\|\s*SHA-256\s*\|\s*$/i.test(line));
if (header < 0 || !/^\|\s*-+\s*\|\s*-+\s*\|\s*$/.test(lines[header + 1] ?? "")) { result.errors.push(`evidence_table_missing:${registration.document}`); continue; }
const rows = [];
for (let index = header + 2; index < lines.length && lines[index].startsWith("|"); index += 1) rows.push(lines[index]);
if (rows.length !== registration.count) result.errors.push(`evidence_count_mismatch:${registration.document}`);
const names = new Set();
for (const row of rows) {
const match = row.match(/^\|\s*`([^`]+)`\s*\|\s*`([a-f0-9]{64})`\s*\|\s*$/i);
if (!match || !/^[\w.-]+\.png$/.test(match[1]) || match[1].startsWith(".") || names.has(match[1])) { result.errors.push(`evidence_row_invalid:${registration.document}`); continue; }
names.add(match[1]);
const relative = path.join(registration.directory, match[1]);
const target = path.join(root, relative);
try {
if (!fs.lstatSync(target).isFile() || fs.realpathSync(target) !== path.join(fs.realpathSync(root), relative)) { result.errors.push(`evidence_not_regular_file:${relative}`); continue; }
const observed = createHash("sha256").update(fs.readFileSync(target)).digest("hex");
if (observed !== match[2].toLowerCase()) result.errors.push(`evidence_digest_mismatch:${relative}`);
else result.checked += 1;
} catch { result.errors.push(`evidence_file_missing_or_unreadable:${relative}`); }
}
}
return result;
}
const requiredSpecs = [
".trellis/spec/hyb-oa/index.md",
".trellis/spec/hyb-oa/project-governance.md",
@@ -342,6 +378,9 @@ export function runGovernanceChecks() {
checkDocsRegistration(result);
checkRemovedProjectSkillReferences(result);
checkSensitiveData(result);
const evidence = validateRegisteredEvidence();
for (const error of evidence.errors) fail(result, "evidence.sha256", error);
if (!evidence.errors.length) pass(result, "evidence.sha256", `${evidence.checked} registered local evidence files verified`);
} catch (error) {
fail(result, "exception", error instanceof Error ? error.message : String(error));
}
+81
查看文件
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
qa_diagnostic_cleanup() {
local command_status=$?
local cleanup_status=0
local owned_server="$QA_SERVER_OWNED"
trap - EXIT
qa_cleanup_server || cleanup_status=1
if [[ "$owned_server" == "1" ]]; then
qa_wait_port_free 15 || cleanup_status=1
fi
if [[ "$QA_DB_CREATED" == "1" ]]; then
qa_dropdb || cleanup_status=1
local remaining
remaining="$(qa_psql -Atc "SELECT count(*) FROM pg_database WHERE datname = '$QA_DB_NAME'")" || cleanup_status=1
[[ "$remaining" == "0" ]] || cleanup_status=1
fi
node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify({phase:process.argv[2],commandExit:Number(process.argv[3]),cleanupExit:Number(process.argv[4]),database:process.argv[5]}))' \
"$QA_DIAGNOSTIC_STAGE_DIR/outcome.json" "$QA_DIAGNOSTIC_PHASE" "$command_status" "$cleanup_status" "$QA_DB_NAME"
if [[ "$command_status" != "0" ]]; then exit "$command_status"; fi
exit "$cleanup_status"
}
qa_run_diagnostic_stage() {
local stage="$1"
[[ -d "${QA_DIAGNOSTIC_STAGE_DIR:-}" && "$QA_DB_NAME" =~ ^hyb_oa_diag_[a-z0-9_]+$ ]] || exit 2
[[ "$QA_DB_HOST" == "127.0.0.1" && "$QA_DB_CLIENT" == "host" && "$QA_KEEP_DB" == "0" ]] || exit 2
[[ -n "${QA_DIAGNOSTIC_REASON:-}" && -n "${QA_DIAGNOSTIC_OPERATOR:-}" && -n "${QA_DIAGNOSTIC_APPROVAL_REF:-}" ]] || exit 2
QA_DIAGNOSTIC_PHASE=prepare
trap qa_diagnostic_cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
qa_require_cmd node
qa_require_cmd npm
qa_select_port
qa_export_app_env
case "$stage" in
inputs)
qa_prepare_full_inputs
node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify({root:process.argv[2],buildA:process.argv[3],buildB:process.argv[4],shaA:process.argv[5],shaB:process.argv[6]}))' \
"$QA_DIAGNOSTIC_STAGE_DIR/pwa-fixture.json" "$PWA_FIXTURE_ROOT" "$PWA_BUILD_A_DIR" "$PWA_BUILD_B_DIR" "$PWA_BUILD_A_SHA" "$PWA_BUILD_B_SHA"
;;
tools)
cd "$SERVER_DIR"
npm run prisma:generate
npm run prisma:check-generated
npm run prisma:validate
;;
server-unit)
cd "$SERVER_DIR"
QA_DIAGNOSTIC_PHASE=test
npm test
;;
typecheck)
cd "$CLIENT_DIR"
QA_DIAGNOSTIC_PHASE=test
npx tsc --noEmit --pretty false --extendedDiagnostics
;;
client-build)
qa_build_client
;;
workflow-actions|server-regressions|browser)
qa_require_cmd curl
qa_require_db_client
qa_prepare_db
if [[ "$stage" == "browser" ]]; then export JOBS_ENABLED="${JOBS_ENABLED_FOR_E2E:-true}"; else export JOBS_ENABLED=false; fi
qa_start_server
QA_DIAGNOSTIC_PHASE=test
cd "$SERVER_DIR"
case "$stage" in
workflow-actions) ./scripts/regression-workflow-actions.sh ;;
server-regressions) ./scripts/regression-all.sh ;;
browser)
PWA_FIXTURE_ROOT="$QA_DIAGNOSTIC_PWA_ROOT"
qa_run_full_browser --trace=on
;;
esac
;;
*) exit 2 ;;
esac
}
+249
查看文件
@@ -0,0 +1,249 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { closeSync, existsSync, lstatSync, readlinkSync, realpathSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const stagePlan = [
{ id: "inputs", dependencies: [] },
{ id: "tools", dependencies: [] },
{ id: "server-unit", dependencies: ["tools"] },
{ id: "typecheck", dependencies: [] },
{ id: "client-build", dependencies: [] },
{ id: "workflow-actions", dependencies: ["tools", "client-build"] },
{ id: "server-regressions", dependencies: ["tools", "client-build"] },
{ id: "browser", dependencies: ["inputs", "tools", "client-build"] },
];
function readJson(file) {
return JSON.parse(readFileSync(file, "utf8"));
}
function atomicJson(file, value) {
writeFileSync(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
renameSync(`${file}.tmp`, file);
}
function currentEnvironment(requireGuest = false) {
const result = spawnSync("node", [path.join(root, "scripts/qa/full-local-environment.mjs"), requireGuest ? "report-guest" : "report"], { encoding: "utf8", timeout: 30000 });
if (result.status !== 0) throw new Error("environment_global_precondition_failed");
return JSON.parse(result.stdout);
}
export function createDiagnosticScratch() {
return mkdtempSync(path.join(os.tmpdir(), "hyb-qa-inventory-"));
}
export function sourceSnapshot(directory = root) {
directory = realpathSync(directory);
const git = (args) => {
const result = spawnSync("git", args, { cwd: directory, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (result.status !== 0) throw new Error("diagnostic_source_unavailable");
return result.stdout;
};
const hash = value => createHash("sha256").update(value).digest("hex");
const snapshotFile = file => {
const target = path.join(directory, file);
let stat;
try { stat = lstatSync(target); } catch (error) { if (error.code === "ENOENT") return { file, kind: "missing", sha256: null }; throw error; }
if (realpathSync(path.dirname(target)) !== path.dirname(target)) return { file, kind: "unsupported", sha256: null };
const kind = stat.isSymbolicLink() ? "symlink" : stat.isFile() ? "file" : "unsupported";
return { file, kind, mode: stat.mode & 0o777, sha256: kind === "symlink" ? hash(readlinkSync(target)) : kind === "file" ? hash(readFileSync(target)) : null };
};
const files = git(["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "scripts/qa", "server", "client", ".trellis/spec"])
.split("\0").filter(Boolean).sort().map(file => ({ file, sha256: snapshotFile(file).sha256 }));
const trackedFiles = [...new Set(git(["ls-files", "--cached", "-z"]).split("\0").filter(Boolean))].sort().map(snapshotFile);
const porcelainV1Z = git(["status", "--porcelain=v1", "--untracked-files=all", "-z"]);
const indexEntriesZ = git(["ls-files", "--stage", "-z"]);
const indexFlagsZ = git(["ls-files", "-v", "-z"]);
const repository = {
porcelainV1Z, statusShort: git(["status", "--short", "--untracked-files=all"]),
trackedFiles, trackedDigest: hash(JSON.stringify(trackedFiles)),
indexEntriesZ, indexFlagsZ, indexDigest: hash(JSON.stringify([indexEntriesZ, indexFlagsZ])),
trackedCoverageComplete: trackedFiles.every(entry => entry.kind !== "unsupported"),
};
return {
commit: git(["rev-parse", "HEAD"]).trim(),
tree: git(["rev-parse", "HEAD^{tree}"]).trim(),
dirty: porcelainV1Z.length !== 0,
repository,
files,
digest: createHash("sha256").update(JSON.stringify(files)).digest("hex"),
};
}
export function compareSnapshots(before, after) {
const identityUnchanged = Boolean(before && after && before.commit === after.commit && before.tree === after.tree);
const sourceUnchanged = identityUnchanged && before.digest === after.digest;
const repositoryComparable = [before, after].every(snapshot => snapshot?.repository?.trackedCoverageComplete && /^[a-f0-9]{64}$/.test(snapshot.repository.trackedDigest ?? "") && /^[a-f0-9]{64}$/.test(snapshot.repository.indexDigest ?? "") && typeof snapshot.repository.porcelainV1Z === "string");
const repositoryUnchanged = Boolean(identityUnchanged && repositoryComparable && before.repository.trackedDigest === after.repository.trackedDigest && before.repository.indexDigest === after.repository.indexDigest && before.repository.porcelainV1Z === after.repository.porcelainV1Z);
const candidateIntegrityStatus = !repositoryComparable ? "UNKNOWN" : sourceUnchanged && repositoryUnchanged ? "UNCHANGED" : "CHANGED";
return { sourceUnchanged, repositoryComparable, repositoryUnchanged, candidateIntegrityStatus, cleanAtStart: before?.dirty === false, cleanAtEnd: after?.dirty === false };
}
export function browserInventory(discovery, execution) {
const actual = new Map();
const key = (entry) => JSON.stringify([entry.id, entry.projectName ?? "", entry.repeatEachIndex ?? 0]);
const issues = [];
function visit(suites) {
for (const suite of suites ?? []) {
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
const identity = { id: spec.id, projectName: test.projectName, repeatEachIndex: test.repeatEachIndex };
if (actual.has(key(identity))) issues.push("duplicate_case");
actual.set(key(identity), {
reportedStatus: test.status,
expectedStatus: test.expectedStatus,
results: (test.results ?? []).map(result => ({ status: result.status, retry: result.retry, duration: result.duration })),
});
}
}
visit(suite.suites);
}
}
visit(execution?.suites);
const cases = (discovery?.cases ?? []).map(entry => {
const observed = actual.get(key(entry));
actual.delete(key(entry));
return { ...entry, status: observed?.results.at(-1)?.status ?? "NOT_RUN", ...observed };
});
if (actual.size) issues.push("unknown_cases");
return { cases, issues, executionErrors: execution?.errors?.length ?? 0, reportPresent: Boolean(execution) };
}
async function main() {
const approval = {};
for (const name of ["REASON", "OPERATOR", "APPROVAL_REF"]) {
const value = process.env[`QA_DIAGNOSTIC_${name}`]?.trim();
if (!value || value.length > 500) throw new Error(`diagnostic_${name.toLowerCase()}_required`);
approval[name.toLowerCase()] = value;
}
if ((process.env.QA_DB_HOST ?? "127.0.0.1") !== "127.0.0.1" || (process.env.QA_DB_CLIENT ?? "host") !== "host") throw new Error("diagnostic_requires_local_host_database");
if (process.env.QA_KEEP_DB === "1" || process.env.QA_SKIP_CLIENT_BUILD === "1") throw new Error("diagnostic_reuse_not_allowed");
for (const name of ["PLAYWRIGHT_MANUAL", "R70_TRIALRUN_ASSIST", "R70_TRIALRUN_DEEP_ASSIST", "PLAYWRIGHT_OCR_LIVE_BATCH"]) {
if (process.env[name] === "1") throw new Error("diagnostic_scope_override_not_allowed");
}
const runId = `${Date.now()}_${randomBytes(5).toString("hex")}`;
const reportDir = path.resolve(process.env.QA_DIAGNOSTIC_REPORT_DIR ?? process.env.QA_FULL_REPORT_DIR ?? path.join(root, "artifacts/qa-flow/full-local-diagnostic", runId));
if (existsSync(reportDir) && readdirSync(reportDir).length) throw new Error("diagnostic_report_directory_not_empty");
mkdirSync(reportDir, { recursive: true, mode: 0o700 });
const scratch = createDiagnosticScratch();
const report = {
schemaVersion: 1, kind: "FULL_LOCAL_DIAGNOSTIC_NOT_RELEASE_RECEIPT", profile: "full-local-diagnostic", status: "PARTIAL",
runId, approval, startedAt: new Date().toISOString(), endedAt: null,
nodeVersion: process.version, ci: process.env.CI ?? null,
stages: stagePlan.map(stage => ({ ...stage, status: "NOT_RUN" })),
limits: ["No release eligibility, even with zero observed failures.", "Nested fail-fast and serial dependencies remain observable coverage gaps.", "server-regressions includes a separate workflow-actions invocation.", "Diagnostic duration is not normal full-local green-path duration."],
};
let interrupted = null;
let activeChild = null;
const save = () => atomicJson(path.join(reportDir, "diagnostic-results.json"), report);
const interrupt = (signal) => {
interrupted = signal;
if (activeChild?.pid) {
try { process.kill(-activeChild.pid, signal); } catch { report.signalDeliveryFailed = true; }
}
};
const onInt = () => interrupt("SIGINT");
const onTerm = () => interrupt("SIGTERM");
process.on("SIGINT", onInt);
process.on("SIGTERM", onTerm);
try {
report.environmentBefore = currentEnvironment();
report.sourceBefore = sourceSnapshot();
save();
for (const stage of report.stages) {
const blockedBy = stage.dependencies.filter(id => report.stages.find(item => item.id === id).status !== "PASS");
if (interrupted || blockedBy.length) {
stage.reason = interrupted ? "interrupted" : "prerequisite_failed";
stage.blockedBy = blockedBy;
save();
continue;
}
stage.environmentBefore = currentEnvironment();
const stageDir = path.join(reportDir, stage.id);
const temporary = path.join(scratch, stage.id);
mkdirSync(stageDir);
mkdirSync(temporary);
mkdirSync(path.join(temporary, "storage"));
const environment = {
...process.env,
QA_DB_HOST: "127.0.0.1", QA_DB_CLIENT: "host", QA_KEEP_DB: "0", QA_SKIP_CLIENT_BUILD: "0",
QA_DB_NAME: `hyb_oa_diag_${runId}_${stage.id.replaceAll("-", "_")}`,
QA_DIAGNOSTIC_STAGE: stage.id, QA_DIAGNOSTIC_STAGE_DIR: stageDir,
QA_FULL_REPORT_DIR: path.join(reportDir, "full-local-report"),
QA_SERVER_LOG: path.join(stageDir, "server.log"), QA_SERVER_PID_FILE: path.join(stageDir, "server.pid"),
TMPDIR: temporary, STORAGE_DIR: path.join(temporary, "storage"), STORAGE_DRIVER: "local",
QA_HOST: "127.0.0.1", JOBS_ENABLED: "false", E2E_MODE: "local",
};
if (stage.id === "browser") {
const fixture = readJson(path.join(reportDir, "inputs/pwa-fixture.json"));
if (!fixture.root.startsWith(`${path.join(scratch, "inputs")}${path.sep}`)) throw new Error("diagnostic_fixture_ownership_invalid");
Object.assign(environment, { QA_DIAGNOSTIC_PWA_ROOT: fixture.root, PWA_BUILD_A_DIR: fixture.buildA, PWA_BUILD_B_DIR: fixture.buildB, PWA_BUILD_A_SHA: fixture.shaA, PWA_BUILD_B_SHA: fixture.shaB });
}
stage.command = ["bash", "scripts/qa/run-web-gates.sh", "__full-local-diagnostic-stage", stage.id];
stage.database = environment.QA_DB_NAME;
stage.startedAt = new Date().toISOString();
stage.status = "RUNNING";
save();
console.log(`[diagnostic] ${stage.id} started`);
const descriptor = openSync(path.join(stageDir, "stage.log"), "w", 0o600);
const outcome = await new Promise(resolve => {
activeChild = spawn("bash", stage.command.slice(1), { cwd: root, env: environment, stdio: ["ignore", descriptor, descriptor], detached: true });
activeChild.once("error", () => resolve({ code: 1, signal: null, spawnFailed: true }));
activeChild.once("close", (code, signal) => resolve({ code, signal }));
});
activeChild = null;
closeSync(descriptor);
stage.endedAt = new Date().toISOString();
stage.durationMs = Date.parse(stage.endedAt) - Date.parse(stage.startedAt);
stage.exitCode = outcome.code;
stage.signal = outcome.signal;
const completion = path.join(stageDir, "outcome.json");
stage.completion = existsSync(completion) ? readJson(completion) : null;
stage.status = outcome.code === 0 && stage.completion?.cleanupExit === 0 ? "PASS" : "FAIL";
stage.failureKind = stage.status === "PASS" ? null : stage.completion?.cleanupExit ? "cleanup" : stage.completion?.phase === "test" ? "test" : "environment";
stage.environmentAfter = currentEnvironment(stage.id === "browser");
save();
console.log(`[diagnostic] ${stage.id} ${stage.status} exit=${outcome.code}`);
}
} catch (error) {
report.runnerError = error.message;
} finally {
report.interrupted = interrupted;
report.environmentQualified = Boolean(report.stages.find(stage => stage.id === "browser")?.environmentAfter?.bootstrapObservation?.valid) && !report.runnerError;
for (const stage of report.stages) {
if (stage.status === "NOT_RUN" && !stage.reason) stage.reason = interrupted ? "interrupted" : "runner_error";
}
const discoveryFile = path.join(reportDir, "full-local-report/discovery-coverage.json");
const browserFile = path.join(reportDir, "full-local-report/playwright-results.json");
let discovery = null;
try {
discovery = existsSync(discoveryFile) ? readJson(discoveryFile) : null;
report.browser = browserInventory(discovery, existsSync(browserFile) ? readJson(browserFile) : null);
} catch {
report.browser = browserInventory(discovery, null);
report.browser.issues.push("report_unreadable");
}
try { rmSync(scratch, { recursive: true }); report.scratchCleanup = "PASS"; } catch { report.scratchCleanup = "FAIL"; }
try {
report.sourceAfter = sourceSnapshot();
Object.assign(report, compareSnapshots(report.sourceBefore, report.sourceAfter));
} catch { report.repositorySnapshotError = "post_run_snapshot_unavailable"; report.repositoryUnchanged = false; report.candidateIntegrityStatus = "UNKNOWN"; }
report.endedAt = new Date().toISOString();
report.durationMs = Date.parse(report.endedAt) - Date.parse(report.startedAt);
report.exitCode = 2;
save();
process.off("SIGINT", onInt);
process.off("SIGTERM", onTerm);
}
console.log("[diagnostic] PARTIAL — no release eligibility");
process.exitCode = 2;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch(error => { console.error(`[diagnostic] ${error.message}`); process.exitCode = 1; });
}
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PROXY_PID=""
cleanup() {
local status=$?
trap - EXIT
if ! node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" complete; then status=1; fi
if [[ -n "$PROXY_PID" ]]; then
kill "$PROXY_PID" 2>/dev/null || true
wait "$PROXY_PID" 2>/dev/null || true
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
ip link set lo up
ip link add qaonline type veth peer name qapeer
ip addr add 192.0.2.1/30 dev qaonline
ip addr add 192.0.2.2/30 dev qapeer
ip link set qaonline up
ip link set qapeer up
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" initialize
node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" native
mkdir "$QA_MANAGED_SCRATCH/tmp" "$QA_MANAGED_SCRATCH/storage"
export TMPDIR="$QA_MANAGED_SCRATCH/tmp" STORAGE_DIR="$QA_MANAGED_SCRATCH/storage"
python3 "$ROOT_DIR/scripts/qa/local-postgres-proxy.py" "$QA_MANAGED_SOCKET/.s.PGSQL.5432" &
PROXY_PID=$!
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify
bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" "$1"
+258
查看文件
@@ -0,0 +1,258 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { existsSync, readFileSync, readlinkSync, readdirSync, mkdirSync, mkdtempSync, chmodSync, writeFileSync, renameSync, rmSync } from "node:fs";
import os from "node:os";
import { createServer, createConnection } from "node:net";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { sourceSnapshot, compareSnapshots } from "./full-local-diagnostic.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const policyFiles = ["scripts/qa/full-local-environment.mjs", "scripts/qa/full-local-environment-inner.sh", "scripts/qa/local-postgres-proxy.py", "scripts/qa/browser-environment-probe.mjs", "scripts/qa/run-web-gates.sh", "scripts/qa/full-local-diagnostic.mjs", "client/playwright.config.ts"];
const sha = value => createHash("sha256").update(value).digest("hex");
const readJson = file => JSON.parse(readFileSync(file, "utf8"));
export function atomicEnvironmentJson(file, value) {
writeFileSync(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
renameSync(`${file}.tmp`, file);
}
function command(program, args) {
const result = spawnSync(program, args, { encoding: "utf8", timeout: 30000 });
if (result.status !== 0) throw new Error(`environment_command_failed:${path.basename(program)}`);
return result.stdout.trim();
}
export function environmentPolicyDigest() {
return sha(JSON.stringify(policyFiles.map(file => [file, sha(readFileSync(path.join(root, file)))])));
}
export function namespaceFingerprint(pid = "self") {
try { return sha(readlinkSync(`/proc/${pid}/ns/net`)); }
catch (error) { throw new Error(`environment_namespace_unreadable:${pid === "self" ? "self" : "other"}:${error.code ?? error.name}`); }
}
function parentPid(pid) {
const value = readFileSync(`/proc/${pid}/stat`, "utf8");
return Number(value.slice(value.lastIndexOf(")") + 2).split(" ")[1]);
}
function descendsFrom(pid, ancestor) {
const visited = new Set();
while (pid > 1 && !visited.has(pid)) {
if (pid === ancestor) return true;
visited.add(pid);
pid = parentPid(pid);
}
return false;
}
export async function requestSupervisorObservation(context) {
return await new Promise((resolve, reject) => {
const socket = createConnection(path.join(context.scratch, "supervisor.sock"));
let response = "";
socket.setTimeout(5000, () => socket.destroy(new Error("environment_supervisor_timeout")));
socket.on("error", () => reject(new Error("environment_supervisor_unavailable")));
socket.on("data", data => {
response += data;
if (response.length > 4096) socket.destroy(new Error("environment_supervisor_response_invalid"));
});
socket.on("end", () => {
try {
const observation = JSON.parse(response);
if (observation.runId !== context.runId || observation.pid !== context.supervisorPid || typeof observation.namespace !== "string" || observation.policyDigest !== context.policyDigest) throw new Error();
resolve(observation);
} catch { reject(new Error("environment_supervisor_response_invalid")); }
});
});
}
export function topologyMatches(addresses, routes, ipv6Routes) {
const names = addresses.map(entry => entry.ifname).sort();
if (JSON.stringify(names) !== JSON.stringify(["lo", "qaonline", "qapeer"])) return false;
for (const [name, address] of [["qaonline", "192.0.2.1"], ["qapeer", "192.0.2.2"]]) {
const entry = addresses.find(item => item.ifname === name);
if (!entry.flags?.includes("UP") || entry.operstate !== "UP" || !entry.addr_info?.some(item => item.local === address && item.prefixlen === 30)) return false;
}
return ![...routes, ...ipv6Routes].some(entry => entry.dst === "default" || entry.gateway);
}
function topology() {
return topologyMatches(JSON.parse(command("ip", ["-j", "address", "show"])), JSON.parse(command("ip", ["-j", "route", "show", "table", "all"])), JSON.parse(command("ip", ["-j", "-6", "route", "show", "table", "all"])));
}
export function validateEnvironmentFacts(context, facts) {
const failures = [];
if (context.schemaVersion !== 1 || context.profile !== "isolated-online-v1") failures.push("profile_invalid");
if (context.policyDigest !== facts.policyDigest) failures.push("policy_changed");
if (!facts.supervisorRecognized || !facts.descendantOfSupervisor) failures.push("supervisor_unverified");
if (context.inheritedNamespace !== facts.supervisorNamespace) failures.push("supervisor_namespace_changed");
if (!context.ownedNamespace || context.ownedNamespace === context.inheritedNamespace || context.ownedNamespace !== facts.currentNamespace) failures.push("namespace_unowned");
if (!facts.topologyMatched) failures.push("topology_invalid");
return failures;
}
export function verifyManagedEnvironment(contextFile = process.env.QA_ENVIRONMENT_CONTEXT, initialize = false) {
if (!contextFile || !existsSync(contextFile)) throw new Error("environment_context_required");
const context = readJson(contextFile);
const actualFile = path.resolve(contextFile);
if (actualFile !== path.join(context.environmentDir, "context.private.json")) throw new Error("environment_context_path_invalid");
const supervisorArgs = readFileSync(`/proc/${context.supervisorPid}/cmdline`, "utf8").split("\0");
const supervisor = JSON.parse(command(process.execPath, [path.join(root, "scripts/qa/full-local-environment.mjs"), "observe-supervisor", actualFile]));
const facts = {
policyDigest: environmentPolicyDigest(),
supervisorRecognized: supervisorArgs.includes(path.join(root, "scripts/qa/full-local-environment.mjs")),
descendantOfSupervisor: descendsFrom(process.pid, context.supervisorPid),
supervisorNamespace: supervisor.namespace,
supervisorObservation: supervisor,
currentNamespace: namespaceFingerprint(),
topologyMatched: topology(),
};
if (initialize && !context.ownedNamespace) context.ownedNamespace = facts.currentNamespace;
const failures = validateEnvironmentFacts(context, facts);
if (failures.length) throw new Error(`environment_invalid:${failures.join(",")}`);
if (initialize) atomicEnvironmentJson(actualFile, context);
return { context, facts };
}
export function validateEnvironmentReceipt(receipt, digest, requireGuest = true) {
const failures = [];
if (!receipt || receipt.schemaVersion !== 1 || receipt.profile !== "isolated-online-v1" || receipt.policyDigest !== digest) failures.push("environment_receipt_policy_invalid");
const namespace = receipt?.namespace;
if (receipt?.supervisorObservation?.source !== "live-supervisor-unix-ipc" || !Number.isFinite(Date.parse(receipt?.supervisorObservation?.sampledAt)) || receipt?.supervisorObservation?.namespace !== namespace?.inheritedRunner) failures.push("supervisor_observation_invalid");
if (!namespace?.inheritedRunner || !namespace.ownedChild || namespace.inheritedRunner === namespace.ownedChild || namespace.runnerCreated !== true || namespace.currentMatchesOwnedChild !== true || namespace.topologyMatched !== true) failures.push("environment_receipt_namespace_invalid");
if (receipt?.nativeOnlineProbe?.browserNamespace !== namespace?.ownedChild || receipt?.nativeOnlineProbe?.noNavigatorOverride !== true) failures.push("native_probe_namespace_invalid");
if (!receipt?.nativeOnlineProbe?.valid || receipt.nativeOnlineProbe.online !== true || !receipt.nativeOnlineProbe.browserMatchesOwnedChild) failures.push("native_probe_invalid");
if (requireGuest) {
const observed = receipt?.bootstrapObservation;
if (observed?.browserNamespace !== namespace?.ownedChild || observed?.noNavigatorOverride !== true || observed?.loginRequestCount !== 0 || observed?.documentStatus !== 200 || observed?.stayedOnLogin !== true) failures.push("guest_probe_evidence_invalid");
if (!observed || observed.observationPoint !== "guest-entry-black-box" || !observed.freshContext || observed.initialSessionHint !== false || !observed.listenerBeforeNavigation || !observed.noRouteMocks || !observed.loginFormVisible || observed.refreshRequestCount !== 0 || observed.nativeOnlineSamples?.beforeNavigation !== true || observed.nativeOnlineSamples?.documentStart !== true || observed.nativeOnlineSamples?.afterForm !== true || !observed.browserMatchesOwnedChild || !observed.valid) failures.push("guest_probe_invalid");
}
return failures;
}
export function environmentReceipt(contextFile = process.env.QA_ENVIRONMENT_CONTEXT, requireGuest = false) {
const { context, facts } = verifyManagedEnvironment(contextFile);
const nativeFile = path.join(context.environmentDir, "native-online.json");
const guestFile = path.join(context.environmentDir, "guest-entry.json");
const receipt = {
schemaVersion: 1, profile: context.profile, policyDigest: context.policyDigest,
platform: process.platform, runnerDigest: sha(readFileSync(path.join(root, "scripts/qa/full-local-environment.mjs"))), probeDigest: sha(readFileSync(path.join(root, "scripts/qa/browser-environment-probe.mjs"))),
supervisorObservation: { source: "live-supervisor-unix-ipc", sampledAt: facts.supervisorObservation.sampledAt, namespace: facts.supervisorNamespace },
namespace: { inheritedRunner: context.inheritedNamespace, ownedChild: context.ownedNamespace, currentMatchesOwnedChild: facts.currentNamespace === context.ownedNamespace, topologyMatched: facts.topologyMatched, runnerCreated: true },
nativeOnlineProbe: existsSync(nativeFile) ? readJson(nativeFile) : null,
bootstrapObservation: existsSync(guestFile) ? readJson(guestFile) : null,
};
const failures = validateEnvironmentReceipt(receipt, context.policyDigest, requireGuest);
if (failures.length) throw new Error(`environment_invalid:${failures.join(",")}`);
return receipt;
}
async function launch(mode) {
if (!["full-local", "full-local-diagnostic"].includes(mode)) throw new Error("environment_mode_invalid");
if (process.platform !== "linux") throw new Error("environment_platform_unsupported");
if (mode === "full-local-diagnostic") for (const name of ["REASON", "OPERATOR", "APPROVAL_REF"]) {
const value = process.env[`QA_DIAGNOSTIC_${name}`]?.trim();
if (!value || value.length > 500) throw new Error(`diagnostic_${name.toLowerCase()}_required`);
}
const runId = `${Date.now()}-${randomBytes(5).toString("hex")}`;
const base = path.resolve(process.env.QA_DIAGNOSTIC_REPORT_DIR ?? process.env.QA_FULL_REPORT_DIR ?? path.join(root, "artifacts/qa-flow", mode, runId));
if (!base.startsWith(`${path.join(root, "artifacts/qa-flow")}${path.sep}`)) throw new Error("environment_report_outside_artifacts");
if (existsSync(base) && readdirSync(base).length) throw new Error("environment_report_not_empty");
mkdirSync(base, { recursive: true, mode: 0o700 });
const environmentDir = path.join(base, "environment");
mkdirSync(environmentDir);
const scratch = mkdtempSync(path.join(os.tmpdir(), "hyb-qa-managed-"));
const socket = path.join(scratch, "socket");
mkdirSync(socket); chmodSync(scratch, 0o755); chmodSync(socket, 0o777);
const owner = `hyb-qa-${runId}`;
const record = { schemaVersion: 1, profile: "isolated-online-v1", policyDigest: environmentPolicyDigest(), platform: process.platform, mode, startedAt: new Date().toISOString(), status: "RUNNING", normalReleaseEligible: false };
const context = { schemaVersion: 1, profile: record.profile, policyDigest: record.policyDigest, runId, supervisorPid: process.pid, inheritedNamespace: namespaceFingerprint(), ownedNamespace: null, environmentDir, scratch, socket, mode };
const contextFile = path.join(environmentDir, "context.private.json");
atomicEnvironmentJson(contextFile, context);
let containerId = null;
let child = null;
let exitCode = 1;
let interrupted = null;
let supervisorServer = null;
const forward = signal => { interrupted = signal; if (child?.pid) { try { process.kill(-child.pid, signal); } catch {} } };
const onInt = () => forward("SIGINT");
const onTerm = () => forward("SIGTERM");
process.on("SIGINT", onInt); process.on("SIGTERM", onTerm);
try {
record.sourceBefore = sourceSnapshot();
const image = command("docker", ["image", "inspect", "--format", "{{.Id}}", "postgres:15-alpine"]);
containerId = command("docker", ["run", "--detach", "--pull=never", "--name", owner, "--label", `hyb.qa.owner=${owner}`, "--network", "none", "--tmpfs", "/var/lib/postgresql/data:rw,size=512m", "--mount", `type=bind,src=${socket},dst=/var/run/postgresql`, "-e", "POSTGRES_HOST_AUTH_METHOD=trust", image]);
if (!/^[a-f0-9]{64}$/.test(containerId)) throw new Error("environment_container_id_invalid");
record.databaseImageId = image;
writeFileSync(path.join(environmentDir, "container.private"), containerId, { mode: 0o600 });
let ready = false;
for (let attempt = 0; attempt < 60 && !interrupted; attempt += 1) {
if (spawnSync("docker", ["exec", containerId, "pg_isready", "-U", "postgres"], { stdio: "ignore", timeout: 10000 }).status === 0) { ready = true; break; }
await new Promise(resolve => setTimeout(resolve, 500));
}
if (!ready) throw new Error("environment_database_not_ready");
supervisorServer = createServer(connection => {
connection.on("error", () => connection.destroy());
try {
connection.end(JSON.stringify({ runId, pid: process.pid, namespace: namespaceFingerprint(), policyDigest: environmentPolicyDigest(), sampledAt: new Date().toISOString() }));
} catch { connection.destroy(); }
});
await new Promise((resolve, reject) => {
supervisorServer.once("error", reject);
supervisorServer.listen(path.join(scratch, "supervisor.sock"), resolve);
});
chmodSync(path.join(scratch, "supervisor.sock"), 0o600);
const env = {};
for (const name of ["PATH", "HOME", "LANG", "TERM", "CI", "NODE_OPTIONS", "PAYMENT_ORDER_PYTHON_BIN", "QA_DIAGNOSTIC_REASON", "QA_DIAGNOSTIC_OPERATOR", "QA_DIAGNOSTIC_APPROVAL_REF"]) if (process.env[name] !== undefined) env[name] = process.env[name];
Object.assign(env, { QA_ENVIRONMENT_CONTEXT: contextFile, QA_FULL_REPORT_DIR: path.join(base, "results"), QA_DIAGNOSTIC_REPORT_DIR: path.join(base, "results"), QA_MANAGED_SCRATCH: scratch, QA_MANAGED_SOCKET: socket, QA_HOST: "127.0.0.1", QA_PORT: "23000", QA_DB_HOST: "127.0.0.1", QA_DB_PORT: "25432", QA_DB_CLIENT: "host", QA_DB_USER: "postgres", QA_DB_PASSWORD: "local_qa_only", QA_KEEP_DB: "0", QA_SKIP_CLIENT_BUILD: "0", E2E_MODE: "local", WECOM_ENABLED: "false", EMAIL_ENABLED: "false", OIDC_ENABLED: "false", STORAGE_DRIVER: "local", NODE_ENV: "test", npm_config_offline: "true", NO_COLOR: "1" });
const outcome = await new Promise(resolve => {
child = spawn("unshare", ["-Urn", "bash", path.join(root, "scripts/qa/full-local-environment-inner.sh"), mode], { cwd: root, env, stdio: "inherit", detached: true });
child.once("error", () => resolve({ code: 1, signal: null }));
child.once("close", (code, signal) => resolve({ code, signal }));
});
child = null;
exitCode = outcome.code ?? 1;
record.childExit = outcome.code; record.childSignal = outcome.signal;
const completionFile = path.join(environmentDir, "completed.json");
if (existsSync(completionFile)) {
const completion = readJson(completionFile);
record.environment = completion;
record.status = validateEnvironmentReceipt(completion, environmentPolicyDigest(), true).length ? "ENVIRONMENT_INVALID" : "PASS";
} else record.status = "ENVIRONMENT_INVALID";
if (record.status !== "PASS") exitCode = mode === "full-local-diagnostic" ? 2 : 1;
} catch (error) {
record.status = "ENVIRONMENT_INVALID";
record.error = error.message.startsWith("environment_") ? error.message : "environment_internal_error";
exitCode = mode === "full-local-diagnostic" ? 2 : 1;
} finally {
if (supervisorServer) await new Promise(resolve => supervisorServer.close(resolve));
record.cleanup = { containerOwnerVerified: false, containerRemoved: containerId === null, scratchRemoved: false };
if (containerId) {
try {
const identity = command("docker", ["inspect", "--format", '{{.Id}}|{{index .Config.Labels "hyb.qa.owner"}}|{{.HostConfig.NetworkMode}}', containerId]);
if (identity !== `${containerId}|${owner}|none`) throw new Error("environment_cleanup_owner_mismatch");
record.cleanup.containerOwnerVerified = true;
spawnSync("docker", ["exec", containerId, "chmod", "0777", "/var/run/postgresql"], { stdio: "ignore", timeout: 10000 });
command("docker", ["rm", "-f", containerId]);
record.cleanup.containerRemoved = true;
} catch { record.cleanup.error = "owned_container_cleanup_failed"; }
}
if (record.cleanup.containerRemoved) {
try { rmSync(scratch, { recursive: true }); record.cleanup.scratchRemoved = true; } catch { record.cleanup.error = "owned_scratch_cleanup_failed"; }
}
if (!record.cleanup.containerRemoved || !record.cleanup.scratchRemoved) { record.status = "ENVIRONMENT_INVALID"; exitCode = mode === "full-local-diagnostic" ? 2 : 1; }
try {
record.sourceAfter = sourceSnapshot();
Object.assign(record, compareSnapshots(record.sourceBefore, record.sourceAfter));
if (record.candidateIntegrityStatus !== "UNCHANGED") exitCode = mode === "full-local-diagnostic" ? 2 : 1;
} catch { record.candidateIntegrityStatus = "UNKNOWN"; record.repositorySnapshotError = "post_run_snapshot_unavailable"; exitCode = mode === "full-local-diagnostic" ? 2 : 1; }
record.interrupted = interrupted; record.endedAt = new Date().toISOString(); record.exitCode = exitCode;
atomicEnvironmentJson(path.join(environmentDir, "result.json"), record);
process.off("SIGINT", onInt); process.off("SIGTERM", onTerm);
}
console.log(`[environment] ${record.status}; ${mode === "full-local-diagnostic" ? "PARTIAL; " : ""}exit=${exitCode}; no release activation`);
process.exitCode = exitCode;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
const action = process.argv[2];
if (action === "observe-supervisor") console.log(JSON.stringify(await requestSupervisorObservation(readJson(process.argv[3]))));
else if (action === "initialize") verifyManagedEnvironment(undefined, true);
else if (action === "verify") environmentReceipt(undefined, false);
else if (action === "report" || action === "report-guest") console.log(JSON.stringify(environmentReceipt(undefined, action === "report-guest")));
else if (action === "complete") {
const receipt = environmentReceipt(undefined, false);
const { context } = verifyManagedEnvironment();
atomicEnvironmentJson(path.join(context.environmentDir, "completed.json"), receipt);
} else await launch(action);
} catch (error) { console.error(`[environment] ${error.message.startsWith("environment_") ? error.message : `environment_verification_failed:${error.code ?? error.name}`}`); process.exitCode = 1; }
}
+31
查看文件
@@ -0,0 +1,31 @@
import asyncio
import sys
async def copy_stream(reader, writer):
try:
while data := await reader.read(65536):
writer.write(data)
await writer.drain()
except (ConnectionError, asyncio.CancelledError):
pass
finally:
writer.close()
async def connect_database(reader, writer):
try:
database_reader, database_writer = await asyncio.open_unix_connection(sys.argv[1])
except OSError:
writer.close()
return
await asyncio.gather(copy_stream(reader, database_writer), copy_stream(database_reader, writer))
async def main():
server = await asyncio.start_server(connect_database, "127.0.0.1", 25432)
async with server:
await server.serve_forever()
asyncio.run(main())
+67
查看文件
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
PWA_FIXTURE_ROOT=""
pwa_build_candidate() {
local client_dir="$1"
local label="$2"
local build_sha="$3"
local output_dir="$4"
local log_file="$5"
local build_status=0
(
cd "$client_dir"
VITE_BUILD_SHA="$build_sha" npx vite build --outDir "$output_dir" --emptyOutDir
) > "$log_file" 2>&1 || build_status="$?"
if [[ "$build_status" -ne 0 ]]; then
echo "[pwa-cross-version][ERROR] build=${label} exit=${build_status}" >&2
return "$build_status"
fi
if [[ ! -f "$output_dir/index.html" || ! -f "$output_dir/sw.js" ]]; then
echo "[pwa-cross-version][ERROR] build=${label} required_artifact_missing" >&2
return 1
fi
echo "[pwa-cross-version] build=${label} status=PASS"
}
pwa_prepare_fixture() {
local client_dir="$1"
local report_dir="$2"
if [[ -n "$PWA_FIXTURE_ROOT" ]]; then
echo "[pwa-cross-version][ERROR] fixture_already_owned" >&2
return 1
fi
mkdir -p "$report_dir" || return "$?"
PWA_FIXTURE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/hyb-pwa-cross-version.XXXXXX")" || return "$?"
PWA_BUILD_A_DIR="$PWA_FIXTURE_ROOT/build-a"
PWA_BUILD_B_DIR="$PWA_FIXTURE_ROOT/build-b"
PWA_BUILD_A_SHA="111111111111"
PWA_BUILD_B_SHA="222222222222"
pwa_build_candidate "$client_dir" A "$PWA_BUILD_A_SHA" "$PWA_BUILD_A_DIR" "$report_dir/pwa-build-a.log" || return "$?"
pwa_build_candidate "$client_dir" B "$PWA_BUILD_B_SHA" "$PWA_BUILD_B_DIR" "$report_dir/pwa-build-b.log" || return "$?"
}
pwa_with_fixture() {
if [[ -z "$PWA_FIXTURE_ROOT" || ! -f "${PWA_BUILD_A_DIR:-}/sw.js" || ! -f "${PWA_BUILD_B_DIR:-}/sw.js" ]]; then
echo "[pwa-cross-version][ERROR] fixture_not_ready" >&2
return 1
fi
PWA_BUILD_A_DIR="$PWA_BUILD_A_DIR" PWA_BUILD_B_DIR="$PWA_BUILD_B_DIR" \
PWA_BUILD_A_SHA="$PWA_BUILD_A_SHA" PWA_BUILD_B_SHA="$PWA_BUILD_B_SHA" "$@"
}
pwa_cleanup_fixture() {
if [[ -z "$PWA_FIXTURE_ROOT" ]]; then
return 0
fi
if [[ "$(basename "$PWA_FIXTURE_ROOT")" != hyb-pwa-cross-version.* ]]; then
echo "[pwa-cross-version][ERROR] fixture_cleanup_refused" >&2
return 1
fi
if [[ -d "$PWA_FIXTURE_ROOT" ]]; then
find "$PWA_FIXTURE_ROOT" -xdev -depth -delete || return "$?"
fi
PWA_FIXTURE_ROOT=""
}
+5 -44
查看文件
@@ -3,61 +3,22 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CLIENT_DIR="$ROOT_DIR/client"
TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/hyb-pwa-cross-version.XXXXXX")"
BUILD_A_DIR="$TEMP_ROOT/build-a"
BUILD_B_DIR="$TEMP_ROOT/build-b"
BUILD_A_LOG="$TEMP_ROOT/build-a.log"
BUILD_B_LOG="$TEMP_ROOT/build-b.log"
BUILD_A_SHA="111111111111"
BUILD_B_SHA="222222222222"
source "$ROOT_DIR/scripts/qa/pwa-cross-version-fixture.sh"
REPORT_DIR="${PWA_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/pwa-cross-version/$(date +%Y%m%d%H%M%S)_$$_$RANDOM}"
cleanup() {
local original_status="$?"
trap - EXIT
if [[ -d "$TEMP_ROOT" ]]; then
find "$TEMP_ROOT" -depth -delete
fi
pwa_cleanup_fixture || { [[ "$original_status" -ne 0 ]] || original_status=1; }
exit "$original_status"
}
trap cleanup EXIT
build_candidate() {
local label="$1"
local build_sha="$2"
local output_dir="$3"
local log_file="$4"
local build_status=0
(
cd "$CLIENT_DIR"
VITE_BUILD_SHA="$build_sha" npx vite build --outDir "$output_dir" --emptyOutDir
) >"$log_file" 2>&1 || build_status="$?"
if [[ "$build_status" -ne 0 ]]; then
local log_lines=0
log_lines="$(wc -l <"$log_file" | tr -d ' ')"
echo "[pwa-cross-version][ERROR] build=${label} exit=${build_status} log_lines=${log_lines}" >&2
exit "$build_status"
fi
if [[ ! -f "$output_dir/index.html" || ! -f "$output_dir/sw.js" ]]; then
echo "[pwa-cross-version][ERROR] build=${label} required_artifact_missing" >&2
exit 1
fi
echo "[pwa-cross-version] build=${label} status=PASS"
}
build_candidate "A" "$BUILD_A_SHA" "$BUILD_A_DIR" "$BUILD_A_LOG"
build_candidate "B" "$BUILD_B_SHA" "$BUILD_B_DIR" "$BUILD_B_LOG"
pwa_prepare_fixture "$CLIENT_DIR" "$REPORT_DIR"
(
cd "$CLIENT_DIR"
PWA_BUILD_A_DIR="$BUILD_A_DIR" \
PWA_BUILD_B_DIR="$BUILD_B_DIR" \
PWA_BUILD_A_SHA="$BUILD_A_SHA" \
PWA_BUILD_B_SHA="$BUILD_B_SHA" \
npx playwright test tests/e2e/pwa-cross-version-upgrade.spec.ts \
pwa_with_fixture npx playwright test tests/e2e/pwa-cross-version-upgrade.spec.ts \
--workers=1 \
--retries=0 \
--reporter=list
+58 -2
查看文件
@@ -4,8 +4,17 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SERVER_DIR="$ROOT_DIR/server"
CLIENT_DIR="$ROOT_DIR/client"
source "$ROOT_DIR/scripts/qa/pwa-cross-version-fixture.sh"
MODE="${1:-}"
if [[ "$MODE" == "full-local" || "$MODE" == "full-local-diagnostic" ]]; then
if [[ -z "${QA_ENVIRONMENT_CONTEXT:-}" ]]; then
exec node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" "$MODE"
fi
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify
elif [[ "$MODE" == "__full-local-diagnostic-stage" ]]; then
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify
fi
QA_HOST="${QA_HOST:-127.0.0.1}"
QA_PORT_INPUT="${QA_PORT:-}"
QA_PORT="${QA_PORT_INPUT:-3000}"
@@ -56,6 +65,10 @@ Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|auth-security-lo
QA_DB_CLIENT=host|docker
QA_DB_DOCKER_CONTAINER / QA_DOCKER_USE_SG=1
QA_DB_PREFIX / QA_DB_NAME / QA_KEEP_DB
QA_FULL_REPORT_DIR full-local 独立发现、构建日志与浏览器结果目录
full-local-diagnostic 仅本地诊断,始终 PARTIAL/退出 2,无发布资格
QA_DIAGNOSTIC_REASON / QA_DIAGNOSTIC_OPERATOR / QA_DIAGNOSTIC_APPROVAL_REF
QA_DIAGNOSTIC_REPORT_DIR 本次诊断专用空目录;禁止覆盖历史报告
QA_SKIP_CLIENT_BUILD=1 复用已有 client/dist(低内存远程机排查视觉问题时使用)
QA_ISSUE_ID / VISUAL_MATRIX_REPORT_DIR
LIVE_SMOKE_BASE_URL / LIVE_SMOKE_ADMIN_* / LIVE_SMOKE_EMPLOYEE_* / LIVE_EXPECT_WECOM_STATUS
@@ -283,6 +296,9 @@ qa_cleanup() {
if ! qa_cleanup_server; then
cleanup_status=1
fi
if ! pwa_cleanup_fixture; then
cleanup_status=1
fi
if [[ "$QA_DB_CREATED" == "1" && "$MODE" != "smoke-live" ]]; then
if ! qa_dropdb; then
cleanup_status=1
@@ -534,10 +550,31 @@ qa_run_smoke_local() {
)
}
qa_prepare_full_inputs() {
QA_FULL_REPORT_DIR="${QA_FULL_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/full-local/$QA_RUN_ID}"
mkdir -p "$QA_FULL_REPORT_DIR"
QA_FULL_REPORT_DIR="$(cd "$QA_FULL_REPORT_DIR" && pwd)"
echo ">>> 准备 full-local PWA 双构建输入"
pwa_prepare_fixture "$CLIENT_DIR" "$QA_FULL_REPORT_DIR"
echo ">>> 发现并核对全部非 manual Playwright spec"
(
cd "$CLIENT_DIR"
pwa_with_fixture env E2E_MODE=local \
PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/discovery.json" \
QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" \
npx playwright test --list --retries=0 --reporter=json \
> "$QA_FULL_REPORT_DIR/discovery.stdout.log" 2> "$QA_FULL_REPORT_DIR/discovery.stderr.log"
)
node "$ROOT_DIR/scripts/qa/verify-full-local-discovery.mjs" "$QA_FULL_REPORT_DIR/discovery.json" \
> "$QA_FULL_REPORT_DIR/discovery-coverage.json"
}
qa_run_full_local() {
qa_select_port
export JOBS_ENABLED="${JOBS_ENABLED:-false}"
qa_export_app_env
qa_prepare_full_inputs
qa_prepare_db
echo ">>> 执行 server Prisma 校验"
@@ -578,11 +615,22 @@ qa_run_full_local() {
export JOBS_ENABLED="${JOBS_ENABLED_FOR_E2E:-true}"
qa_start_server
qa_run_full_browser
}
qa_run_full_browser() {
if [[ -n "${QA_DIAGNOSTIC_STAGE:-}" ]]; then QA_DIAGNOSTIC_PHASE=environment; fi
node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL"
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" report-guest > "$QA_FULL_REPORT_DIR/environment-before-browser.json"
if [[ -n "${QA_DIAGNOSTIC_STAGE:-}" ]]; then QA_DIAGNOSTIC_PHASE=test; fi
echo ">>> 执行 Playwright full E2E"
(
cd "$CLIENT_DIR"
rm -rf test-results playwright-report
E2E_MODE=local npm run test:e2e:full
pwa_with_fixture env E2E_MODE=local \
PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/playwright-results.json" \
QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" \
PLAYWRIGHT_HTML_OUTPUT_DIR="$QA_FULL_REPORT_DIR/playwright-report" PLAYWRIGHT_HTML_OPEN=never \
npm run test:e2e:full -- "$@" --retries=0 --reporter=list,json,html --output="$QA_FULL_REPORT_DIR/test-results"
)
}
@@ -759,6 +807,14 @@ case "$MODE" in
trap qa_cleanup EXIT
qa_run_smoke_local
;;
full-local-diagnostic)
qa_require_cmd node
node "$ROOT_DIR/scripts/qa/full-local-diagnostic.mjs"
;;
__full-local-diagnostic-stage)
source "$ROOT_DIR/scripts/qa/full-local-diagnostic-stage.sh"
qa_run_diagnostic_stage "${2:-}"
;;
full-local)
qa_require_cmd curl
qa_require_db_client
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import { observeGuestEntry } from "../browser-environment-probe.mjs";
function fixture({ refresh = false, failed = false, online = true, documentOnline = true, cookies = [], status = 200 } = {}) {
const handlers = {};
let navigated = false;
const page = {
on(event, handler) { handlers[event] = handler; },
async addInitScript() {},
async evaluate() { return online; },
async goto() {
assert.ok(handlers.request);
navigated = true;
handlers.console({ text: () => `QA_ENV_DOCUMENT_ONLINE:${documentOnline}` });
handlers.console({ text: () => "QA_ENV_DOCUMENT_ONLINE:true" });
if (refresh) {
const request = { url: () => "http://127.0.0.1/api/auth/refresh" };
handlers.request(request);
if (failed) handlers.requestfailed(request);
}
return { status: () => status };
},
getByTestId() { return { async waitFor() {} }; },
url() { return "http://127.0.0.1/login"; },
async close() {},
};
return { context: { async storageState() { return { cookies, origins: [] }; }, async newPage() { return page; } }, navigated: () => navigated };
}
test("在线新 context、表单及零 refresh 是独立事实", async () => {
const owned = fixture();
const result = await observeGuestEntry(owned.context, "http://127.0.0.1");
assert.equal(result.valid, true);
assert.equal(result.refreshRequestCount, 0);
assert.equal(result.noRouteMocks, true);
});
for (const failed of [false, true]) test(`表单可见但 refresh 已尝试必须拒绝,失败请求=${failed}`, async () => {
const result = await observeGuestEntry(fixture({ refresh: true, failed }).context, "http://127.0.0.1");
assert.equal(result.valid, false);
assert.equal(result.refreshRequestCount, 1);
assert.equal(result.refreshFailedCount, Number(failed));
assert.ok(result.failures.includes("refresh_attempt_observed"));
});
test("首次 document-start 离线不可被后续 true 覆盖", async () => {
const result = await observeGuestEntry(fixture({ documentOnline: false }).context, "http://127.0.0.1");
assert.equal(result.nativeOnlineSamples.documentStart, false);
assert.equal(result.valid, false);
});
for (const options of [{ online: false }, { cookies: [{ name: "has_session", value: "1" }] }]) test(`前置无效不导航 ${JSON.stringify(options)}`, async () => {
const owned = fixture(options);
assert.equal((await observeGuestEntry(owned.context, "http://127.0.0.1")).valid, false);
assert.equal(owned.navigated(), false);
});
test("非候选 HTTP 200 不合格,禁止公网探针", async () => {
assert.equal((await observeGuestEntry(fixture({ status: 500 }).context, "http://127.0.0.1")).valid, false);
await assert.rejects(observeGuestEntry(fixture().context, "https://example.invalid"), /requires_loopback/);
});
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync, unlinkSync, symlinkSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { validateExactProductionIdentity } from "../../../deploy/trialrun/production-preflight.mjs";
import { browserInventory, createDiagnosticScratch, sourceSnapshot, compareSnapshots } from "../full-local-diagnostic.mjs";
test("全仓快照发现已跟踪 PNG 覆盖、dirty 二次改写、index-only 和删除", context => {
const root = createDiagnosticScratch();
context.after(() => rmSync(root, { recursive: true }));
const git = args => { const result = spawnSync("git", args, { cwd: root, encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); };
mkdirSync(path.join(root, "client")); mkdirSync(path.join(root, "output"));
writeFileSync(path.join(root, ".gitignore"), "output/\n");
writeFileSync(path.join(root, "client/source.ts"), "export {};\n");
const image = path.join(root, "output/archive.png"); writeFileSync(image, "archived");
git(["init", "-q"]); git(["add", "."]); git(["add", "-f", "output/archive.png"]);
git(["-c", "user.name=Fixture", "-c", "[email protected]", "-c", "core.hooksPath=/dev/null", "commit", "-qm", "fixture"]);
const before = sourceSnapshot(root);
assert.equal(before.dirty, false);
writeFileSync(path.join(root, "output/ignored.png"), "new run");
assert.equal(compareSnapshots(before, sourceSnapshot(root)).repositoryUnchanged, true);
writeFileSync(image, "first overwrite"); const first = sourceSnapshot(root);
assert.equal(compareSnapshots(before, first).sourceUnchanged, true);
assert.equal(compareSnapshots(before, first).repositoryUnchanged, false);
assert.ok(first.repository.porcelainV1Z.includes("output/archive.png\0"));
writeFileSync(image, "second overwrite"); const second = sourceSnapshot(root);
assert.equal(first.dirty, true); assert.equal(second.dirty, true);
assert.equal(first.repository.porcelainV1Z, second.repository.porcelainV1Z);
assert.equal(compareSnapshots(first, second).repositoryUnchanged, false);
git(["add", "-f", "output/archive.png"]); const staged = sourceSnapshot(root);
assert.equal(second.repository.trackedDigest, staged.repository.trackedDigest);
assert.notEqual(second.repository.indexDigest, staged.repository.indexDigest);
assert.equal(compareSnapshots(second, staged).repositoryUnchanged, false);
git(["update-index", "--assume-unchanged", "output/archive.png"]);
const flagged = sourceSnapshot(root);
assert.equal(staged.repository.trackedDigest, flagged.repository.trackedDigest);
assert.notEqual(staged.repository.indexDigest, flagged.repository.indexDigest);
assert.equal(compareSnapshots(staged, flagged).repositoryUnchanged, false);
git(["update-index", "--no-assume-unchanged", "output/archive.png"]);
unlinkSync(image); const deleted = sourceSnapshot(root);
assert.equal(deleted.repository.trackedFiles.find(entry => entry.file === "output/archive.png").kind, "missing");
assert.equal(compareSnapshots(staged, deleted).repositoryUnchanged, false);
symlinkSync("absent-target", image); const linked = sourceSnapshot(root);
assert.equal(linked.repository.trackedFiles.find(entry => entry.file === "output/archive.png").kind, "symlink");
assert.equal(compareSnapshots(deleted, linked).repositoryUnchanged, false);
assert.equal(compareSnapshots(null, linked).repositoryUnchanged, false);
assert.equal(compareSnapshots(null, linked).candidateIntegrityStatus, "UNKNOWN");
const incomplete = structuredClone(linked);
delete incomplete.repository.trackedDigest;
assert.equal(compareSnapshots(linked, incomplete).candidateIntegrityStatus, "UNKNOWN");
});
test("诊断临时根不污染原生产身份测试的合成合法路径", context => {
const scratch = createDiagnosticScratch();
context.after(() => rmSync(scratch, { recursive: true }));
const identity = {
deployServer: "production-node",
composeProjectName: "production-project",
remoteDir: path.join(scratch, "server-unit", "hyb-oa-deploy-safety-fixture", "app"),
};
const validate = remoteDir => validateExactProductionIdentity({
...identity,
remoteDir,
expectedDeployServer: identity.deployServer,
expectedComposeProjectName: identity.composeProjectName,
expectedRemoteDir: remoteDir,
});
assert.doesNotThrow(() => validate(identity.remoteDir));
assert.throws(() => validate(path.join(scratch, "hyb-full-local-diagnostic-fixture", "app")), /REMOTE_DIR_NON_PRODUCTION_IDENTITY/);
});
test("原聚合器在 workflow 失败后停止,同库继续参数会破坏该边界", context => {
const scratch = createDiagnosticScratch();
context.after(() => rmSync(scratch, { recursive: true }));
mkdirSync(path.join(scratch, "lib"));
copyFileSync(new URL("../../../server/scripts/regression-all.sh", import.meta.url), path.join(scratch, "regression-all.sh"));
copyFileSync(new URL("../../../server/scripts/lib/regression-report.sh", import.meta.url), path.join(scratch, "lib/regression-report.sh"));
const suites = ["smoke-test", "regression-dedup", "regression-workflow-actions", "regression-procurement", "regression-assets", "regression-audit", "regression-ocr", "regression-im-notifier"];
for (const suite of suites) {
writeFileSync(path.join(scratch, `${suite}.sh`), `#!/usr/bin/env bash\nprintf '%s\\n' '${suite}' >> "$QA_AGGREGATE_FIXTURE_LOG"\nexit ${suite === "regression-workflow-actions" ? 31 : 0}\n`, { mode: 0o755 });
}
const log = path.join(scratch, "invocations.log");
const environment = { PATH: process.env.PATH, HOME: scratch, TMPDIR: scratch, QA_AGGREGATE_FIXTURE_LOG: log };
const run = args => spawnSync("bash", [path.join(scratch, "regression-all.sh"), ...args], { cwd: scratch, env: environment, encoding: "utf8", timeout: 15000 });
const normal = run([]);
assert.equal(normal.status, 31, normal.stderr);
assert.deepEqual(readFileSync(log, "utf8").trim().split("\n"), suites.slice(0, 3));
writeFileSync(log, "");
const continued = run(["--json-report", path.join(scratch, "continued.json")]);
assert.equal(continued.status, 1, continued.stderr);
assert.deepEqual(readFileSync(log, "utf8").trim().split("\n"), suites);
});
const discovery = { cases: [
{ file: "client/tests/e2e/ordinary.spec.ts", id: "first", projectName: "", repeatEachIndex: 0 },
{ file: "client/tests/e2e/ordinary.spec.ts", id: "second", projectName: "", repeatEachIndex: 0 },
] };
test("缺浏览器回执不能从枚举伪造执行通过", () => {
const result = browserInventory(discovery, null);
assert.equal(result.reportPresent, false);
assert.deepEqual(result.cases.map(entry => entry.status), ["NOT_RUN", "NOT_RUN"]);
});
test("保留失败、serial 跳过、预期状态及重试事实", () => {
const execution = { errors: [], suites: [{ specs: [
{ id: "first", tests: [{ projectName: "", expectedStatus: "passed", status: "unexpected", results: [{ status: "failed", retry: 0, duration: 100 }] }] },
{ id: "second", tests: [{ projectName: "", expectedStatus: "passed", status: "skipped", results: [{ status: "skipped", retry: 0, duration: 0 }] }] },
] }] };
const result = browserInventory(discovery, execution);
assert.deepEqual(result.cases.map(entry => entry.status), ["failed", "skipped"]);
assert.equal(result.cases[1].reportedStatus, "skipped");
assert.equal(result.cases[0].results[0].retry, 0);
});
test("部分回执只为实际返回的用例计结果", () => {
const result = browserInventory(discovery, { errors: [{}], suites: [{ specs: [{ id: "first", tests: [{ results: [{ status: "passed" }] }] }] }] });
assert.deepEqual(result.cases.map(entry => entry.status), ["passed", "NOT_RUN"]);
assert.equal(result.executionErrors, 1);
});
test("重复和未知用例作为报告问题单列", () => {
const result = browserInventory(discovery, { suites: [{ specs: [
{ id: "unknown", tests: [{}, {}] },
] }] });
assert.deepEqual(result.issues, ["duplicate_case", "unknown_cases"]);
assert.ok(result.cases.every(entry => entry.status === "NOT_RUN"));
});
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import test from "node:test";
import { spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { createServer } from "node:net";
import { topologyMatches, validateEnvironmentFacts, validateEnvironmentReceipt, verifyManagedEnvironment, requestSupervisorObservation } from "../full-local-environment.mjs";
function receipt() {
return {
schemaVersion: 1, profile: "isolated-online-v1", policyDigest: "policy",
supervisorObservation: { source: "live-supervisor-unix-ipc", sampledAt: "2026-09-07T00:00:00Z", namespace: "parent" },
namespace: { inheritedRunner: "parent", ownedChild: "child", runnerCreated: true, currentMatchesOwnedChild: true, topologyMatched: true },
nativeOnlineProbe: { valid: true, online: true, browserNamespace: "child", browserMatchesOwnedChild: true, noNavigatorOverride: true },
bootstrapObservation: {
valid: true, observationPoint: "guest-entry-black-box", freshContext: true, initialSessionHint: false,
listenerBeforeNavigation: true, noRouteMocks: true, loginFormVisible: true, refreshRequestCount: 0, loginRequestCount: 0,
nativeOnlineSamples: { beforeNavigation: true, documentStart: true, afterForm: true },
browserNamespace: "child", browserMatchesOwnedChild: true, noNavigatorOverride: true, documentStatus: 200, stayedOnLogin: true,
},
};
}
test("环境回执必须消费事实而非 valid 标记", () => {
assert.deepEqual(validateEnvironmentReceipt(receipt(), "policy"), []);
for (const section of ["namespace", "nativeOnlineProbe", "bootstrapObservation", "supervisorObservation"]) {
for (const key of Object.keys(receipt()[section])) {
const value = receipt();
delete value[section][key];
assert.ok(validateEnvironmentReceipt(value, "policy").length, `${section}.${key}`);
}
}
for (const mutate of [
value => { value.bootstrapObservation.refreshRequestCount = 1; },
value => { value.bootstrapObservation.nativeOnlineSamples.documentStart = false; },
value => { value.namespace.ownedChild = "parent"; },
value => { value.nativeOnlineProbe.browserNamespace = "other"; },
value => { value.policyDigest = "old"; },
]) {
const value = receipt(); mutate(value);
assert.ok(validateEnvironmentReceipt(value, "policy").length);
}
});
test("父 runner 实时 IPC 绑定本轮、进程和当前策略,错误响应拒绝", async context => {
const scratch = mkdtempSync(path.join(os.tmpdir(), "hyb-env-ipc-test-"));
const owner = { scratch, runId: "current", supervisorPid: process.pid, policyDigest: "policy" };
let observation = { runId: owner.runId, pid: owner.supervisorPid, policyDigest: owner.policyDigest, namespace: "parent" };
const server = createServer(connection => connection.end(JSON.stringify(observation)));
context.after(async () => { await new Promise(resolve => server.close(resolve)); rmSync(scratch, { recursive: true, force: true }); });
await new Promise(resolve => server.listen(path.join(scratch, "supervisor.sock"), resolve));
assert.equal((await requestSupervisorObservation(owner)).namespace, "parent");
for (const key of ["runId", "pid", "policyDigest", "namespace"]) {
const previous = observation;
observation = { ...previous, [key]: null };
await assert.rejects(requestSupervisorObservation(owner), /response_invalid/);
observation = previous;
}
await assert.rejects(requestSupervisorObservation({ ...owner, scratch: path.join(scratch, "missing") }), /unavailable/);
});
test("缺 context 不接受 GATE_PASSED 或已有 true", () => {
assert.throws(() => verifyManagedEnvironment(""), /context_required/);
const context = { schemaVersion: 1, profile: "isolated-online-v1", policyDigest: "policy", inheritedNamespace: "parent", ownedNamespace: "child" };
const facts = { policyDigest: "policy", supervisorRecognized: true, descendantOfSupervisor: true, supervisorNamespace: "parent", currentNamespace: "child", topologyMatched: true };
assert.deepEqual(validateEnvironmentFacts(context, facts), []);
for (const key of Object.keys(facts)) assert.ok(validateEnvironmentFacts(context, { ...facts, [key]: null }).length, key);
});
test("拓扑只接纳 owned veth 且禁止默认出口", () => {
const addresses = [{ ifname: "lo" }, ...["qaonline", "qapeer"].map((ifname, index) => ({ ifname, flags: ["UP"], operstate: "UP", addr_info: [{ local: `192.0.2.${index + 1}`, prefixlen: 30 }] }))];
assert.equal(topologyMatches(addresses, [], []), true);
assert.equal(topologyMatches(addresses, [{ dst: "default" }], []), false);
assert.equal(topologyMatches(addresses, [], [{ gateway: "::1" }]), false);
assert.equal(topologyMatches([...addresses, { ifname: "eth0" }], [], []), false);
assert.equal(topologyMatches(addresses.slice(0, 2), [], []), false);
});
for (const mismatch of [false, true]) test(`namespace 创建失败不降级,清理须匹配 owner:mismatch=${mismatch}`, context => {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const temporary = mkdtempSync(path.join(os.tmpdir(), "hyb-env-command-test-"));
const report = mkdtempSync(path.join(root, "artifacts/qa-flow/environment-command-test-"));
const commands = path.join(temporary, "commands.jsonl");
mkdirSync(path.join(temporary, "bin"));
context.after(() => {
try {
const owned = JSON.parse(readFileSync(path.join(report, "environment/context.private.json")));
assert.ok(path.basename(owned.scratch).startsWith("hyb-qa-managed-"));
rmSync(owned.scratch, { recursive: true, force: true });
} finally {
rmSync(temporary, { recursive: true, force: true });
rmSync(report, { recursive: true, force: true });
}
});
writeFileSync(path.join(temporary, "bin/docker"), `#!${process.execPath}
const fs = require('node:fs');
const args = process.argv.slice(2);
fs.appendFileSync(${JSON.stringify(commands)},JSON.stringify(args)+'\\n');
if (args[0] === 'image') console.log('sha256:synthetic');
if (args[0] === 'run') { fs.writeFileSync(${JSON.stringify(path.join(temporary, "owner"))},args[args.indexOf('--name')+1]); console.log('a'.repeat(64)); }
if (args[0] === 'inspect') console.log('a'.repeat(64)+'|'+(${mismatch} ? 'other-owner' : fs.readFileSync(${JSON.stringify(path.join(temporary, "owner"))},'utf8'))+'|none');
`, { mode: 0o755 });
writeFileSync(path.join(temporary, "bin/unshare"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
const result = spawnSync(process.execPath, [path.join(root, "scripts/qa/full-local-environment.mjs"), "full-local-diagnostic"], {
cwd: root, encoding: "utf8", timeout: 30000,
env: { PATH: `${path.join(temporary, "bin")}:${process.env.PATH}`, HOME: temporary, QA_DIAGNOSTIC_REPORT_DIR: report, QA_DIAGNOSTIC_REASON: "synthetic test", QA_DIAGNOSTIC_OPERATOR: "fixture", QA_DIAGNOSTIC_APPROVAL_REF: "fixture-only", ENVIRONMENT_PASSED: "1" },
});
assert.equal(result.status, 2, result.stderr);
const receipt = JSON.parse(readFileSync(path.join(report, "environment/result.json")));
assert.equal(receipt.status, "ENVIRONMENT_INVALID");
assert.equal(receipt.normalReleaseEligible, false);
assert.equal(receipt.cleanup.containerRemoved, !mismatch);
const calls = readFileSync(commands, "utf8").trim().split("\n").map(JSON.parse);
assert.equal(calls.some(args => args[0] === "rm"), !mismatch);
assert.ok(calls.find(args => args[0] === "run").includes("--pull=never"));
});
@@ -0,0 +1,332 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
for (const [flag, protectedLine] of [
["FAIL_ENVIRONMENT_VERIFY", ' node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify'],
["FAIL_ENVIRONMENT_PROBE", ' node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL"'],
["FAIL_ENVIRONMENT_CONSUME", ' node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" report-guest > "$QA_FULL_REPORT_DIR/environment-before-browser.json"'],
]) test(`环境保护拒绝并对真实源码删除敏感:${flag}`, context => {
const assertBlocked = result => {
assert.notEqual(result.status, 0);
assert.ok(!result.commands.some(command => command.args.includes("test:e2e:full")));
};
const owned = fixture(context, { [flag]: "1", QA_ENVIRONMENT_CONTEXT: "synthetic-reference", ENVIRONMENT_PASSED: "1", ONLINE: "1" });
assertBlocked(owned.run());
const source = path.join(owned.root, "scripts/qa/run-web-gates.sh");
const original = readFileSync(source, "utf8");
assert.ok(original.includes(protectedLine));
writeFileSync(source, original.replaceAll(protectedLine, ":"));
assert.throws(() => assertBlocked(owned.run()), assert.AssertionError);
});
function fixture(context, overrides = {}) {
const root = mkdtempSync(path.join(os.tmpdir(), "hyb-full-local-test-"));
context.after(() => rmSync(root, { recursive: true, force: true }));
for (const directory of ["bin", "tmp", "scripts/qa", "server/scripts", "client/dist", "client/tests/e2e", "output"]) mkdirSync(path.join(root, directory), { recursive: true });
writeFileSync(path.join(root, "output/archive.png"), "historical evidence");
for (const file of ["run-web-gates.sh", "run-pwa-cross-version-upgrade.sh", "pwa-cross-version-fixture.sh", "verify-full-local-discovery.mjs", "full-local-diagnostic.mjs", "full-local-diagnostic-stage.sh"]) copyFileSync(path.join(projectRoot, "scripts/qa", file), path.join(root, "scripts/qa", file));
for (const file of ["pwa-cross-version-upgrade.spec.ts", "ordinary.spec.ts"]) writeFileSync(path.join(root, "client/tests/e2e", file), "");
writeFileSync(path.join(root, "client/dist/keep.txt"), "original-dist");
for (const [file, stage] of [["regression-workflow-actions.sh", "workflow-actions"], ["regression-all.sh", "server-regressions"]]) {
writeFileSync(path.join(root, "server/scripts", file), `#!${process.execPath}
const fs = require('node:fs');
fs.appendFileSync(process.env.HYB_QA_FIXTURE_ROOT+'/commands.jsonl',JSON.stringify({command:'regression',stage:'${stage}',args:process.argv.slice(2),database:process.env.QA_DB_NAME})+'\\n');
process.exit((process.env.FAIL_STAGES??'').split(',').includes('${stage}') ? 31 : 0);
`, { mode: 0o755 });
}
const fake = `#!${process.execPath}
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = process.env.HYB_QA_FIXTURE_ROOT;
if (!root || !path.basename(root).startsWith('hyb-full-local-test-') || path.dirname(process.argv[1]) !== path.join(root, 'bin')) throw Error('fixture_only');
const command = path.basename(process.argv[1]);
const args = process.argv.slice(2);
fs.appendFileSync(path.join(root, 'commands.jsonl'), JSON.stringify({command,args,visualOutput:process.env.QA_VISUAL_OUTPUT_DIR,stage:process.env.QA_DIAGNOSTIC_STAGE,database:process.env.QA_DB_NAME,sha:process.env.VITE_BUILD_SHA,inputs:[process.env.PWA_BUILD_A_DIR,process.env.PWA_BUILD_B_DIR,process.env.PWA_BUILD_A_SHA,process.env.PWA_BUILD_B_SHA]})+'\\n');
if (command === 'node' && args[0]?.endsWith('full-local-environment.mjs')) {
if (process.env.FAIL_ENVIRONMENT) process.exit(41);
if (process.env.FAIL_ENVIRONMENT_VERIFY && args[1] === 'verify') process.exit(41);
if (process.env.FAIL_ENVIRONMENT_CONSUME && args[1] === 'report-guest') process.exit(41);
if (['full-local','full-local-diagnostic'].includes(args[1])) {
const result = spawnSync('bash',[path.join(root,'scripts/qa/run-web-gates.sh'),args[1]],{env:{...process.env,QA_ENVIRONMENT_CONTEXT:'synthetic-reference'},stdio:'inherit'});
process.exit(result.status ?? 1);
}
console.log(JSON.stringify({bootstrapObservation:{valid:true}}));
process.exit(0);
}
if (command === 'node' && args[0]?.endsWith('browser-environment-probe.mjs')) process.exit(process.env.FAIL_ENVIRONMENT_PROBE ? 42 : 0);
if (command === 'node' && (args[0]?.endsWith('verify-full-local-discovery.mjs') || args[0]?.endsWith('full-local-diagnostic.mjs') || args[0] === '-e')) {
const result = spawnSync(process.execPath, args, {env:process.env, stdio:'inherit'});
process.exit(result.status ?? 1);
}
if (command === 'npx' && args[0] === 'vite' && args.includes('--outDir')) {
if (process.env.FAIL_BUILD_SHA === process.env.VITE_BUILD_SHA) process.exit(23);
const directory = args[args.indexOf('--outDir')+1];
fs.mkdirSync(directory, {recursive:true});
fs.writeFileSync(path.join(directory,'index.html'),'synthetic');
if (!process.env.OMIT_SW) fs.writeFileSync(path.join(directory,'sw.js'),'synthetic');
}
if (command === 'npx' && args.includes('--list')) {
for (const name of ['PWA_BUILD_A_DIR','PWA_BUILD_B_DIR','PWA_BUILD_A_SHA','PWA_BUILD_B_SHA']) if (!process.env[name]) process.exit(24);
if (process.env.FAIL_DISCOVERY) process.exit(25);
let files = fs.readdirSync(path.join(root,'client/tests/e2e'));
if (process.env.OMIT_PWA) files = files.filter(file=>!file.startsWith('pwa-'));
const report = {errors:[],suites:files.map(file=>({file,specs:[{file,id:file,title:file,line:1,tests:[{projectName:''}]}]}))};
fs.writeFileSync(process.env.PLAYWRIGHT_JSON_OUTPUT_FILE,JSON.stringify(report));
}
if (command === 'psql' && args.some(arg=>arg.includes('count(*)'))) console.log('0');
if (command === 'dropdb' && process.env.FAIL_CLEANUP_STAGE && process.env.FAIL_CLEANUP_STAGE === process.env.QA_DIAGNOSTIC_STAGE) process.exit(33);
if (command === 'createdb' && process.env.FAIL_PREPARE_STAGE && process.env.FAIL_PREPARE_STAGE === process.env.QA_DIAGNOSTIC_STAGE) process.exit(34);
const failStage = (process.env.FAIL_STAGES??'').split(',').includes(process.env.QA_DIAGNOSTIC_STAGE);
if (command === 'npm' && args[0] === 'test' && failStage) process.exit(32);
if (command === 'npx' && args[0] === 'tsc' && failStage) process.exit(32);
if (command === 'npm' && args.includes('test:e2e:full')) {
if (process.env.MUTATE_ARCHIVE) fs.writeFileSync(path.join(root,'output/archive.png'),'overwritten');
if (process.env.PLAYWRIGHT_JSON_OUTPUT_FILE) {
const discovery = JSON.parse(fs.readFileSync(path.join(root,process.env.QA_DIAGNOSTIC_STAGE?'report/full-local-report/discovery.json':'report/discovery.json')));
for (const suite of discovery.suites) for (const spec of suite.specs) for (const test of spec.tests) Object.assign(test,{status:process.env.FAIL_BROWSER?'unexpected':'expected',expectedStatus:'passed',results:[{status:process.env.FAIL_BROWSER?'failed':'passed',retry:0,duration:1}]});
fs.writeFileSync(process.env.PLAYWRIGHT_JSON_OUTPUT_FILE,JSON.stringify(discovery));
}
if (process.env.FAIL_BROWSER) process.exit(26);
}
if (!['node','npm','npx','curl','psql','createdb','dropdb'].includes(command)) throw Error('unexpected_fixture_command');
`;
writeFileSync(path.join(root, "bin/fake"), fake, { mode: 0o755 });
for (const command of ["node", "npm", "npx", "curl", "psql", "createdb", "dropdb"]) symlinkSync("fake", path.join(root, "bin", command));
const env = {
PATH: `${path.join(root, "bin")}:${process.env.PATH}`, HOME: root, LANG: "C.UTF-8",
HYB_QA_FIXTURE_ROOT: root, TMPDIR: path.join(root, "tmp"),
QA_DB_CLIENT: "host", QA_DB_NAME: "hyb_oa_webgate_fixture", QA_HOST: "127.0.0.1",
QA_FULL_REPORT_DIR: path.join(root, "report"), PWA_REPORT_DIR: path.join(root, "report"),
QA_SERVER_LOG: path.join(root, "server.log"), QA_SERVER_PID_FILE: path.join(root, "server.pid"),
...overrides,
};
writeFileSync(path.join(root, ".gitignore"), "bin/\ntmp/\nreport/\noutput/\ncommands.jsonl\n*.log\n*.pid\n");
for (const args of [["init", "-q"], ["add", "."], ["add", "-f", "output/archive.png"], ["-c", "user.name=QA Fixture", "-c", "[email protected]", "commit", "-qm", "synthetic fixture"]]) {
assert.equal(spawnSync("git", args, { cwd: root, encoding: "utf8" }).status, 0);
}
return {
root,
run(script = "run-web-gates.sh", args = ["full-local"]) {
const result = spawnSync("bash", [path.join(root, "scripts/qa", script), ...args], { env, cwd: root, encoding: "utf8", timeout: 30000 });
return { ...result, commands: existsSync(path.join(root, "commands.jsonl")) ? readFileSync(path.join(root, "commands.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)) : [] };
},
};
}
for (const ci of [undefined, "1"]) {
test(`full-local 准备 A/B、保留并发、CI=${ci ?? "unset"} 仍强制零重试`, (context) => {
const owned = fixture(context, ci ? { CI: ci } : {});
const result = owned.run();
assert.equal(result.status, 0, result.stderr);
const builds = result.commands.filter((command) => command.args.includes("--outDir"));
assert.deepEqual(builds.map((command) => command.sha), ["111111111111", "222222222222"]);
const discovery = result.commands.find((command) => command.args.includes("--list"));
const browser = result.commands.find((command) => command.args.includes("test:e2e:full"));
assert.ok(!browser.args.some(argument => argument.startsWith("--trace")));
for (const command of [discovery, browser]) {
assert.ok(command);
assert.ok(command.args.includes("--retries=0"));
assert.ok(!command.args.some((argument) => argument.startsWith("--workers") || argument.startsWith("--grep") || argument.startsWith("--shard")));
assert.ok(command.inputs.every(Boolean));
assert.equal(command.visualOutput, path.join(owned.root, "report/visual"));
assert.notEqual(command.inputs[0], command.inputs[1]);
assert.notEqual(command.inputs[2], command.inputs[3]);
}
assert.ok(result.commands.indexOf(discovery) < result.commands.findIndex((command) => command.command === "createdb"));
const environment = result.commands.find(command => command.args[0]?.endsWith("full-local-environment.mjs") && command.args[1] === "verify");
const probe = result.commands.find(command => command.args[0]?.endsWith("browser-environment-probe.mjs"));
const consume = result.commands.find(command => command.args[0]?.endsWith("full-local-environment.mjs") && command.args[1] === "report-guest");
assert.ok(result.commands.indexOf(environment) < result.commands.indexOf(discovery));
assert.ok(result.commands.indexOf(probe) < result.commands.indexOf(consume));
assert.ok(result.commands.indexOf(consume) < result.commands.indexOf(browser));
assert.ok(result.commands.filter((command) => command.command === "npm" && !command.args.includes("test:e2e:full")).every((command) => command.inputs.every((value) => value === null)));
assert.equal(readFileSync(path.join(owned.root, "client/dist/keep.txt"), "utf8"), "original-dist");
assert.deepEqual(readdirSync(path.join(owned.root, "tmp")), []);
assert.ok(existsSync(path.join(owned.root, "report/pwa-build-a.log")));
});
}
for (const overrides of [{ FAIL_BUILD_SHA: "111111111111" }, { FAIL_BUILD_SHA: "222222222222" }, { OMIT_SW: "1" }, { FAIL_DISCOVERY: "1" }, { OMIT_PWA: "1" }]) {
test(`输入或完整发现失败阻止后续阶段 ${JSON.stringify(overrides)}`, (context) => {
const owned = fixture(context, overrides);
const result = owned.run();
assert.notEqual(result.status, 0);
assert.ok(!result.commands.some((command) => command.command === "createdb" || command.args.includes("test:e2e:full")));
assert.deepEqual(readdirSync(path.join(owned.root, "tmp")), []);
assert.ok(existsSync(path.join(owned.root, "report/pwa-build-a.log")));
});
}
test("浏览器失败保留非零并清理本轮输入", (context) => {
const owned = fixture(context, { FAIL_BROWSER: "1" });
const result = owned.run();
assert.equal(result.status, 26);
assert.ok(result.commands.some((command) => command.command === "dropdb"));
assert.deepEqual(readdirSync(path.join(owned.root, "tmp")), []);
assert.ok(existsSync(path.join(owned.root, "report/discovery-coverage.json")));
});
test("原专用 runner 共用输入逻辑且保留自身单 worker", (context) => {
const owned = fixture(context);
const result = owned.run("run-pwa-cross-version-upgrade.sh", []);
assert.equal(result.status, 0, result.stderr);
const browser = result.commands.find((command) => command.args.includes("tests/e2e/pwa-cross-version-upgrade.spec.ts"));
assert.ok(browser.args.includes("--workers=1"));
assert.ok(browser.args.includes("--retries=0"));
assert.ok(browser.inputs.every(Boolean));
assert.deepEqual(readdirSync(path.join(owned.root, "tmp")), []);
});
test("恢复静默排除 PWA 的真实源码变异必须使 full-local 失败", (context) => {
const owned = fixture(context);
const source = path.join(owned.root, "scripts/qa/run-web-gates.sh");
const original = readFileSync(source, "utf8");
const mutated = original.replace("npx playwright test --list --retries=0", "OMIT_PWA=1 npx playwright test --list --retries=0");
assert.notEqual(mutated, original);
writeFileSync(source, mutated);
assert.notEqual(owned.run().status, 0);
});
const diagnosticApproval = {
QA_DIAGNOSTIC_REASON: "synthetic coverage inventory",
QA_DIAGNOSTIC_OPERATOR: "fixture",
QA_DIAGNOSTIC_APPROVAL_REF: "synthetic-test-approval",
};
function runDiagnostic(context, overrides = {}) {
const owned = fixture(context, { ...diagnosticApproval, ...overrides });
const result = owned.run("run-web-gates.sh", ["full-local-diagnostic"]);
const reportFile = path.join(owned.root, "report/diagnostic-results.json");
return { owned, result, report: existsSync(reportFile) ? JSON.parse(readFileSync(reportFile, "utf8")) : null };
}
test("普通 full-local 遇 workflow 失败仍立即停止", context => {
const owned = fixture(context, { FAIL_STAGES: "workflow-actions" });
const result = owned.run();
assert.equal(result.status, 31, result.stderr);
assert.ok(!result.commands.some(command => command.stage === "server-regressions" || command.args.includes("test:e2e:full")));
});
test("诊断全绿仍 PARTIAL/2,每个写库阶段使用全新独占库并清理", context => {
const { result, report, owned } = runDiagnostic(context, { CI: "1", GATE_PASSED: "1" });
assert.equal(result.status, 2, result.stderr);
assert.equal(report.status, "PARTIAL");
assert.ok(report.stages.every(stage => stage.status === "PASS"), JSON.stringify(report.stages));
assert.equal(report.sourceUnchanged, true);
assert.equal(report.repositoryUnchanged, true);
assert.equal(report.cleanAtStart, true);
assert.equal(report.cleanAtEnd, true);
assert.equal(report.browser.cases.length, 2);
assert.ok(report.browser.cases.every(entry => entry.status === "passed"));
const created = result.commands.filter(command => command.command === "createdb");
assert.equal(new Set(created.map(command => command.database)).size, 3);
assert.equal(result.commands.filter(command => command.command === "dropdb").length, 3);
const browser = result.commands.find(command => command.args.includes("test:e2e:full"));
assert.ok(browser.args.includes("--retries=0"));
assert.deepEqual(browser.args.filter(argument => argument.startsWith("--trace")), ["--trace=on"]);
assert.ok(!browser.args.some(argument => /^(--workers|--grep|--shard)/.test(argument)));
assert.deepEqual(result.commands.find(command => command.command === "regression" && command.stage === "server-regressions").args, []);
assert.deepEqual(readdirSync(path.join(owned.root, "tmp")), []);
});
test("诊断跨过独立测试失败,浏览器失败与来源分别留痕", context => {
const { result, report } = runDiagnostic(context, { FAIL_STAGES: "server-unit,workflow-actions,server-regressions", FAIL_BROWSER: "1" });
assert.equal(result.status, 2, result.stderr);
for (const id of ["server-unit", "workflow-actions", "server-regressions", "browser"]) {
const stage = report.stages.find(stage => stage.id === id);
assert.equal(stage.status, "FAIL");
assert.equal(stage.failureKind, "test");
}
assert.ok(report.browser.cases.every(entry => entry.status === "failed"));
});
test("环境有效与 tracked output 被改写必须独立记录", context => {
const { result, report } = runDiagnostic(context, { MUTATE_ARCHIVE: "1" });
assert.equal(result.status, 2);
assert.equal(report.environmentQualified, true);
assert.equal(report.sourceUnchanged, true);
assert.equal(report.repositoryUnchanged, false);
assert.equal(report.cleanAtStart, true);
assert.equal(report.cleanAtEnd, false);
assert.equal(report.candidateIntegrityStatus, "CHANGED");
assert.ok(report.sourceAfter.repository.porcelainV1Z.includes("output/archive.png\0"));
});
for (const missing of Object.keys(diagnosticApproval)) {
test(`诊断缺授权记录拒绝 ${missing}`, context => {
const { result, report } = runDiagnostic(context, { [missing]: " " });
assert.notEqual(result.status, 0);
assert.equal(report, null);
assert.ok(!result.commands.some(command => command.command === "createdb" || command.command === "npx"));
});
}
test("PWA 前置失败只阻断浏览器依赖,其他测试仍执行", context => {
const { result, report } = runDiagnostic(context, { FAIL_BUILD_SHA: "111111111111" });
assert.equal(result.status, 2);
assert.equal(report.stages.find(stage => stage.id === "inputs").status, "FAIL");
assert.equal(report.stages.find(stage => stage.id === "server-unit").status, "PASS");
assert.equal(report.stages.find(stage => stage.id === "browser").status, "NOT_RUN");
assert.ok(!result.commands.some(command => command.args.includes("test:e2e:full")));
});
test("建库失败不执行该套件,后续独立新库仍可验证", context => {
const { result, report } = runDiagnostic(context, { FAIL_PREPARE_STAGE: "workflow-actions" });
assert.equal(result.status, 2);
assert.equal(report.stages.find(stage => stage.id === "workflow-actions").failureKind, "environment");
assert.ok(!result.commands.some(command => command.command === "regression" && command.stage === "workflow-actions"));
assert.equal(report.stages.find(stage => stage.id === "browser").status, "PASS");
});
test("清理失败必须记录,不能被测试退出 0 吞掉", context => {
const { result, report } = runDiagnostic(context, { FAIL_CLEANUP_STAGE: "workflow-actions" });
assert.equal(result.status, 2);
assert.equal(report.stages.find(stage => stage.id === "workflow-actions").failureKind, "cleanup");
assert.equal(report.scratchCleanup, "PASS");
});
test("必要构建失败明确阻断写库及浏览器,不把缺环境记为产品红项", context => {
const { result, report } = runDiagnostic(context, { FAIL_STAGES: "client-build" });
assert.equal(result.status, 2);
assert.equal(report.stages.find(stage => stage.id === "client-build").failureKind, "environment");
for (const id of ["workflow-actions", "server-regressions", "browser"]) {
assert.equal(report.stages.find(stage => stage.id === id).status, "NOT_RUN");
}
assert.ok(report.browser.cases.every(entry => entry.status === "NOT_RUN"));
assert.ok(!result.commands.some(command => command.command === "createdb"));
});
test("移除诊断固定非零保护的源码变异可观测", context => {
const owned = fixture(context, diagnosticApproval);
const source = path.join(owned.root, "scripts/qa/full-local-diagnostic.mjs");
const original = readFileSync(source, "utf8");
const mutated = original.replace("process.exitCode = 2;", "process.exitCode = 0;");
assert.notEqual(mutated, original);
writeFileSync(source, mutated);
const result = owned.run("run-web-gates.sh", ["full-local-diagnostic"]);
assert.equal(result.status, 0, result.stderr);
assert.throws(() => assert.equal(result.status, 2));
});
test("移除诊断 trace 实参的源码变异会被真实命令捕获拒绝", context => {
const owned = fixture(context, diagnosticApproval);
const source = path.join(owned.root, "scripts/qa/full-local-diagnostic-stage.sh");
const original = readFileSync(source, "utf8");
const mutated = original.replace("qa_run_full_browser --trace=on", "qa_run_full_browser");
assert.notEqual(mutated, original);
writeFileSync(source, mutated);
const result = owned.run("run-web-gates.sh", ["full-local-diagnostic"]);
assert.equal(result.status, 2, result.stderr);
const browser = result.commands.find(command => command.args.includes("test:e2e:full"));
assert.ok(browser);
assert.ok(!browser.args.includes("--trace=on"));
assert.throws(() => assert.deepEqual(browser.args.filter(argument => argument.startsWith("--trace")), ["--trace=on"]));
});
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { validateRegisteredEvidence } from "../check-trellis-governance.mjs";
function fixture(context) {
const root = mkdtempSync(path.join(os.tmpdir(), "hyb-evidence-test-"));
context.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(path.join(root, "output"));
const digest = createHash("sha256").update("archive").digest("hex");
const rows = ["first.png", "second.png"].map(name => `| \`${name}\` | \`${digest}\` |`);
const document = "| 截图 | SHA-256 |\n| --- | --- |\n" + rows.join("\n") + "\n";
writeFileSync(path.join(root, "evidence.md"), document);
for (const name of ["first.png", "second.png"]) writeFileSync(path.join(root, "output", name), "archive");
return { root, document, check: () => validateRegisteredEvidence(root, [{ document: "evidence.md", directory: "output", count: 2 }]) };
}
test("归档登记摘要匹配时计数明确", context => {
assert.deepEqual(fixture(context).check(), { checked: 2, errors: [] });
});
for (const mutation of ["overwrite", "missing", "symlink", "table-missing", "row-missing", "duplicate", "escape", "invalid-hash"]) test(`证据错误必须被机器发现:${mutation}`, context => {
const owned = fixture(context); const image = path.join(owned.root, "output/first.png");
if (mutation === "overwrite") writeFileSync(image, "new run");
if (["missing", "symlink"].includes(mutation)) rmSync(image);
if (mutation === "symlink") symlinkSync("second.png", image);
const changes = {
"table-missing": "history only\n",
"row-missing": owned.document.split("\n").slice(0, 3).join("\n"),
duplicate: owned.document.replace("second.png", "first.png"),
escape: owned.document.replace("first.png", "../first.png"),
"invalid-hash": owned.document.replace(/`[a-f0-9]{64}`/, "`unknown`"),
};
if (mutation in changes) writeFileSync(path.join(owned.root, "evidence.md"), changes[mutation]);
assert.ok(owned.check().errors.length > 0);
});
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { fullLocalSpecFiles, verifyFullLocalDiscovery } from "../verify-full-local-discovery.mjs";
function fixture(context) {
const root = mkdtempSync(path.join(os.tmpdir(), "hyb-discovery-test-"));
context.after(() => rmSync(root, { recursive: true, force: true }));
const files = ["pwa-cross-version-upgrade.spec.ts", "nested/ordinary.test.ts", "live-smoke.spec.ts", "manual/private.spec.ts"];
for (const file of files) {
const target = path.join(root, "client/tests/e2e", file);
mkdirSync(path.dirname(target), { recursive: true });
writeFileSync(target, "");
}
return root;
}
function report(files) {
return {
errors: [],
suites: files.map((file, index) => ({ file, specs: [{ file, id: `case-${index}`, title: `case ${index}`, line: 1, tests: [{ projectName: "" }] }] })),
};
}
test("归属来自源码:包含专用输入和 live 名称,不包含 manual", (context) => {
const root = fixture(context);
const files = fullLocalSpecFiles(root);
assert.equal(files.length, 3);
assert.ok(files.some((file) => file.endsWith("pwa-cross-version-upgrade.spec.ts")));
assert.ok(files.some((file) => file.endsWith("live-smoke.spec.ts")));
const summary = verifyFullLocalDiscovery(root, report(files));
assert.equal(summary.status, "PASS");
assert.equal(summary.kind, "FULL_LOCAL_DISCOVERY_ONLY");
assert.equal(summary.cases.length, 3);
});
test("只报 manifest 子集或排除 PWA 必须失败", (context) => {
const root = fixture(context);
const files = fullLocalSpecFiles(root).filter((file) => !file.includes("pwa-cross-version"));
const summary = verifyFullLocalDiscovery(root, report(files));
assert.equal(summary.status, "FAIL");
assert.deepEqual(summary.missingSpecs, ["client/tests/e2e/pwa-cross-version-upgrade.spec.ts"]);
});
test("新增非 manual 文件不能被旧清单遗漏", (context) => {
const root = fixture(context);
const original = report(fullLocalSpecFiles(root));
writeFileSync(path.join(root, "client/tests/e2e/newly-added.spec.ts"), "");
assert.deepEqual(verifyFullLocalDiscovery(root, original).missingSpecs, ["client/tests/e2e/newly-added.spec.ts"]);
});
test("完整文件集合也不能掩盖收集错误或零用例", (context) => {
const root = fixture(context);
const failed = report(fullLocalSpecFiles(root));
failed.errors.push({ message: "PWA_BUILD_A_DIR_missing" });
assert.ok(verifyFullLocalDiscovery(root, failed).issues.includes("discovery_errors"));
failed.errors = [];
failed.suites[0].specs[0].tests = [];
assert.ok(verifyFullLocalDiscovery(root, failed).issues.includes("spec_without_cases"));
assert.equal(verifyFullLocalDiscovery(root, {}).status, "FAIL");
});
test("manual、未知或越界来源不能冒充完整集合", (context) => {
const root = fixture(context);
for (const extra of ["manual/private.spec.ts", "unknown.spec.ts", "../../../outside.spec.ts"]) {
const summary = verifyFullLocalDiscovery(root, report([...fullLocalSpecFiles(root), extra]));
assert.equal(summary.status, "FAIL");
assert.ok(summary.issues.includes("unexpected_specs"));
}
});
test("支持 Playwright 的根相对、测试目录相对与绝对文件来源", (context) => {
const root = fixture(context);
const files = fullLocalSpecFiles(root);
const sources = [files[0].replace(/^client\//u, ""), files[1].replace(/^client\/tests\/e2e\//u, ""), path.join(root, files[2])];
assert.equal(verifyFullLocalDiscovery(root, report(sources)).status, "PASS");
});
test("符号链接必须有明确归属,不能静默跳过", (context) => {
const root = fixture(context);
symlinkSync("live-smoke.spec.ts", path.join(root, "client/tests/e2e/linked.spec.ts"));
assert.throws(() => fullLocalSpecFiles(root), /explicit_ownership/u);
});
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import * as fs from "node:fs";
import os from "node:os";
import path from "node:path";
import vm from "node:vm";
import test from "node:test";
const require = createRequire(new URL("../../../client/package.json", import.meta.url));
const ts = require("typescript");
for (const configured of [false, true]) test(`截图输出按 spec/加载隔离,配置输出根=${configured}`, context => {
const root = mkdtempSync(path.join(os.tmpdir(), "hyb-visual-path-test-"));
context.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(path.join(root, "client"));
const outputRoot = path.join(root, configured ? "report-visual" : "output/playwright");
const directories = [];
for (const [file, marker, output] of [
["internal-readiness-ui.spec.ts", "const WECOM_UA", "[SCREENSHOT_DIR]"],
["pc-finance-admin-visual-wave4.spec.ts", "const visualUser", "[outputDir, taskOutputDir]"],
]) {
const source = readFileSync(new URL(`../../../client/tests/e2e/${file}`, import.meta.url), "utf8");
assert.ok(source.includes(marker));
const prefix = source.slice(0, source.indexOf(marker));
assert.ok(!prefix.includes("07-18-small-team-internal-readiness"));
const script = ts.transpileModule(prefix + `\nglobalThis.observed = ${output};`, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText;
for (let attempt = 0; attempt < 2; attempt += 1) {
const sandbox = { exports: {}, process: { cwd: () => path.join(root, "client"), env: configured ? { QA_VISUAL_OUTPUT_DIR: outputRoot } : {} }, require: name => name === "node:fs" ? fs : name === "node:path" ? path : { test: { use() {} } } };
vm.runInNewContext(script, sandbox);
for (const directory of sandbox.observed) {
assert.ok(directory.startsWith(`${outputRoot}${path.sep}`));
assert.ok(!directory.includes("07-18-small-team-internal-readiness"));
assert.ok(!directories.includes(directory));
directories.push(directory);
}
}
}
});
@@ -0,0 +1,84 @@
#!/usr/bin/env node
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const TEST_FILE_PATTERN = /\.(?:spec|test)\.[cm]?[jt]sx?$/u;
export function fullLocalSpecFiles(root) {
const files = [];
function visit(directory) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (entry.name !== "manual") visit(path.join(directory, entry.name));
} else if (entry.isFile() && TEST_FILE_PATTERN.test(entry.name)) {
files.push(path.relative(root, path.join(directory, entry.name)).split(path.sep).join("/"));
} else if (entry.isSymbolicLink()) {
throw new Error("full_local_symlink_requires_explicit_ownership");
}
}
}
visit(path.join(root, "client/tests/e2e"));
return files.sort();
}
function sourceFile(root, file) {
if (typeof file !== "string" || !file) return "invalid_source";
let relative = path.isAbsolute(file) ? path.relative(root, file) : file;
relative = relative.replaceAll("\\", "/");
if (relative.startsWith("tests/e2e/")) relative = `client/${relative}`;
if (!relative.startsWith("client/tests/e2e/")) relative = `client/tests/e2e/${relative}`;
relative = path.posix.normalize(relative);
return relative.startsWith("client/tests/e2e/") ? relative : "invalid_source";
}
export function verifyFullLocalDiscovery(root, report) {
const expectedSpecs = fullLocalSpecFiles(root);
const cases = [];
const observed = new Set();
const issues = [];
function visit(suites, inheritedFile = "") {
for (const suite of Array.isArray(suites) ? suites : []) {
const suiteFile = suite.file ?? inheritedFile;
for (const spec of Array.isArray(suite.specs) ? suite.specs : []) {
const file = sourceFile(root, spec.file ?? suiteFile);
observed.add(file);
if (!Array.isArray(spec.tests) || !spec.tests.length) issues.push("spec_without_cases");
for (const test of Array.isArray(spec.tests) ? spec.tests : []) {
cases.push({ file, id: spec.id, title: spec.title, line: spec.line, projectName: test.projectName ?? "", repeatEachIndex: test.repeatEachIndex ?? 0 });
}
}
visit(suite.suites, suiteFile);
}
}
visit(report?.suites);
if (!Array.isArray(report?.suites) || !Array.isArray(report?.errors)) issues.push("invalid_discovery_report");
if (report?.errors?.length) issues.push("discovery_errors");
if (!expectedSpecs.length || !cases.length) issues.push("empty_discovery");
const missingSpecs = expectedSpecs.filter((file) => !observed.has(file));
const unexpectedSpecs = [...observed].filter((file) => !expectedSpecs.includes(file)).sort();
if (missingSpecs.length) issues.push("missing_specs");
if (unexpectedSpecs.length) issues.push("unexpected_specs");
return {
status: issues.length ? "FAIL" : "PASS",
kind: "FULL_LOCAL_DISCOVERY_ONLY",
expectedSpecs,
observedSpecs: [...observed].sort(),
missingSpecs,
unexpectedSpecs,
cases,
issues: [...new Set(issues)],
};
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const summary = verifyFullLocalDiscovery(root, JSON.parse(readFileSync(process.argv[2], "utf8")));
console.log(JSON.stringify(summary, null, 2));
process.exitCode = summary.status === "PASS" ? 0 : 1;
} catch {
console.error("[full-local-discovery] report_or_source_invalid");
process.exitCode = 1;
}
}