完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { coveragePlan, verifyLocalCoverage, verifyPostDeployCoverage } from "../full-local-coverage.mjs";
|
||||
|
||||
const candidate = { commit: "a".repeat(40), digest: "b".repeat(64) };
|
||||
function report(rows, executed = false) {
|
||||
return { errors: [], suites: rows.map(row => ({ file: row.file, specs: [{ ...row, tests: [{ projectName: "", repeatEachIndex: 0, ...(executed ? { expectedStatus: "passed", status: "expected", results: [{ status: "passed", retry: 0 }] } : {}) }] }] })) };
|
||||
}
|
||||
function fixture(context) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "hyb-coverage-"));
|
||||
context.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rows = ["local", "live", "frozen", "protection"].map(name => ({ file: `client/tests/e2e/${name}.spec.ts`, id: name, title: name, projectName: "", repeatEachIndex: 0 }));
|
||||
for (const row of rows) { mkdirSync(path.dirname(path.join(root, row.file)), { recursive: true }); writeFileSync(path.join(root, row.file), row.title); }
|
||||
const contract = { schemaVersion: 1, approvalReference: "synthetic", exceptions: rows.slice(1, 3).map(row => ({ ...row, kind: row.id === "live" ? "post-deploy" : "frozen", reason: "synthetic", entry: "synthetic", responsibilityRole: "synthetic", namedOwner: null, requiredEvidence: "synthetic", sourceSha256: createHash("sha256").update(row.title).digest("hex"), replacementFile: rows[3].file, replacementTitle: rows[3].title })) };
|
||||
const discovery = report(rows);
|
||||
const plan = () => coveragePlan(root, discovery, contract, candidate);
|
||||
return { root, rows, contract, discovery, plan };
|
||||
}
|
||||
|
||||
test("全发现分层,本地通过仍保留公网必跑且不能获得交付资格", context => {
|
||||
const f = fixture(context), p = f.plan();
|
||||
assert.equal(p.status, "PASS"); assert.equal(p.totalCases, 4); assert.equal(p.localCases.length, 2);
|
||||
const r = verifyLocalCoverage(p, report(p.localCases, true), candidate);
|
||||
assert.equal(r.status, "PASS"); assert.equal(r.deliveryStatus, "INCOMPLETE"); assert.equal(r.normalReleaseEligible, false);
|
||||
assert.deepEqual(r.obligations.map(row => row.status), ["NOT_RUN_LOCAL", "FROZEN_PROTECTION_PASS"]);
|
||||
});
|
||||
|
||||
for (const mutation of ["missing-file", "new-file", "new-live-case", "identity-drift", "duplicate", "source-drift", "no-protection", "unknown-exception", "missing-reason"]) test(`发现/归属反例:${mutation}`, context => {
|
||||
const f = fixture(context);
|
||||
if (mutation === "missing-file") f.discovery.suites.pop();
|
||||
if (mutation === "new-file") writeFileSync(path.join(f.root, "client/tests/e2e/new.spec.ts"), "new");
|
||||
if (mutation === "new-live-case") f.discovery.suites[1].specs.push({ ...f.discovery.suites[1].specs[0], id: "new-live", title: "new-live" });
|
||||
if (mutation === "identity-drift") f.discovery.suites[1].specs[0].id = "drift";
|
||||
if (mutation === "duplicate") f.discovery.suites.push(f.discovery.suites[0]);
|
||||
if (mutation === "source-drift") writeFileSync(path.join(f.root, f.rows[1].file), "changed");
|
||||
if (mutation === "no-protection") f.contract.exceptions[1].replacementTitle = "missing";
|
||||
if (mutation === "unknown-exception") f.contract.exceptions[1].kind = "ignore";
|
||||
if (mutation === "missing-reason") delete f.contract.exceptions[0].reason;
|
||||
assert.equal(f.plan().status, "FAIL");
|
||||
});
|
||||
|
||||
for (const mutation of ["missing", "unexpected", "duplicate", "skipped", "failed", "timedOut", "interrupted", "retry", "expected-failure", "fake-pass", "candidate", "errors", "title-drift"]) test(`本地结果反例:${mutation}`, context => {
|
||||
const f = fixture(context), p = f.plan(), r = report(p.localCases, true);
|
||||
let current = candidate;
|
||||
const row = r.suites[0].specs[0].tests[0];
|
||||
if (mutation === "missing") r.suites.pop();
|
||||
if (mutation === "unexpected") r.suites.push(report([f.rows[1]], true).suites[0]);
|
||||
if (mutation === "duplicate") r.suites.push(r.suites[0]);
|
||||
if (["skipped", "failed", "timedOut", "interrupted"].includes(mutation)) row.results[0].status = mutation;
|
||||
if (mutation === "retry") row.results.push({ status: "passed", retry: 1 });
|
||||
if (mutation === "expected-failure") row.expectedStatus = "failed";
|
||||
if (mutation === "fake-pass") { r.passed = true; delete row.results; }
|
||||
if (mutation === "candidate") current = { ...candidate, digest: "c".repeat(64) };
|
||||
if (mutation === "errors") r.errors.push({ message: "synthetic" });
|
||||
if (mutation === "title-drift") r.suites[0].specs[0].title = "changed";
|
||||
assert.equal(verifyLocalCoverage(p, r, current).status, "FAIL");
|
||||
});
|
||||
|
||||
test("公网只接受同候选 live 逐项结果,不接受本地或布尔通过", context => {
|
||||
const f = fixture(context), p = f.plan();
|
||||
const good = { profile: "post-deploy-live", mode: "live", candidate, deployedCandidate: candidate, report: report([f.rows[1]], true) };
|
||||
assert.equal(verifyPostDeployCoverage(p, good).status, "PASS");
|
||||
for (const bad of [{ passed: true }, { ...good, profile: "full-local" }, { ...good, mode: "local" }, { ...good, deployedCandidate: { ...candidate, commit: "c".repeat(40) } }, { ...good, candidate: { ...candidate, digest: "d".repeat(64) } }, { ...good, report: report(p.localCases, true) }]) assert.equal(verifyPostDeployCoverage(p, bad).status, "FAIL");
|
||||
});
|
||||
@@ -13,7 +13,7 @@ function receipt() {
|
||||
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 },
|
||||
nativeOnlineProbe: { valid: true, online: true, browserNamespace: "child", browserMatchesOwnedChild: true, noNavigatorOverride: true, engines: ["chromium", "webkit"].map(name => ({ name, version: "synthetic", online: true, valid: true, noNavigatorOverride: true, browserNamespace: "child", browserMatchesOwnedChild: true })) },
|
||||
bootstrapObservation: {
|
||||
valid: true, observationPoint: "guest-entry-black-box", freshContext: true, initialSessionHint: false,
|
||||
listenerBeforeNavigation: true, noRouteMocks: true, loginFormVisible: true, refreshRequestCount: 0, loginRequestCount: 0,
|
||||
@@ -66,13 +66,21 @@ test("缺 context 不接受 GATE_PASSED 或已有 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 且禁止默认出口", () => {
|
||||
test("拓扑只接纳同 namespace 内部 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);
|
||||
const links = [{ifname:"lo",ifindex:1},{ifname:"qaonline",ifindex:2,link:"qapeer",linkinfo:{info_kind:"veth"}},{ifname:"qapeer",ifindex:3,link:"qaonline",linkinfo:{info_kind:"veth"}}];
|
||||
const routes = [{dst:"default",dev:"qaonline",scope:"link"}];
|
||||
assert.equal(topologyMatches(addresses, routes, [], links), true);
|
||||
for(const mutate of [
|
||||
f=>{f.routes=[];}, f=>{f.routes.push(f.routes[0]);}, f=>{f.routes[0].gateway="192.0.2.2";},
|
||||
f=>{f.routes[0].dev="eth0";}, f=>{f.routes[0].scope="global";}, f=>{f.routes[0].multipath=[];},
|
||||
f=>{f.ipv6=[{dst:"default",dev:"qaonline"}];}, f=>{f.addresses.push({ifname:"eth0"});},
|
||||
f=>{f.links[1].link_netnsid=0;}, f=>{f.links[1].link="outside";}, f=>{f.links[1].linkinfo.info_kind="bridge";},
|
||||
f=>{f.links[1].master="outside";}, f=>{f.links.pop();}, f=>{f.links=undefined;},
|
||||
]) {
|
||||
const f=structuredClone({addresses,links,routes,ipv6:[]});mutate(f);
|
||||
assert.equal(topologyMatches(f.addresses,f.routes,f.ipv6,f.links),false);
|
||||
}
|
||||
});
|
||||
|
||||
for (const mismatch of [false, true]) test(`namespace 创建失败不降级,清理须匹配 owner:mismatch=${mismatch}`, context => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { prepareAccounts, localAccounts } from "../prepare-local-e2e-accounts.mjs";
|
||||
|
||||
const input = { origin: "http://127.0.0.1:12345", databaseName: "hyb_oa_webgate_synthetic", initialPassword: "Initial!synthetic-only-123456789" };
|
||||
|
||||
test("preparation refuses remote origins, wrong database and reused initial credentials before any request", async () => {
|
||||
for (const change of [{ origin: "https://example.invalid" }, { origin: "http://127.0.0.1:1/api" },
|
||||
{ databaseName: "production" }, { initialPassword: localAccounts[0].password }]) {
|
||||
let calls = 0;
|
||||
await assert.rejects(prepareAccounts({ ...input, ...change, fetchImpl: async () => { calls++; } }));
|
||||
assert.equal(calls, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("all roles must really require change, reject old password and login again; receipts contain no credentials", async () => {
|
||||
const calls = [];
|
||||
const result = await prepareAccounts({ ...input, fetchImpl: async (url, request) => {
|
||||
const step = calls.length % 4;
|
||||
calls.push({ url, body: JSON.parse(request.body) });
|
||||
assert.equal(request.redirect, "error");
|
||||
assert.equal(new URL(url).pathname, step === 1 ? "/api/auth/change-password" : "/api/auth/login");
|
||||
if (step === 1) assert.equal(request.headers.authorization, "Bearer synthetic");
|
||||
return { status: step === 2 ? 401 : 200, json: async () => ({ data: { accessToken: "synthetic", mustChangePassword: step === 0 } }) };
|
||||
} });
|
||||
assert.equal(calls.length, localAccounts.length * 4);
|
||||
assert.equal(result.accounts.length, 8);
|
||||
assert.ok(!JSON.stringify(result).includes(input.initialPassword));
|
||||
assert.ok(localAccounts.every(a => !JSON.stringify(result).includes(a.password)));
|
||||
});
|
||||
|
||||
test("one false precondition stops before changing any further account", async () => {
|
||||
for (const failingStep of [0, 1, 2, 3]) {
|
||||
let calls = 0;
|
||||
await assert.rejects(prepareAccounts({ ...input, fetchImpl: async () => {
|
||||
const step = calls++;
|
||||
return { status: step === failingStep ? 503 : step === 2 ? 401 : 200,
|
||||
json: async () => ({ data: { accessToken: "synthetic", mustChangePassword: step === 0 } }) };
|
||||
} }));
|
||||
assert.equal(calls, failingStep + 1);
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -31,8 +32,16 @@ function fixture(context, overrides = {}) {
|
||||
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 ["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", "full-local-coverage.mjs", "qa-stage-record.mjs"]) 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), "");
|
||||
const exceptions = [];
|
||||
if (overrides.LAYERED_FIXTURE) {
|
||||
for (const [file, kind] of [["live.spec.ts", "post-deploy"], ["frozen.spec.ts", "frozen"]]) {
|
||||
writeFileSync(path.join(root, "client/tests/e2e", file), "preserved");
|
||||
exceptions.push({ file: `client/tests/e2e/${file}`, id: file, title: file, projectName: "", repeatEachIndex: 0, kind, sourceSha256: createHash("sha256").update("preserved").digest("hex"), reason: "fixture", entry: "fixture", responsibilityRole: "fixture", requiredEvidence: "fixture", replacementFile: "client/tests/e2e/ordinary.spec.ts", replacementTitle: "ordinary.spec.ts" });
|
||||
}
|
||||
}
|
||||
writeFileSync(path.join(root, "scripts/qa/full-local-coverage-contract.json"), JSON.stringify({ schemaVersion: 1, approvalReference: "fixture", exceptions }));
|
||||
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}
|
||||
@@ -49,20 +58,21 @@ 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');
|
||||
fs.appendFileSync(path.join(root, 'commands.jsonl'), JSON.stringify({command,args,nodeEnv:process.env.NODE_ENV,imNotifierEnabled:process.env.IM_NOTIFIER_ENABLED,ocrProvider:process.env.OCR_PROVIDER,jobsEnabled:process.env.JOBS_ENABLED,ocrMock:process.env.PLAYWRIGHT_OCR_MOCK,syntheticInitialPresent:Boolean(process.env.QA_SYNTHETIC_INITIAL_PASSWORD),seedRolesPrepared:['CEO','ADMIN','MANAGER','EMPLOYEE','FINANCE','FINANCE_MGR','HR','ADMINISTRATION'].every(role=>Boolean(process.env['SEED_'+role+'_PASSWORD'])),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])) {
|
||||
if (['full-local','full-local-diagnostic','retained-server-local'].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('prepare-local-e2e-accounts.mjs')) process.exit(process.env.FAIL_ACCOUNT_PREPARATION ? 43 : 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')) {
|
||||
if (command === 'node' && (args[0]?.endsWith('verify-full-local-discovery.mjs') || args[0]?.endsWith('full-local-diagnostic.mjs') || args[0]?.endsWith('full-local-coverage.mjs') || args[0]?.endsWith('qa-stage-record.mjs') || args[0] === '-e')) {
|
||||
const result = spawnSync(process.execPath, args, {env:process.env, stdio:'inherit'});
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
@@ -91,7 +101,10 @@ 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')));
|
||||
if (process.env.LAYERED_FIXTURE) discovery.suites = discovery.suites.filter(suite => args.some(arg => {try { return new RegExp(arg).test('tests/e2e/'+suite.file); } catch { return false; }}));
|
||||
if (process.env.OMIT_EXECUTION_CASE) discovery.suites.pop();
|
||||
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}]});
|
||||
if (process.env.SKIP_EXECUTION_CASE) discovery.suites[0].specs[0].tests[0].results[0].status = 'skipped';
|
||||
fs.writeFileSync(process.env.PLAYWRIGHT_JSON_OUTPUT_FILE,JSON.stringify(discovery));
|
||||
}
|
||||
if (process.env.FAIL_BROWSER) process.exit(26);
|
||||
@@ -115,6 +128,10 @@ if (!['node','npm','npx','curl','psql','createdb','dropdb'].includes(command)) t
|
||||
return {
|
||||
root,
|
||||
run(script = "run-web-gates.sh", args = ["full-local"]) {
|
||||
// Each mutation invocation owns a fresh report; do not fail on a prior
|
||||
// invocation's exclusive output before reaching the mutated guard.
|
||||
rmSync(path.join(root, "report"), { recursive: true, force: true });
|
||||
rmSync(path.join(root, "commands.jsonl"), { force: true });
|
||||
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)) : [] };
|
||||
},
|
||||
@@ -128,12 +145,15 @@ for (const ci of [undefined, "1"]) {
|
||||
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"]);
|
||||
assert.ok(builds.every(command => command.nodeEnv === "production"));
|
||||
const discovery = result.commands.find((command) => command.args.includes("--list"));
|
||||
const browser = result.commands.find((command) => command.args.includes("test:e2e:full"));
|
||||
assert.equal(browser.imNotifierEnabled, "true");
|
||||
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.includes("--forbid-only"));
|
||||
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"));
|
||||
@@ -330,3 +350,132 @@ test("移除诊断 trace 实参的源码变异会被真实命令捕获拒绝", c
|
||||
assert.ok(!browser.args.includes("--trace=on"));
|
||||
assert.throws(() => assert.deepEqual(browser.args.filter(argument => argument.startsWith("--trace")), ["--trace=on"]));
|
||||
});
|
||||
|
||||
|
||||
test("普通 full-local 在回归前串行准备账号,随后不传播初始密码", context => {
|
||||
const owned = fixture(context);
|
||||
const result = owned.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const seed = result.commands.find(c => c.args.includes("seed:dev"));
|
||||
const preparations = result.commands.filter(c => c.args[0]?.endsWith("prepare-local-e2e-accounts.mjs"));
|
||||
assert.equal(preparations.length, 1);
|
||||
assert.equal(seed.seedRolesPrepared, true);
|
||||
assert.equal(preparations[0].syntheticInitialPresent, true);
|
||||
assert.ok(result.commands.indexOf(seed) < result.commands.indexOf(preparations[0]));
|
||||
const regression = result.commands.find(c => c.command === "regression");
|
||||
assert.ok(result.commands.indexOf(preparations[0]) < result.commands.indexOf(regression));
|
||||
const browser = result.commands.find(c => c.args.includes("test:e2e:full"));
|
||||
assert.equal(browser.syntheticInitialPresent, false);
|
||||
assert.equal(browser.seedRolesPrepared, false);
|
||||
});
|
||||
|
||||
test("普通 full-local 账号准备失败阻断回归及浏览器,删前置反例会被抓到", context => {
|
||||
const owned = fixture(context, { FAIL_ACCOUNT_PREPARATION: "1" });
|
||||
const verify = result => {
|
||||
assert.equal(result.status, 43);
|
||||
assert.ok(!result.commands.some(c => c.command === "regression" || c.args.includes("test:e2e:full")));
|
||||
assert.ok(result.commands.some(c => c.command === "dropdb"));
|
||||
};
|
||||
verify(owned.run());
|
||||
const file = path.join(owned.root, "scripts/qa/run-web-gates.sh");
|
||||
const source = readFileSync(file, "utf8");
|
||||
writeFileSync(file, source.replace("qa_full_stage accounts qa_prepare_synthetic_accounts", "qa_full_stage accounts : # removed preparation"));
|
||||
assert.throws(() => verify(owned.run()), assert.AssertionError);
|
||||
});
|
||||
|
||||
|
||||
for (const suite of ["workflow-actions", "server-regressions"]) {
|
||||
test(`受管限定服务端只运行已选套件并保持无发布资格 ${suite}`, context => {
|
||||
const owned = fixture(context, { QA_RETAINED_SERVER_SUITE: suite });
|
||||
const result = owned.run("run-web-gates.sh", ["retained-server-local"]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(result.commands.filter(c => c.command === "regression").map(c => c.stage), [suite]);
|
||||
assert.ok(!result.commands.some(c => c.args.includes("--list") || c.args.includes("test:e2e:full")));
|
||||
const selection = JSON.parse(readFileSync(path.join(owned.root, "report/selection.json")));
|
||||
assert.equal(selection.normalReleaseEligible, false);
|
||||
assert.equal(selection.scope, "focused-server-regression");
|
||||
assert.equal(selection.suite, suite);
|
||||
const regressionIndex = result.commands.findIndex(c => c.command === "regression");
|
||||
const accountPreparation = result.commands.find(c => c.args[0]?.endsWith("prepare-local-e2e-accounts.mjs"));
|
||||
assert.equal(accountPreparation.imNotifierEnabled, suite === "server-regressions" ? "true" : "false");
|
||||
for (const script of ["browser-environment-probe.mjs", "prepare-local-e2e-accounts.mjs"]) {
|
||||
const index = result.commands.findIndex(c => c.args[0]?.endsWith(script));
|
||||
assert.ok(index >= 0 && index < regressionIndex);
|
||||
}
|
||||
assert.ok(result.commands.some(c => c.command === "dropdb"));
|
||||
});
|
||||
}
|
||||
|
||||
for (const suite of [undefined, "../workflow-actions", "workflow-actions; true"]) {
|
||||
test(`限定服务端拒绝缺失或任意命令选择 ${suite}`, context => {
|
||||
const owned = fixture(context, suite ? { QA_RETAINED_SERVER_SUITE: suite } : {});
|
||||
const result = owned.run("run-web-gates.sh", ["retained-server-local"]);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.ok(!result.commands.some(c => c.command === "createdb" || c.command === "regression"));
|
||||
});
|
||||
}
|
||||
|
||||
for (const flag of ["FAIL_ENVIRONMENT_PROBE", "FAIL_ACCOUNT_PREPARATION"]) {
|
||||
test(`限定服务端前置失败不进入回归 ${flag}`, context => {
|
||||
const owned = fixture(context, { QA_RETAINED_SERVER_SUITE: "workflow-actions", [flag]: "1" });
|
||||
const result = owned.run("run-web-gates.sh", ["retained-server-local"]);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.ok(!result.commands.some(c => c.command === "regression"));
|
||||
assert.ok(result.commands.some(c => c.command === "dropdb"));
|
||||
});
|
||||
}
|
||||
|
||||
for (const [overrides, enabled] of [
|
||||
[{}, true],
|
||||
[{ PLAYWRIGHT_OCR_MOCK: "0" }, true],
|
||||
[{ PLAYWRIGHT_OCR_MOCK: "1", JOBS_ENABLED_FOR_E2E: "false" }, false],
|
||||
[{ PLAYWRIGHT_OCR_MOCK: "1", OCR_PROVIDER: "external-fixture" }, false],
|
||||
]) {
|
||||
test(`OCR 修正能力绑定已启动服务而非调用者标志 ${JSON.stringify(overrides)}`, context => {
|
||||
const owned = fixture(context, overrides);
|
||||
const result = owned.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const browser = result.commands.find(c => c.args.includes("test:e2e:full"));
|
||||
assert.equal(browser.ocrMock, enabled ? "1" : "0");
|
||||
assert.equal(browser.ocrMock === "1", browser.ocrProvider === "mock" && browser.jobsEnabled === "true");
|
||||
const capabilities = readFileSync(path.join(owned.root, "report/server-capabilities.jsonl"), "utf8").trim().split("\n").map(JSON.parse);
|
||||
assert.equal(capabilities.at(-1).mockCorrectionEnabled, enabled);
|
||||
assert.equal(capabilities.at(-1).source, "owned-server-start");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
test("分层 full-local 真实入口从总发现派生集合并保留公网与冻结义务", context => {
|
||||
const owned = fixture(context, { LAYERED_FIXTURE: "1" });
|
||||
const result = owned.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const plan = JSON.parse(readFileSync(path.join(owned.root, "report/coverage-plan.json")));
|
||||
const receipt = JSON.parse(readFileSync(path.join(owned.root, "report/local-coverage-result.json")));
|
||||
assert.equal(plan.totalCases, 4);
|
||||
assert.equal(plan.localCases.length, 2);
|
||||
assert.deepEqual(receipt.obligations.map(row => row.status), ["NOT_RUN_LOCAL", "FROZEN_PROTECTION_PASS"]);
|
||||
assert.equal(receipt.deliveryStatus, "INCOMPLETE");
|
||||
assert.equal(receipt.normalReleaseEligible, false);
|
||||
const browser = result.commands.find(command => command.args.includes("test:e2e:full"));
|
||||
assert.ok(!browser.args.some(arg => arg.includes("live.spec") || arg.includes("frozen.spec")));
|
||||
});
|
||||
|
||||
for (const flag of ["OMIT_EXECUTION_CASE", "SKIP_EXECUTION_CASE"]) test(`即使浏览器退出零也拒绝漏例或跳过:${flag}`, context => {
|
||||
const owned = fixture(context, { LAYERED_FIXTURE: "1", [flag]: "1" });
|
||||
const result = owned.run();
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(JSON.parse(readFileSync(path.join(owned.root, "report/local-coverage-result.json"))).status, "FAIL");
|
||||
});
|
||||
|
||||
|
||||
test("删除真实覆盖消费必须使跳过反例失去阻断", context => {
|
||||
const owned = fixture(context, { LAYERED_FIXTURE: "1", SKIP_EXECUTION_CASE: "1" });
|
||||
const assertBlocked = result => assert.notEqual(result.status, 0);
|
||||
assertBlocked(owned.run());
|
||||
const source = path.join(owned.root, "scripts/qa/run-web-gates.sh");
|
||||
const original = readFileSync(source, "utf8");
|
||||
const protectedCall = 'node "$ROOT_DIR/scripts/qa/full-local-coverage.mjs" verify-local "$QA_FULL_REPORT_DIR"';
|
||||
assert.ok(original.includes(protectedCall));
|
||||
writeFileSync(source, original.replace(protectedCall, ':'));
|
||||
assert.throws(() => assertBlocked(owned.run()), assert.AssertionError);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
import { captureReleaseSource, verifyReleaseEvidence, validateSkipRequest, confinedPath } from '../release-gate-result.mjs';
|
||||
import { coveragePlan, localSpecArgs } from '../full-local-coverage.mjs';
|
||||
import { FULL_LOCAL_STAGES, newStageRecord, updateStageRecord, validateStages } from '../qa-stage-record.mjs';
|
||||
|
||||
const time = seconds => new Date(Date.UTC(2026,8,18,0,0,seconds)).toISOString();
|
||||
function stages(ids, profile, start=1) {
|
||||
return {schemaVersion:1,profile,status:'PASS',exitCode:0,startedAt:time(start),endedAt:time(start+ids.length*2+1),stages:ids.map((id,index)=>({id,status:'PASS',exitCode:0,startedAt:time(start+index*2),endedAt:time(start+index*2+1)}))};
|
||||
}
|
||||
function fixture(context) {
|
||||
const root=mkdtempSync(path.join(os.tmpdir(),'hyb-release-evidence-'));
|
||||
context.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
mkdirSync(path.join(root,'client/tests/e2e'),{recursive:true});
|
||||
const rows=Array.from({length:10},(_,i)=>({file:`client/tests/e2e/flow-${i}.spec.ts`,id:`flow-${i}`,title:`flow-${i} @local-candidate:flow-${i}`,line:1}));
|
||||
for(const row of rows) writeFileSync(path.join(root,row.file),'synthetic');
|
||||
const source={commit:'a'.repeat(40),tree:'b'.repeat(40),digest:'c'.repeat(64),untrackedDigest:'d'.repeat(64),dirty:false,repository:{trackedCoverageComplete:true,trackedDigest:'e'.repeat(64),indexDigest:'f'.repeat(64),porcelainV1Z:''}};
|
||||
const policy={digest:'policy',environmentDigest:'environment',runnerDigest:'runner',probeDigest:'probe',toolchain:{node:process.version,playwright:'1.58.2',browserRegistry:'browser',browserVersions:{chromium:'synthetic',webkit:'synthetic'},platform:'linux',arch:'x64',ci:false,workers:4}};
|
||||
const facts={root,source,policy,contract:{schemaVersion:1,approvalReference:'synthetic',exceptions:[]},manifest:{localCandidateFlows:rows.map((row,i)=>({bundle:`flow-${i}`,witnessTag:`@local-candidate:flow-${i}`,witnessSpec:row.file})),acceptanceSpecs:rows.map(row=>row.file)}};
|
||||
const report=executed=>({errors:[],suites:rows.map(row=>({file:row.file,specs:[{...row,tests:[{projectName:'',repeatEachIndex:0,...(executed?{expectedStatus:'passed',status:'expected',results:[{status:'passed',retry:0}]}:{})}]}]}))});
|
||||
const discovery=report(false),execution=report(true);
|
||||
execution.config={forbidOnly:true,projects:[{name:'',retries:0,repeatEach:1,timeout:180000,testIgnore:['**/manual/**']}],shard:null,maxFailures:0,workers:4,version:'1.58.2'};
|
||||
execution.stats={startTime:time(26),duration:500,expected:10,unexpected:0,flaky:0,skipped:0};
|
||||
const environment={schemaVersion:1,profile:'isolated-online-v1',mode:'full-local',policyDigest:'environment',platform:'linux',status:'PASS',exitCode:0,childExit:0,childSignal:null,interrupted:null,startedAt:time(3),endedAt:time(30),sourceBefore:structuredClone(source),sourceAfter:structuredClone(source),sourceUnchanged:true,repositoryUnchanged:true,candidateIntegrityStatus:'UNCHANGED',cleanup:{containerOwnerVerified:true,containerRemoved:true,scratchRemoved:true},environment:{schemaVersion:1,profile:'isolated-online-v1',policyDigest:'environment',platform:'linux',runnerDigest:'runner',probeDigest:'probe',supervisorObservation:{source:'live-supervisor-unix-ipc',sampledAt:time(3),namespace:'parent'},namespace:{inheritedRunner:'parent',ownedChild:'child',runnerCreated:true,currentMatchesOwnedChild:true,topologyMatched:true},nativeOnlineProbe:{valid:true,online:true,browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,engines:['chromium','webkit'].map(name=>({name,version:'synthetic',online:true,valid:true,noNavigatorOverride:true,browserNamespace:'child',browserMatchesOwnedChild: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}}};
|
||||
const record={schemaVersion:1,profile:'release-predeploy',mode:'verify-candidate',runId:'a'.repeat(24),startedAt:time(0),finishedAt:time(33),executionExit:0,policy:structuredClone(policy),sourceBefore:structuredClone(source),sourceAfter:structuredClone(source),phases:stages(['static','full-local'],'release-predeploy-stages')};
|
||||
record.phases.endedAt=time(32);record.phases.stages[1].endedAt=time(31);
|
||||
const bundle={environment,phases:stages(FULL_LOCAL_STAGES,'full-local-stages',4),discovery,execution,plan:coveragePlan(root,discovery,facts.contract,source)};
|
||||
bundle.selection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(bundle.plan),additionalArgs:[],retries:0,forbidOnly:true,workers:4,mode:'local'};
|
||||
return {root,record,bundle,facts,verify:()=>verifyReleaseEvidence(record,bundle,facts)};
|
||||
}
|
||||
|
||||
test('完整同源分阶段结果获得发布前资格,但公网交付仍未完成',context=>{
|
||||
const f=fixture(context), result=f.verify();assert.equal(result.status,'PASS',JSON.stringify(result.issues));assert.equal(result.deploymentEligible,true);assert.equal(result.deliveryStatus,'INCOMPLETE');assert.equal(result.localCases.length,10);assert.equal(result.witnessSummary.passed,10);
|
||||
});
|
||||
|
||||
test('dirty 开发树可完成本地检查,但始终 PARTIAL/无发布资格',context=>{
|
||||
const f=fixture(context);
|
||||
for(const source of [f.facts.source,f.record.sourceBefore,f.record.sourceAfter,f.bundle.environment.sourceBefore,f.bundle.environment.sourceAfter]){source.dirty=true;source.repository.porcelainV1Z=' M client/file\0';}
|
||||
const result=f.verify();assert.equal(result.localChecksPassed,true,JSON.stringify(result.issues));assert.equal(result.status,'PARTIAL');assert.equal(result.deploymentEligible,false);
|
||||
});
|
||||
|
||||
const mutations={
|
||||
'missing-receipt':f=>{f.record={};},
|
||||
'old-schema':f=>{f.record.schemaVersion=0;},
|
||||
'focused-profile':f=>{f.record.profile='local-candidate';},
|
||||
'no-finish':f=>{f.record.finishedAt=null;},
|
||||
'failed-run':f=>{f.record.executionExit=17;},
|
||||
'interrupted':f=>{f.record.interrupted='SIGTERM';},
|
||||
'phase-missing':f=>{f.record.phases.stages.pop();},
|
||||
'phase-skipped':f=>{f.record.phases.stages[0].status='SKIP';},
|
||||
'phase-failed':f=>{f.record.phases.stages[0].exitCode=3;},
|
||||
'phase-reversed':f=>{f.record.phases.stages.reverse();},
|
||||
'phase-after-completion':f=>{f.record.phases.endedAt=time(40);},
|
||||
'inner-phase-missing':f=>{f.bundle.phases.stages.pop();},
|
||||
'inner-phase-failed':f=>{f.bundle.phases.stages[2].status='FAIL';},
|
||||
'inner-profile-invalid':f=>{f.bundle.phases.profile='diagnostic';},
|
||||
'inner-time-outside-run':f=>{f.bundle.phases.startedAt=time(0);},
|
||||
'changed-commit':f=>{f.facts.source.commit='1'.repeat(40);},
|
||||
'changed-source':f=>{f.facts.source.digest='1'.repeat(64);},
|
||||
'changed-index':f=>{f.facts.source.repository.indexDigest='1'.repeat(64);},
|
||||
'changed-untracked-input':f=>{f.facts.source.untrackedDigest='1'.repeat(64);},
|
||||
'source-unknown':f=>{f.record.sourceBefore.repository.trackedCoverageComplete=false;},
|
||||
'policy-drift':f=>{f.record.policy.digest='old';},
|
||||
'toolchain-drift':f=>{f.record.policy.toolchain.playwright='old';},
|
||||
'environment-missing':f=>{delete f.bundle.environment;},
|
||||
'environment-focused':f=>{f.bundle.environment.mode='retained-local';},
|
||||
'environment-child-failed':f=>{f.bundle.environment.childExit=2;},
|
||||
'environment-exit-failed':f=>{f.bundle.environment.exitCode=2;},
|
||||
'environment-source-mismatch':f=>{f.bundle.environment.sourceBefore.digest='1'.repeat(64);},
|
||||
'environment-self-claimed':f=>{delete f.bundle.environment.environment.namespace;},
|
||||
'native-offline':f=>{f.bundle.environment.environment.nativeOnlineProbe.online=false;},
|
||||
'webkit-version-drift':f=>{f.bundle.environment.environment.nativeOnlineProbe.engines[1].version='old';},
|
||||
'webkit-native-offline':f=>{f.bundle.environment.environment.nativeOnlineProbe.engines[1].online=false;},
|
||||
'guest-refresh':f=>{f.bundle.environment.environment.bootstrapObservation.refreshRequestCount=1;},
|
||||
'guest-mock':f=>{f.bundle.environment.environment.bootstrapObservation.noRouteMocks=false;},
|
||||
'runner-digest-drift':f=>{f.bundle.environment.environment.runnerDigest='old';},
|
||||
'cleanup-missing':f=>{f.bundle.environment.cleanup.containerRemoved=false;},
|
||||
'discovery-truncated':f=>{f.bundle.discovery.suites.pop();},
|
||||
'plan-truncated':f=>{f.bundle.plan.localCases.pop();},
|
||||
'execution-missing':f=>{f.bundle.execution.suites.pop();},
|
||||
'execution-duplicate':f=>{f.bundle.execution.suites.push(f.bundle.execution.suites[0]);},
|
||||
'execution-unknown':f=>{f.bundle.execution.suites[0].specs[0].id='unknown';},
|
||||
'execution-skipped':f=>{f.bundle.execution.suites[0].specs[0].tests[0].results[0].status='skipped';},
|
||||
'execution-retried':f=>{f.bundle.execution.suites[0].specs[0].tests[0].results[0].retry=1;},
|
||||
'execution-xfail':f=>{f.bundle.execution.suites[0].specs[0].tests[0].expectedStatus='failed';},
|
||||
'execution-global-error':f=>{f.bundle.execution.errors.push({message:'synthetic'});},
|
||||
'selection-grep':f=>{f.bundle.selection.additionalArgs=['--grep=one'];},
|
||||
'selection-missing':f=>{delete f.bundle.selection;},
|
||||
'config-missing':f=>{delete f.bundle.execution.config;},
|
||||
'projects-missing':f=>{delete f.bundle.execution.config.projects;},
|
||||
'only-allowed':f=>{f.bundle.execution.config.forbidOnly=false;},
|
||||
'sharded':f=>{f.bundle.execution.config.shard={current:1,total:2};},
|
||||
'serialized':f=>{f.bundle.execution.config.workers=1;},
|
||||
'budget-increased':f=>{f.bundle.execution.config.projects[0].timeout=360000;},
|
||||
'stats-false-pass':f=>{f.bundle.execution.stats.expected=11;},
|
||||
'browser-time-mismatch':f=>{f.bundle.execution.stats.startTime=time(0);},
|
||||
'acceptance-missing':f=>{f.facts.manifest.acceptanceSpecs.push('client/tests/e2e/absent.spec.ts');},
|
||||
'witness-missing':f=>{f.bundle.execution.suites[0].specs[0].title='missing';},
|
||||
};
|
||||
for(const [name,mutate] of Object.entries(mutations))test(`拒绝伪通过:${name}`,context=>{
|
||||
const f=fixture(context);mutate(f);const r=verifyReleaseEvidence(f.record,f.bundle,f.facts);assert.equal(r.deploymentEligible,false);assert.ok(r.issues.length);
|
||||
});
|
||||
|
||||
test('阶段记录保留真实失败、未跑阶段与清理失败,不吞早期错误',()=>{
|
||||
const r=newStageRecord(['a','b'],'fixture');assert.throws(()=>updateStageRecord(r,'start','b'));
|
||||
updateStageRecord(r,'start','a');updateStageRecord(r,'finish',null,23);
|
||||
assert.equal(r.status,'FAIL');assert.equal(r.exitCode,23);assert.equal(r.stages[0].exitCode,23);assert.equal(r.stages[1].status,'NOT_RUN');assert.throws(()=>updateStageRecord(r,'start','b'));
|
||||
const complete=newStageRecord(['a'],'fixture');updateStageRecord(complete,'start','a');updateStageRecord(complete,'end','a',0);updateStageRecord(complete,'finish',null,31);assert.ok(validateStages(complete,['a'],'fixture').length);
|
||||
});
|
||||
|
||||
test('skip 精确值和批准记录,仅诊断 PARTIAL,普通发布前入口拒绝',()=>{
|
||||
assert.equal(validateSkipRequest('verify-candidate',{}),null);
|
||||
for(const value of ['true','yes','01','', '2'])assert.throws(()=>validateSkipRequest('project-wide',{PROJECT_WIDE_SKIP_VISUAL:value}));
|
||||
const env={PROJECT_WIDE_SKIP_VISUAL:'1',PROJECT_WIDE_SKIP_REASON:'synthetic',PROJECT_WIDE_SKIP_OPERATOR:'fixture',PROJECT_WIDE_SKIP_APPROVAL_REF:'fixture-only'};
|
||||
assert.throws(()=>validateSkipRequest('verify-candidate',env));
|
||||
for(const key of ['PROJECT_WIDE_SKIP_REASON','PROJECT_WIDE_SKIP_OPERATOR','PROJECT_WIDE_SKIP_APPROVAL_REF'])assert.throws(()=>validateSkipRequest('project-wide',{...env,[key]:''}));
|
||||
const r=validateSkipRequest('project-wide',env);assert.equal(r.status,'PARTIAL');assert.equal(r.deploymentEligible,false);assert.deepEqual(r.affectedStages,['project-wide-visual']);
|
||||
});
|
||||
|
||||
test('回执路径拒绝越界、现存或断裂符号链接',context=>{
|
||||
const f=fixture(context);
|
||||
for(const input of ['../outside','/absolute','a/../b','a//b','a\\b'])assert.throws(()=>confinedPath(f.root,input));
|
||||
symlinkSync('/nonexistent-synthetic-target',path.join(f.root,'link'));
|
||||
assert.throws(()=>confinedPath(f.root,'link'));
|
||||
symlinkSync(f.root,path.join(f.root,'directory'));
|
||||
assert.throws(()=>confinedPath(f.root,'directory/file'));
|
||||
});
|
||||
|
||||
test('原有未跟踪部署文件内容变化,即使 Git status 相同也使来源变化',context=>{
|
||||
const f=fixture(context);
|
||||
for(const args of [['init','-q'],['add','.'],['-c','user.name=QA Fixture','-c','[email protected]','commit','-qm','synthetic fixture']])assert.equal(spawnSync('git',args,{cwd:f.root}).status,0);
|
||||
mkdirSync(path.join(f.root,'deploy'));writeFileSync(path.join(f.root,'deploy/new.sh'),'first');
|
||||
const before=captureReleaseSource(f.root);writeFileSync(path.join(f.root,'deploy/new.sh'),'second');const after=captureReleaseSource(f.root);
|
||||
assert.equal(before.repository.porcelainV1Z,after.repository.porcelainV1Z);assert.notEqual(before.untrackedDigest,after.untrackedDigest);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
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 path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
const projectRoot=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../../..');
|
||||
const receiptFiles={environment:'environment/result.json',phases:'results/full-local-stages.json',discovery:'results/discovery.json',plan:'results/coverage-plan.json',execution:'results/playwright-results.json',selection:'results/execution-selection.json'};
|
||||
function fixture(context,overrides={}) {
|
||||
const root=mkdtempSync(path.join(os.tmpdir(),'hyb-release-shell-'));
|
||||
context.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const dir of ['scripts/qa','deploy/trialrun','client/tests/e2e','server','bin','client/node_modules/@playwright/test','client/node_modules/playwright-core'])mkdirSync(path.join(root,dir),{recursive:true});
|
||||
for(const file of readdirSync(path.join(projectRoot,'scripts/qa')).filter(file=>file.endsWith('.mjs')||file.endsWith('.sh')||file.endsWith('.py')))copyFileSync(path.join(projectRoot,'scripts/qa',file),path.join(root,'scripts/qa',file));
|
||||
const rows=Array.from({length:10},(_,i)=>({file:`client/tests/e2e/flow-${i}.spec.ts`,id:`flow-${i}`,title:`flow-${i} @local-candidate:flow-${i}`,line:1}));
|
||||
rows.push({file:'client/tests/e2e/ordinary.spec.ts',id:'ordinary',title:'ordinary',line:1});
|
||||
for(const row of rows)writeFileSync(path.join(root,row.file),'synthetic');
|
||||
const manifest={localCandidateFlows:rows.slice(0,10).map((row,i)=>({bundle:`flow-${i}`,witnessTag:`@local-candidate:flow-${i}`,witnessSpec:row.file})),acceptanceSpecs:rows.map(row=>row.file)};
|
||||
const contract={schemaVersion:1,approvalReference:'synthetic',exceptions:[]};
|
||||
writeFileSync(path.join(root,'scripts/qa/route-manifest.json'),JSON.stringify(manifest));
|
||||
writeFileSync(path.join(root,'scripts/qa/full-local-coverage-contract.json'),JSON.stringify(contract));
|
||||
for(const file of ['client/package.json','client/package-lock.json','server/package.json','server/package-lock.json','client/node_modules/playwright-core/browsers.json'])writeFileSync(path.join(root,file),'{}');
|
||||
writeFileSync(path.join(root,'client/node_modules/playwright-core/browsers.json'),JSON.stringify({browsers:['chromium','webkit'].map(name=>({name,browserVersion:'synthetic'}))}));
|
||||
writeFileSync(path.join(root,'client/node_modules/@playwright/test/package.json'),JSON.stringify({version:'1.58.2'}));
|
||||
writeFileSync(path.join(root,'client/playwright.config.ts'),'// synthetic policy');
|
||||
writeFileSync(path.join(root,'deploy/trialrun/validate-env-contract.js'),'// fake tooling owns this command');
|
||||
writeFileSync(path.join(root,'.gitignore'),'artifacts/\nbin/\nnode_modules/\n');
|
||||
const env={PATH:`${path.join(root,'bin')}:${process.env.PATH}`,LANG:'C.UTF-8',HYB_RELEASE_FIXTURE_ROOT:root,RELEASE_GATE_ARTIFACT_DIR:path.join(root,'artifacts/release-gate/run'),...overrides};
|
||||
const fake=`#!${process.execPath}
|
||||
const fs=require('node:fs'),path=require('node:path'),{spawnSync}=require('node:child_process');
|
||||
const root=process.env.HYB_RELEASE_FIXTURE_ROOT,args=process.argv.slice(2),name=path.basename(process.argv[1]);
|
||||
fs.mkdirSync(path.join(root,'artifacts'),{recursive:true});fs.appendFileSync(path.join(root,'artifacts/commands.jsonl'),JSON.stringify({name,args})+'\\n');
|
||||
if(name==='node' && args[0]?.endsWith('release-gate-result.mjs')){const result=spawnSync(process.execPath,args,{env:process.env,stdio:'inherit'});process.exit(result.status??1);}
|
||||
if(name==='node' && args[0]?.endsWith('validate-env-contract.js') && process.env.FAIL_STATIC)process.exit(23);
|
||||
if(name==='bash' && args[0]?.endsWith('run-web-gates.sh') && args[1]==='full-local'){if(process.env.OMIT_EVIDENCE)process.exit(0);const result=spawnSync(process.execPath,[path.join(root,'bin/evidence.mjs')],{env:process.env,stdio:'inherit'});process.exit(result.status??1);}
|
||||
if(name==='bash' && args[0]?.includes('deploy/trialrun'))process.exit(91);
|
||||
if(!['node','npm','bash','python3'].includes(name))throw Error('unexpected command');
|
||||
`;
|
||||
writeFileSync(path.join(root,'bin/fake'),fake,{mode:0o755});
|
||||
for(const name of ['node','npm','bash','python3'])symlinkSync('fake',path.join(root,'bin',name));
|
||||
writeFileSync(path.join(root,'bin/evidence.mjs'),`
|
||||
import {mkdirSync,writeFileSync,readFileSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {captureReleaseSource,currentPolicy} from '../scripts/qa/release-gate-result.mjs';
|
||||
import {FULL_LOCAL_STAGES,newStageRecord,updateStageRecord} from '../scripts/qa/qa-stage-record.mjs';
|
||||
import {coveragePlan,localSpecArgs} from '../scripts/qa/full-local-coverage.mjs';
|
||||
const root=process.env.HYB_RELEASE_FIXTURE_ROOT,base=process.env.QA_FULL_REPORT_DIR,now=()=>new Date().toISOString();
|
||||
const source=captureReleaseSource(root),policy=currentPolicy(root),startedAt=now();
|
||||
const phases=newStageRecord(FULL_LOCAL_STAGES,'full-local-stages');
|
||||
const rows=${JSON.stringify(rows)},contract=${JSON.stringify(contract)};
|
||||
const report=executed=>({errors:[],suites:rows.map(row=>({file:row.file,specs:[{...row,tests:[{projectName:'',repeatEachIndex:0,...(executed?{expectedStatus:'passed',status:'expected',results:[{status:'passed',retry:0}]}:{})}]}]}))});
|
||||
const discovery=report(false),execution=report(true);
|
||||
execution.config={forbidOnly:true,projects:[{name:'',retries:0,repeatEach:1,timeout:180000,testIgnore:['**/manual/**']}],shard:null,maxFailures:0,workers:policy.toolchain.workers,version:policy.toolchain.playwright};
|
||||
for(const stage of FULL_LOCAL_STAGES){updateStageRecord(phases,'start',stage);if(stage==='browser')execution.stats={startTime:now(),duration:0,expected:rows.length,unexpected:0,flaky:0,skipped:0};updateStageRecord(phases,'end',stage,0);}updateStageRecord(phases,'finish',null,0);
|
||||
const environment={schemaVersion:1,profile:'isolated-online-v1',mode:'full-local',policyDigest:policy.environmentDigest,platform:process.platform,status:'PASS',exitCode:0,childExit:0,childSignal:null,interrupted:null,startedAt,endedAt:now(),sourceBefore:source,sourceAfter:source,sourceUnchanged:true,repositoryUnchanged:true,candidateIntegrityStatus:'UNCHANGED',cleanup:{containerOwnerVerified:true,containerRemoved:true,scratchRemoved:true},environment:{schemaVersion:1,profile:'isolated-online-v1',policyDigest:policy.environmentDigest,platform:process.platform,runnerDigest:policy.runnerDigest,probeDigest:policy.probeDigest,supervisorObservation:{source:'live-supervisor-unix-ipc',sampledAt:now(),namespace:'parent'},namespace:{inheritedRunner:'parent',ownedChild:'child',runnerCreated:true,currentMatchesOwnedChild:true,topologyMatched:true},nativeOnlineProbe:{valid:true,online:true,browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,engines:['chromium','webkit'].map(name=>({name,version:'synthetic',online:true,valid:true,noNavigatorOverride:true,browserNamespace:'child',browserMatchesOwnedChild: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}}};
|
||||
const bundle={environment,phases,discovery,execution,plan:coveragePlan(root,discovery,contract,source)};
|
||||
bundle.selection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(bundle.plan),additionalArgs:[],retries:0,forbidOnly:true,workers:policy.toolchain.workers,mode:'local'};
|
||||
if(process.env.BAD_PROFILE)environment.mode='retained-local';
|
||||
if(process.env.BAD_SOURCE)environment.sourceBefore={...source,digest:'0'.repeat(64)};
|
||||
if(process.env.BAD_COVERAGE)execution.suites.pop();
|
||||
if(process.env.BAD_CLEANUP)environment.cleanup.containerRemoved=false;
|
||||
if(process.env.BAD_STAGE)phases.stages.splice(2,1);
|
||||
for(const [key,relative] of Object.entries(${JSON.stringify(receiptFiles)})){const file=path.join(base,relative);mkdirSync(path.dirname(file),{recursive:true});writeFileSync(file,JSON.stringify(bundle[key]));}
|
||||
if(process.env.FAIL_FULL)process.exit(17);
|
||||
`);
|
||||
for(const args of [['init','-q'],['add','.'],['-c','user.name=QA Fixture','-c','[email protected]','commit','-qm','synthetic fixture']])assert.equal(spawnSync('git',args,{cwd:root}).status,0);
|
||||
return {root,env,run(mode='verify-candidate') {
|
||||
rmSync(path.join(root,'artifacts'),{recursive:true,force:true});
|
||||
const result=spawnSync('/bin/bash',[path.join(root,'scripts/qa/run-release-gate.sh'),mode],{cwd:root,env,encoding:'utf8',timeout:30000});
|
||||
const file=path.join(env.RELEASE_GATE_ARTIFACT_DIR,'release-result.json');
|
||||
return {...result,record:existsSync(file)?JSON.parse(readFileSync(file)):null,commands:existsSync(path.join(root,'artifacts/commands.jsonl'))?readFileSync(path.join(root,'artifacts/commands.jsonl'),'utf8').trim().split('\n').map(JSON.parse):[]};
|
||||
},verify() {return spawnSync(process.execPath,[path.join(root,'scripts/qa/release-gate-result.mjs'),'verify',env.RELEASE_GATE_ARTIFACT_DIR],{cwd:root,env,encoding:'utf8',timeout:30000});}};
|
||||
}
|
||||
function noDeploy(result) {assert.ok(!result.commands.some(c=>c.args.some(arg=>arg.includes('deploy/trialrun/deploy'))));}
|
||||
|
||||
test('真实 shell 复用 static/full-local、回执离线重验、部署命令零调用',context=>{
|
||||
const f=fixture(context),r=f.run();assert.equal(r.status,0,r.stdout+r.stderr);assert.equal(r.record.status,'PASS');assert.equal(r.record.verification.localChecksPassed,true);assert.deepEqual(r.record.phases.stages.map(s=>s.id),['static','full-local']);noDeploy(r);assert.equal(f.verify().status,0);
|
||||
});
|
||||
for(const flag of ['FAIL_STATIC','FAIL_FULL','OMIT_EVIDENCE','BAD_COVERAGE','BAD_CLEANUP','BAD_STAGE'])test(`shell 假工具退出零也不能绕过:${flag}`,context=>{
|
||||
const f=fixture(context,{[flag]:'1'}),r=f.run();assert.notEqual(r.status,0,r.stdout+r.stderr);assert.equal(r.record.deploymentEligible,false);noDeploy(r);
|
||||
if(flag==='FAIL_STATIC'){assert.equal(r.status,23);assert.equal(r.record.phases.stages[0].exitCode,23);assert.equal(r.record.phases.stages[1].status,'NOT_RUN');assert.ok(!r.commands.some(c=>c.name==='bash'));}
|
||||
if(flag==='FAIL_FULL')assert.equal(r.status,17);
|
||||
});
|
||||
test('dirty 本地树执行完整阶段但 PARTIAL/退出2',context=>{
|
||||
const f=fixture(context);writeFileSync(path.join(f.root,'deploy/untracked.sh'),'synthetic');const r=f.run();assert.equal(r.status,2,r.stdout+r.stderr);assert.equal(r.record.verification.localChecksPassed,true);assert.equal(r.record.status,'PARTIAL');assert.equal(f.verify().status,2);noDeploy(r);
|
||||
});
|
||||
test('实际回执拒绝文件摘要篡改、截断、删除和链接',context=>{
|
||||
const f=fixture(context),r=f.run();assert.equal(r.status,0,r.stdout+r.stderr);
|
||||
const file=path.join(f.root,r.record.fullLocalDirectory,'results/playwright-results.json'),raw=readFileSync(file);
|
||||
for(const mutate of [()=>writeFileSync(file,'{'),()=>writeFileSync(file,raw.toString()+' '),()=>rmSync(file),()=>{rmSync(file);symlinkSync('/outside-synthetic',file); }]){
|
||||
writeFileSync(file,raw);mutate();assert.notEqual(f.verify().status,0);rmSync(file,{force:true});
|
||||
}
|
||||
});
|
||||
for(const flags of [{PROJECT_WIDE_SKIP_VISUAL:'1'},{PROJECT_WIDE_SKIP_BUSINESS_FLOW:'1'},{PROJECT_WIDE_SKIP_VISUAL:'1',PROJECT_WIDE_SKIP_BUSINESS_FLOW:'1'}])test(`跳过有批准元数据时只限诊断 PARTIAL/2:${JSON.stringify(flags)}`,context=>{
|
||||
const f=fixture(context,{...flags,PROJECT_WIDE_SKIP_REASON:'synthetic',PROJECT_WIDE_SKIP_OPERATOR:'fixture',PROJECT_WIDE_SKIP_APPROVAL_REF:'fixture-only'});
|
||||
const rejected=f.run();assert.notEqual(rejected.status,0);assert.ok(!rejected.commands.some(c=>c.name==='npm'||c.name==='bash'));noDeploy(rejected);
|
||||
const r=f.run('project-wide');assert.equal(r.status,2,r.stdout+r.stderr);assert.doesNotMatch(r.stdout,/\[PASS\]/);noDeploy(r);
|
||||
const receipt=JSON.parse(readFileSync(path.join(f.env.RELEASE_GATE_ARTIFACT_DIR,'skip-receipt.json')));assert.equal(receipt.status,'PARTIAL');assert.equal(receipt.deploymentEligible,false);
|
||||
});
|
||||
test('删掉真实 skip 前置校验会使反例失败',context=>{
|
||||
const f=fixture(context,{PROJECT_WIDE_SKIP_VISUAL:'yes'});
|
||||
const blocked=r=>{assert.notEqual(r.status,0);assert.ok(!r.commands.some(c=>c.name==='npm'));};blocked(f.run('project-wide'));
|
||||
const file=path.join(f.root,'scripts/qa/run-release-gate.sh'),original=readFileSync(file,'utf8'),mutated=original.replace(' node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" skip "$ARTIFACT_ROOT" "$MODE"',' : # removed skip validation');assert.notEqual(original,mutated);writeFileSync(file,mutated);
|
||||
assert.throws(()=>blocked(f.run('project-wide')),assert.AssertionError);
|
||||
});
|
||||
test('删除最终回执消费会使缺证据反例失败',context=>{
|
||||
const f=fixture(context,{OMIT_EVIDENCE:'1'});const blocked=r=>assert.notEqual(r.status,0);blocked(f.run());
|
||||
const file=path.join(f.root,'scripts/qa/run-release-gate.sh'),original=readFileSync(file,'utf8'),mutated=original.replace(' node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" finish "$ARTIFACT_ROOT" "$original_status" || receipt_status=$?',' receipt_status=0 # removed verification');assert.notEqual(original,mutated);writeFileSync(file,mutated);
|
||||
assert.throws(()=>blocked(f.run()),assert.AssertionError);
|
||||
});
|
||||
test('放宽真实阶段验证使缺阶段反例失败',context=>{
|
||||
const f=fixture(context,{BAD_STAGE:'1'});const blocked=r=>assert.notEqual(r.status,0);blocked(f.run());
|
||||
const file=path.join(f.root,'scripts/qa/qa-stage-record.mjs'),original=readFileSync(file,'utf8'),mutated=original.replace('export function validateStages(record, expected, profile) {','export function validateStages(record, expected, profile) { return [];');assert.notEqual(original,mutated);writeFileSync(file,mutated);
|
||||
const r=f.run();assert.equal(r.status,2,r.stdout+r.stderr); // mutation makes source dirty, still no release eligibility
|
||||
assert.equal(r.record.verification.localChecksPassed,true);
|
||||
});
|
||||
|
||||
for(const [flag,from,to] of [
|
||||
['BAD_PROFILE', "env?.mode!=='full-local' || ", ''],
|
||||
['BAD_SOURCE', "issues.push('managed_source_mismatch');", "; // removed source validation"],
|
||||
['BAD_COVERAGE', 'issues.push(...coverage.issues);', '; // removed coverage validation'],
|
||||
])test(`移除实际 profile/source/coverage 保护使反例失效:${flag}`,context=>{
|
||||
const f=fixture(context,{[flag]:'1'}),first=f.run();
|
||||
assert.notEqual(first.status,0);assert.equal(first.record.verification.localChecksPassed,false);noDeploy(first);
|
||||
const file=path.join(f.root,'scripts/qa/release-gate-result.mjs'),original=readFileSync(file,'utf8'),mutated=original.replace(from,to);
|
||||
assert.notEqual(original,mutated);writeFileSync(file,mutated);
|
||||
const result=f.run();assert.equal(result.status,2,result.stdout+result.stderr);
|
||||
assert.equal(result.record.verification.localChecksPassed,true);noDeploy(result);
|
||||
});
|
||||
|
||||
test('候选路径越界在建目录和静态执行前拒绝',context=>{
|
||||
const f=fixture(context);f.env.RELEASE_GATE_ARTIFACT_DIR=path.join(f.root,'unapproved-directory');
|
||||
const r=f.run();assert.notEqual(r.status,0);assert.equal(existsSync(f.env.RELEASE_GATE_ARTIFACT_DIR),false);assert.ok(!r.commands.some(c=>c.name==='npm'));noDeploy(r);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, 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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const source = readFileSync(path.join(root, 'server/scripts/regression-workflow-actions.sh'), 'utf8');
|
||||
function checkResponse(context, code, details, candidate = source) {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), 'hyb-payment-profile-'));
|
||||
context.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
writeFileSync(path.join(directory, 'pay-ok-pay.json'), JSON.stringify({ error: { code: 'VALID_001', details } }));
|
||||
// Execute the candidate's real shell assertions. No app, database, or HTTP tools are started.
|
||||
const helpers = ['assert_status', 'assert_error_code', 'assert_error_detail'].map(name => {
|
||||
const start = candidate.indexOf(name + '() {');
|
||||
assert.ok(start >= 0);
|
||||
const end = candidate.indexOf('\n}\n', start);
|
||||
assert.ok(end > start);
|
||||
return candidate.slice(start, end + 3);
|
||||
}).join('\n');
|
||||
const start = candidate.indexOf('if [[', candidate.indexOf('\npay_ok_status='));
|
||||
const end = candidate.indexOf(' echo "[19/23] batch-required profile:', start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const script = 'set -euo pipefail\n' + helpers + '\n' + candidate.slice(start, end) + '\nfi\n';
|
||||
return spawnSync('bash', ['-c', script], {
|
||||
env: { PATH: process.env.PATH, TMP_DIR: directory, PAYMENT_BATCH_ENABLED: 'true', pay_ok_status: String(code) },
|
||||
encoding: 'utf8', timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
test('configured batch mode accepts only the exact batch-required refusal', context => {
|
||||
const valid = { payment: 'payment_batch_v1_required', reasonCode: 'reimbursement.payment_payment_batch_v1_required' };
|
||||
assert.equal(checkResponse(context, 409, valid).status, 0);
|
||||
for (const code of [200, 400, 403, 500]) assert.notEqual(checkResponse(context, code, valid).status, 0);
|
||||
assert.notEqual(checkResponse(context, 409, { payment: 'some_other_reason' }).status, 0);
|
||||
});
|
||||
|
||||
test('choosing the branch from the observed response is detected as a false-green mutation', context => {
|
||||
const before = 'if [[ "${PAYMENT_BATCH_ENABLED:-false}" == "true" ]]; then';
|
||||
assert.ok(source.includes(before));
|
||||
const mutant = source.replace(before, 'if [[ "$pay_ok_status" == "409" ]]; then');
|
||||
const verify = candidate => assert.notEqual(checkResponse(context, 200, {}, candidate).status, 0);
|
||||
verify(source);
|
||||
assert.throws(() => verify(mutant), assert.AssertionError);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户