feat(s5): add final verification cli
This commit is contained in:
parent
5fd4f66217
commit
7874ca4122
48
docs/memorys/2026-06-05-S5Task6最终验证.md
Normal file
48
docs/memorys/2026-06-05-S5Task6最终验证.md
Normal file
@ -0,0 +1,48 @@
|
||||
# S5 Task6 最终验证
|
||||
|
||||
## 结论
|
||||
|
||||
S5 Task6 已补齐可执行 final verification 入口:`pnpm run build:minigame` 和 `pnpm run smoke:devtool-import`。三端 simulation fixture 可以构建并通过 static gate;DevTool import smoke 默认 fail-closed 写 blocker,不会伪造 `DevToolImportEvidence.result="passed"`。
|
||||
|
||||
当前 S5 final acceptance 仍是 No-Go:本地没有真实开发者工具导入材料,因此不存在可接受的 passed evidence 文件。只有提供真实 devtool 导入 log、tool name/version 和可选 screenshot 后,使用 `--allow-manual-passed-evidence` 才能生成 P0 可接受证据。
|
||||
|
||||
## 关键实现
|
||||
|
||||
- `package.json` 新增:
|
||||
- `build:minigame`
|
||||
- `smoke:devtool-import`
|
||||
- `scripts/build-minigame.mjs` 复用 `MiniGameProjectBuilderService` 和 `StaticGateService`,生成 `simulation-fixture-v1` 三端项目并执行 static gate。
|
||||
- `scripts/smoke-devtool-import.mjs` 默认写 `DevToolImportBlocker` 并 exit 1;manual passed mode 必须显式传入 audited inputs,并通过 `DevToolImportEvidenceRecorder` checksum/path 校验。
|
||||
- `scripts/check-scope.mjs` 开始扫描 `scripts/`,只精确放行 Task6 四个 CLI/spec 文件,仍拒绝 generic scripts 与 S6-S8 泄漏。
|
||||
- CLI tests 覆盖三端 build/static gate、默认 No-Go、manual passed probe、evidence root symlink 和 parent-chain symlink fail-closed。
|
||||
|
||||
## Controller 验证
|
||||
|
||||
- `node --check scripts/build-minigame.mjs` PASS。
|
||||
- `node --check scripts/smoke-devtool-import.mjs` PASS。
|
||||
- `node --check scripts/build-minigame.spec.mjs` PASS。
|
||||
- `node --check scripts/smoke-devtool-import.spec.mjs` PASS。
|
||||
- `node --test --test-concurrency=1 scripts/build-minigame.spec.mjs scripts/smoke-devtool-import.spec.mjs` PASS,5 tests。
|
||||
- `pnpm run build:minigame -- --target all --fixture simulation` PASS,wechat/douyin/kuaishou static gate 均 `static_validated`,kuaishou 保留 `KUAISHOU_GLOBAL_AND_CONFIG_SHAPE_UNKNOWN` warning。
|
||||
- 默认 `pnpm run smoke:devtool-import -- --target wechat_minigame --game-version-id simulation-fixture-v1 --project dist/packages/simulation-fixture-v1/wechat_minigame --out docs/evidence/devtool-import/wechat_minigame-simulation-fixture-v1.json` EXPECTED No-Go PASS:exit 1,写 blocker,未写 passed evidence。
|
||||
- manual passed mode probe PASS;probe evidence/log/blocker 已清理,避免误当真实 DevTools 材料。
|
||||
- `pnpm --filter @huijing/api test -- minigame-conversion static-gate devtool-import-evidence` PASS,26 files / 291 tests。存在既有 `pg@9` deprecation warning。
|
||||
- `pnpm --filter @huijing/api typecheck` PASS。
|
||||
- `pnpm --filter @huijing/api lint` PASS。
|
||||
- `pnpm check:s5-scope` PASS。
|
||||
- `pnpm check:workspace-scripts` PASS。
|
||||
- `pnpm lint` PASS。
|
||||
- `pnpm typecheck` PASS。
|
||||
- `pnpm test` PASS。
|
||||
- `git diff --check` PASS;未跟踪文件额外 whitespace probe PASS。
|
||||
- 原始 no-overclaim grep 命中 shared-contracts 负向测试 fixture 中的 `device_smoke_verified`;排除测试文件后 PASS。
|
||||
- Task6 evidence gate EXPECTED_NO_GO PASS:`docs/evidence/devtool-import/wechat_minigame-simulation-fixture-v1.json` 不存在。
|
||||
|
||||
## Fresh review
|
||||
|
||||
- Spec review `019e9715-4004-78c1-a101-0441e5bd5315`: PASS。
|
||||
- Quality review `019e9717-1c47-7fa3-81c0-54028d848552`: PASS。
|
||||
|
||||
## 下一步
|
||||
|
||||
S5 Task6 入口可提交,但 S5 阶段不能标记 final accepted,直到真实开发者工具导入 smoke 产出 `DevToolImportEvidence.result="passed"`。如果没有真实材料,应保留 blocker/No-Go 并阻断 S6/S8。
|
||||
@ -6,8 +6,10 @@
|
||||
"typecheck": "pnpm check:workspace-scripts && pnpm -r typecheck",
|
||||
"test": "pnpm check:workspace-scripts && pnpm -r test",
|
||||
"build": "pnpm check:workspace-scripts && pnpm -r build",
|
||||
"build:minigame": "node scripts/build-minigame.mjs",
|
||||
"dev": "pnpm -r --parallel --if-present dev",
|
||||
"dev:smoke": "pnpm check:workspace-scripts && pnpm -r dev:smoke",
|
||||
"smoke:devtool-import": "node scripts/smoke-devtool-import.mjs",
|
||||
"check:workspace-scripts": "node scripts/check-workspace-scripts.mjs",
|
||||
"check:s1-scope": "node scripts/check-scope.mjs --phase S1",
|
||||
"check:s2-scope": "node scripts/check-scope.mjs --phase S2",
|
||||
|
||||
216
scripts/build-minigame.mjs
Normal file
216
scripts/build-minigame.mjs
Normal file
@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const workspaceRoot = process.cwd();
|
||||
const validTargets = ["wechat_minigame", "douyin_minigame", "kuaishou_minigame"];
|
||||
const simulationGameVersionId = "simulation-fixture-v1";
|
||||
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await ensureApiBuild();
|
||||
const { FileSystemMiniGameProjectRepository, MiniGameProjectBuilderService } = await importApiModule("minigame-conversion/index.js");
|
||||
const { StaticGateService } = await importApiModule("static-gate/index.js");
|
||||
|
||||
const repository = new FileSystemMiniGameProjectRepository({ root: workspaceRoot });
|
||||
const fixture = await createSimulationFixture(repository);
|
||||
const targets = options.target === "all" ? validTargets : [options.target];
|
||||
const builder = new MiniGameProjectBuilderService(repository);
|
||||
const conversionResult = await builder.convertGamePackage({
|
||||
gamePackage: fixture.gamePackage,
|
||||
assetManifest: fixture.assetManifest,
|
||||
targets
|
||||
});
|
||||
const staticGate = new StaticGateService({ root: workspaceRoot });
|
||||
const targetSummaries = [];
|
||||
|
||||
for (const targetResult of conversionResult.targets) {
|
||||
const gateResult = await staticGate.validateProject({
|
||||
project: targetResult.project,
|
||||
sourceLogic: {
|
||||
path: fixture.gamePackage.profiles.web.manifest.logicArtifactPath,
|
||||
content: fixture.logicSource
|
||||
}
|
||||
});
|
||||
targetSummaries.push({
|
||||
target: targetResult.project.target,
|
||||
projectRoot: targetResult.project.rootPath,
|
||||
conversionReportPath: targetResult.project.conversionReportPath,
|
||||
assetManifestPath: targetResult.project.assetManifestPath,
|
||||
projectChecksum: targetResult.project.checksum,
|
||||
staticGate: gateResult
|
||||
});
|
||||
}
|
||||
|
||||
const failedTargets = targetSummaries.filter((summary) => summary.staticGate.status !== "static_validated");
|
||||
if (failedTargets.length > 0) {
|
||||
printJson({
|
||||
status: "FAIL",
|
||||
fixture: options.fixture,
|
||||
gameVersionId: fixture.gamePackage.gameVersionId,
|
||||
failedTargets
|
||||
});
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
printJson({
|
||||
status: "PASS",
|
||||
fixture: options.fixture,
|
||||
gameVersionId: fixture.gamePackage.gameVersionId,
|
||||
targets: targetSummaries
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
printJson(errorPayload("FAIL", error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function parseArgs(args) {
|
||||
const values = new Map();
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--") continue;
|
||||
if (!arg.startsWith("--")) throw new Error(`Unsupported positional argument: ${arg}`);
|
||||
if (arg === "--target" || arg === "--fixture") {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${arg}`);
|
||||
values.set(arg.slice(2), value);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
|
||||
const target = values.get("target") ?? "all";
|
||||
const fixture = values.get("fixture") ?? "simulation";
|
||||
if (target !== "all" && !validTargets.includes(target)) {
|
||||
throw new Error(`Unsupported --target: ${target}`);
|
||||
}
|
||||
if (fixture !== "simulation") {
|
||||
throw new Error(`Unsupported --fixture: ${fixture}`);
|
||||
}
|
||||
return { target, fixture };
|
||||
}
|
||||
|
||||
async function ensureApiBuild() {
|
||||
// Node 当前只支持 strip-only TypeScript,仓库模块里有参数属性等不可擦除语法;先复用现有 tsc 输出再加载业务模块。
|
||||
try {
|
||||
await execFileAsync("pnpm", ["--filter", "@huijing/api", "exec", "tsc", "-p", "tsconfig.json"], {
|
||||
cwd: workspaceRoot,
|
||||
maxBuffer: 1024 * 1024 * 16
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = [error.stdout, error.stderr].filter(Boolean).join("\n");
|
||||
throw new Error(`@huijing/api TypeScript build failed${detail ? `\n${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function importApiModule(modulePath) {
|
||||
return import(pathToFileURL(`${workspaceRoot}/apps/api/dist/modules/${modulePath}`).href);
|
||||
}
|
||||
|
||||
async function createSimulationFixture(repository) {
|
||||
const gameVersionId = simulationGameVersionId;
|
||||
const packagePath = `dist/packages/${gameVersionId}/web`;
|
||||
const logicSource = [
|
||||
'import * as RuntimeSdk from "@huijing/runtime-sdk";',
|
||||
"",
|
||||
"export function init(sdk, config) {",
|
||||
" void RuntimeSdk;",
|
||||
" sdk.telemetry?.emit?.({ name: \"load_success\", gameVersionId: config.gameVersionId });",
|
||||
" return { status: \"running\", gameVersionId: config.gameVersionId, tick: 0 };",
|
||||
"}",
|
||||
"export function update(state) { return { ...state, tick: state.tick + 1 }; }",
|
||||
"export function render() { return [{ type: \"text\", value: \"simulation\" }]; }",
|
||||
"export function handleInput(state) { return state; }",
|
||||
"export function onPause() {}",
|
||||
"export function onResume() {}",
|
||||
""
|
||||
].join("\n");
|
||||
const assets = {
|
||||
"assets/facility.png": "fake-png-bytes",
|
||||
"assets/balance.json": "{\"score\":12}\n"
|
||||
};
|
||||
const assetManifest = {
|
||||
id: `asset-manifest-${gameVersionId}`,
|
||||
entries: [
|
||||
{
|
||||
path: "assets/facility.png",
|
||||
hash: checksumText(assets["assets/facility.png"]),
|
||||
sizeBytes: Buffer.byteLength(assets["assets/facility.png"]),
|
||||
type: "image"
|
||||
},
|
||||
{
|
||||
path: "assets/balance.json",
|
||||
hash: checksumText(assets["assets/balance.json"]),
|
||||
sizeBytes: Buffer.byteLength(assets["assets/balance.json"]),
|
||||
type: "json"
|
||||
}
|
||||
]
|
||||
};
|
||||
const assetManifestText = stableJsonText(assetManifest);
|
||||
const manifestPayload = {
|
||||
entry: "runtime/index.js",
|
||||
configPath: "config.json",
|
||||
logicArtifactPath: "logic/logic.mjs",
|
||||
assetManifestPath: "asset-manifest.json",
|
||||
runtimeVersion: "web-runtime-mvp-v1"
|
||||
};
|
||||
const webManifest = {
|
||||
...manifestPayload,
|
||||
checksum: checksumText(JSON.stringify(manifestPayload))
|
||||
};
|
||||
const gamePackage = {
|
||||
id: `game-package-${gameVersionId}`,
|
||||
gameVersionId,
|
||||
profiles: {
|
||||
web: {
|
||||
status: "built",
|
||||
artifactPath: packagePath,
|
||||
manifest: webManifest
|
||||
},
|
||||
wechat_minigame: { status: "pending_conversion" },
|
||||
douyin_minigame: { status: "pending_conversion" },
|
||||
kuaishou_minigame: { status: "pending_conversion" }
|
||||
},
|
||||
assetManifestId: assetManifest.id,
|
||||
artifactChecksums: {
|
||||
[assetManifest.id]: checksumText(assetManifestText),
|
||||
[webManifest.logicArtifactPath]: checksumText(logicSource),
|
||||
[webManifest.assetManifestPath]: checksumText(assetManifestText),
|
||||
"manifest.json": webManifest.checksum
|
||||
}
|
||||
};
|
||||
|
||||
// 这里仅创建 S5 final gate 使用的 simulation web 包输入;真实转换和 static gate 仍由现有 API 模块完成。
|
||||
await repository.replaceDirectory(packagePath);
|
||||
await repository.writeTextFile(`${packagePath}/logic/logic.mjs`, logicSource);
|
||||
await repository.writeTextFile(`${packagePath}/asset-manifest.json`, assetManifestText);
|
||||
await repository.writeTextFile(`${packagePath}/manifest.json`, stableJsonText(webManifest));
|
||||
await repository.writeTextFile(`${packagePath}/assets/facility.png`, assets["assets/facility.png"]);
|
||||
await repository.writeTextFile(`${packagePath}/assets/balance.json`, assets["assets/balance.json"]);
|
||||
|
||||
return { gamePackage, assetManifest, logicSource };
|
||||
}
|
||||
|
||||
function stableJsonText(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function checksumText(value) {
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
|
||||
function printJson(value) {
|
||||
process.stdout.write(stableJsonText(value));
|
||||
}
|
||||
|
||||
function errorPayload(status, error) {
|
||||
return {
|
||||
status,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
...(error && typeof error === "object" && "reasonCode" in error ? { reasonCode: error.reasonCode, path: error.path } : {})
|
||||
};
|
||||
}
|
||||
47
scripts/build-minigame.spec.mjs
Normal file
47
scripts/build-minigame.spec.mjs
Normal file
@ -0,0 +1,47 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const workspaceRoot = process.cwd();
|
||||
const packageRoot = path.join(workspaceRoot, "dist/packages/simulation-fixture-v1");
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(path.join(workspaceRoot, "dist/packages/simulation-fixture-v1"), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("build-minigame CLI", () => {
|
||||
it("builds all simulation fixture mini-game targets and runs static gate", async () => {
|
||||
const { stdout } = await execFileAsync("node", ["scripts/build-minigame.mjs", "--target", "all", "--fixture", "simulation"], {
|
||||
cwd: workspaceRoot,
|
||||
maxBuffer: 1024 * 1024 * 8
|
||||
});
|
||||
const output = JSON.parse(stdout);
|
||||
|
||||
assert.equal(output.status, "PASS");
|
||||
assert.equal(output.fixture, "simulation");
|
||||
assert.equal(output.gameVersionId, "simulation-fixture-v1");
|
||||
assert.deepEqual(
|
||||
output.targets.map((target) => target.target),
|
||||
["wechat_minigame", "douyin_minigame", "kuaishou_minigame"]
|
||||
);
|
||||
assert.ok(output.targets.every((target) => target.staticGate.status === "static_validated"));
|
||||
|
||||
for (const target of ["wechat_minigame", "douyin_minigame", "kuaishou_minigame"]) {
|
||||
const targetRoot = path.join(packageRoot, target);
|
||||
assert.equal(JSON.parse(await readFile(path.join(targetRoot, "conversion-report.json"), "utf8")).target, target);
|
||||
assert.equal(JSON.parse(await readFile(path.join(targetRoot, "asset-manifest.json"), "utf8")).id, "asset-manifest-simulation-fixture-v1");
|
||||
await readFile(path.join(targetRoot, "game.js"), "utf8");
|
||||
await readFile(path.join(targetRoot, "game.json"), "utf8");
|
||||
await readFile(path.join(targetRoot, "project.config.json"), "utf8");
|
||||
await readFile(path.join(targetRoot, "js/platform-adapter.js"), "utf8");
|
||||
await readFile(path.join(targetRoot, "js/runtime-sdk.js"), "utf8");
|
||||
await readFile(path.join(targetRoot, "js/game-logic-module.js"), "utf8");
|
||||
await readFile(path.join(targetRoot, "assets/facility.png"), "utf8");
|
||||
await readFile(path.join(targetRoot, "assets/balance.json"), "utf8");
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -2,8 +2,12 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promis
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const scanRoots = ["apps", "packages"];
|
||||
const scanRoots = ["apps", "packages", "scripts"];
|
||||
const ignoredScanSegments = new Set(["node_modules", "dist", ".vite", "coverage", ".next", "generated"]);
|
||||
const ignoredScanExactPaths = new Set([
|
||||
// scope checker 内部必须包含全量 denylist 和自测 fixture;真实阶段扫描只跳过 checker 自身,不跳过普通 scripts。
|
||||
"scripts/check-scope.mjs"
|
||||
]);
|
||||
const sourceLikeFilePattern = /\.(?:ts|tsx|js|jsx|mjs|cjs|json|sql|prisma)$/;
|
||||
const prismaMigrationPrefix = "apps/api/prisma/migrations/";
|
||||
const generatedPrismaPrefix = "apps/api/src/generated/prisma/";
|
||||
@ -291,6 +295,12 @@ const s5Task5ApprovedTermAllowExactPaths = new Set([
|
||||
"apps/api/src/modules/minigame-conversion/devtool-import-evidence.ts",
|
||||
"apps/api/src/modules/minigame-conversion/devtool-import-evidence.spec.ts"
|
||||
]);
|
||||
const s5Task6CliAllowExactPaths = new Set([
|
||||
"scripts/build-minigame.mjs",
|
||||
"scripts/build-minigame.spec.mjs",
|
||||
"scripts/smoke-devtool-import.mjs",
|
||||
"scripts/smoke-devtool-import.spec.mjs"
|
||||
]);
|
||||
const s5ApprovedTermReferenceAllowExactPaths = new Set([
|
||||
...s5ApprovedTermAllowExactPaths,
|
||||
// 共享合同根出口可以暴露 S5 Task1 合同,但不能因此把 index.ts 整体变成 S5-owned path。
|
||||
@ -338,7 +348,8 @@ function normalizePath(filePath) {
|
||||
}
|
||||
|
||||
function isIgnoredScanPath(filePath) {
|
||||
return filePath.split("/").some((segment) => ignoredScanSegments.has(segment));
|
||||
const normalizedFilePath = normalizePath(filePath);
|
||||
return ignoredScanExactPaths.has(normalizedFilePath) || normalizedFilePath.split("/").some((segment) => ignoredScanSegments.has(segment));
|
||||
}
|
||||
|
||||
function isHarnessClientTestPath(filePath) {
|
||||
@ -6263,6 +6274,10 @@ function hasS5Task5DevtoolEvidenceTermAllowPath(filePath) {
|
||||
return hasS5ContractTermAllowPath(filePath) || s5Task5ApprovedTermAllowExactPaths.has(filePath);
|
||||
}
|
||||
|
||||
function hasS5Task6CliTermAllowPath(filePath) {
|
||||
return s5Task6CliAllowExactPaths.has(filePath);
|
||||
}
|
||||
|
||||
function isAllowedS5ApprovedTermReference(filePath, term) {
|
||||
if (isAllowedS2ApprovedTermReference(filePath, term)) return true;
|
||||
if (isAllowedS3ApprovedTermReference(filePath, term)) return true;
|
||||
@ -6273,7 +6288,8 @@ function isAllowedS5ApprovedTermReference(filePath, term) {
|
||||
hasS4ApprovedTermAllowPath(filePath) ||
|
||||
hasS5RuntimeAdapterTermAllowPath(filePath) ||
|
||||
hasS5Task4StaticGateTermAllowPath(filePath) ||
|
||||
hasS5Task5DevtoolEvidenceTermAllowPath(filePath)
|
||||
hasS5Task5DevtoolEvidenceTermAllowPath(filePath) ||
|
||||
hasS5Task6CliTermAllowPath(filePath)
|
||||
);
|
||||
}
|
||||
|
||||
@ -6284,7 +6300,8 @@ function isAllowedS5ApprovedTermReference(filePath, term) {
|
||||
// Task5 只允许专用 devtool evidence recorder 消费导入证据术语,避免 converter/static gate 冒充导入证据。
|
||||
return (
|
||||
(s5Task4ApprovedTerms.has(term) && hasS5Task4StaticGateTermAllowPath(filePath)) ||
|
||||
(s5Task5ApprovedTerms.has(term) && hasS5Task5DevtoolEvidenceTermAllowPath(filePath))
|
||||
(s5Task5ApprovedTerms.has(term) && hasS5Task5DevtoolEvidenceTermAllowPath(filePath)) ||
|
||||
(s5Task5ApprovedTerms.has(term) && hasS5Task6CliTermAllowPath(filePath))
|
||||
);
|
||||
}
|
||||
|
||||
@ -11540,6 +11557,46 @@ export type DevToolImportBlocker = { result: "blocked"; };
|
||||
}
|
||||
);
|
||||
|
||||
await withFixture(
|
||||
{
|
||||
"scripts/build-minigame.mjs": `
|
||||
const modulePath = "minigame-conversion/index.js";
|
||||
const runtimePackage = "runtime-sdk";
|
||||
const webRuntimeVersion = "web-runtime-mvp-v1";
|
||||
const gamePackageId = "game-package-simulation-fixture-v1";
|
||||
export const summary = { modulePath, runtimePackage, webRuntimeVersion, gamePackageId };
|
||||
`,
|
||||
"scripts/smoke-devtool-import.mjs": `
|
||||
const modulePath = "minigame-conversion/devtool-import-evidence.js";
|
||||
export class DevToolImportEvidenceRecorder {}
|
||||
export const result = { modulePath, status: "DevToolImportBlocker" };
|
||||
`,
|
||||
"scripts/build-minigame.spec.mjs": `
|
||||
export const runtimePath = "js/runtime-sdk.js";
|
||||
`,
|
||||
"scripts/smoke-devtool-import.spec.mjs": `
|
||||
export const passedShape = "DevToolImportEvidence";
|
||||
`
|
||||
},
|
||||
async (root) => {
|
||||
const violations = await findScopeViolations(root, "S5");
|
||||
assertNoViolations(violations, "S5 Task6 exact CLI scripts can consume S5 verification terms");
|
||||
}
|
||||
);
|
||||
|
||||
await withFixture(
|
||||
{
|
||||
"scripts/generic-minigame-helper.mjs": `
|
||||
export class DevToolImportEvidenceRecorder {}
|
||||
export const modulePath = "minigame-conversion/devtool-import-evidence.js";
|
||||
`
|
||||
},
|
||||
async (root) => {
|
||||
const violations = await findScopeViolations(root, "S5");
|
||||
assertHasReason(violations, "LATER_PHASE_SCOPE_LEAK", "S5 still rejects Task6 terms from generic scripts");
|
||||
}
|
||||
);
|
||||
|
||||
await withFixture(
|
||||
{
|
||||
"packages/shared-contracts/src/minigame-conversion.ts": `
|
||||
|
||||
297
scripts/smoke-devtool-import.mjs
Normal file
297
scripts/smoke-devtool-import.mjs
Normal file
@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFile } from "node:child_process";
|
||||
import { lstat, mkdir, realpath, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const workspaceRoot = process.cwd();
|
||||
const validTargets = ["wechat_minigame", "douyin_minigame", "kuaishou_minigame"];
|
||||
const defaultEvidenceRoot = "docs/evidence/devtool-import";
|
||||
const pathSymlinkUnsupportedReason = "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED";
|
||||
const pathRealpathEscapeReason = "DEVTOOL_IMPORT_PATH_REALPATH_ESCAPE";
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
printJson(errorPayload("FAIL", error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
validateOutPath(options);
|
||||
await ensureApiBuild();
|
||||
const { DevToolImportEvidenceRecorder, selectReferencePlatform } = await importApiModule("minigame-conversion/devtool-import-evidence.js");
|
||||
const recorder = new DevToolImportEvidenceRecorder({ workspaceRoot, evidenceRoot: defaultEvidenceRoot });
|
||||
const selection =
|
||||
options.target === "wechat_minigame" ? selectReferencePlatform({}) : selectReferencePlatform({ businessPrimaryTarget: options.target });
|
||||
|
||||
if (!options.allowManualPassedEvidence) {
|
||||
const blockerLogPath = await writeDefaultBlockerLog(options);
|
||||
const blocker = await recorder.recordBlocker({
|
||||
target: options.target,
|
||||
gameVersionId: options.gameVersionId,
|
||||
result: "blocked",
|
||||
reasonCode: "DEVTOOL_UNAVAILABLE",
|
||||
logPath: blockerLogPath,
|
||||
nextAction: "Run the target developer tool import manually, keep the real import log, then rerun with --allow-manual-passed-evidence, --tool-name, --tool-version and --log.",
|
||||
createdAt: isoTimestamp()
|
||||
});
|
||||
const blockerPath = `${defaultEvidenceRoot}/${options.target}-${options.gameVersionId}.blocker.json`;
|
||||
printJson({
|
||||
status: "NO_GO",
|
||||
target: blocker.target,
|
||||
gameVersionId: blocker.gameVersionId,
|
||||
result: blocker.result,
|
||||
reasonCode: blocker.reasonCode,
|
||||
blockerPath,
|
||||
nextAction: blocker.nextAction
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.toolName || !options.toolVersion || !options.log) {
|
||||
throw new Error("--allow-manual-passed-evidence requires --tool-name, --tool-version and --log");
|
||||
}
|
||||
|
||||
const projectChecksum = await recorder.checksumProjectDirectory(options.project);
|
||||
const evidence = await recorder.recordPassedEvidence({
|
||||
target: options.target,
|
||||
gameVersionId: options.gameVersionId,
|
||||
referencePlatformReason: selection.referencePlatformReason,
|
||||
toolName: options.toolName,
|
||||
toolVersion: options.toolVersion,
|
||||
importedProjectPath: options.project,
|
||||
projectChecksum,
|
||||
logPath: options.log,
|
||||
...(options.screenshot === undefined ? {} : { screenshotPath: options.screenshot }),
|
||||
createdAt: isoTimestamp()
|
||||
});
|
||||
const defaultPath = `${defaultEvidenceRoot}/${options.target}-${options.gameVersionId}.json`;
|
||||
if (options.out !== defaultPath) {
|
||||
await moveDefaultEvidenceToOut(defaultPath, options.out);
|
||||
}
|
||||
|
||||
printJson({
|
||||
status: "PASS",
|
||||
target: evidence.target,
|
||||
gameVersionId: evidence.gameVersionId,
|
||||
result: evidence.result,
|
||||
evidencePath: options.out,
|
||||
projectChecksum: evidence.projectChecksum
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(args) {
|
||||
const options = {};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--") continue;
|
||||
if (arg === "--allow-manual-passed-evidence") {
|
||||
options.allowManualPassedEvidence = true;
|
||||
continue;
|
||||
}
|
||||
const valueArgs = ["--target", "--game-version-id", "--project", "--out", "--tool-name", "--tool-version", "--log", "--screenshot"];
|
||||
if (valueArgs.includes(arg)) {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${arg}`);
|
||||
options[toCamelCase(arg.slice(2))] = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
|
||||
const required = ["target", "gameVersionId", "project", "out"];
|
||||
for (const field of required) {
|
||||
if (!options[field]) throw new Error(`Missing required --${toKebabCase(field)}`);
|
||||
}
|
||||
if (!validTargets.includes(options.target)) throw new Error(`Unsupported --target: ${options.target}`);
|
||||
assertSafeFileSegment(options.gameVersionId, "game-version-id");
|
||||
for (const field of ["project", "out", "log", "screenshot"]) {
|
||||
if (options[field] !== undefined) assertControlledRelativePath(options[field], field);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function validateOutPath(options) {
|
||||
const expectedName = `${options.target}-${options.gameVersionId}.json`;
|
||||
const expectedPath = `${defaultEvidenceRoot}/${expectedName}`;
|
||||
if (options.out !== expectedPath) {
|
||||
// 当前 recorder 只按 target/gameVersionId 写默认命名;CLI 精确约束 out,避免证据输出路径被参数扩展。
|
||||
throw new Error(`--out must be ${expectedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureApiBuild() {
|
||||
try {
|
||||
await execFileAsync("pnpm", ["--filter", "@huijing/api", "exec", "tsc", "-p", "tsconfig.json"], {
|
||||
cwd: workspaceRoot,
|
||||
maxBuffer: 1024 * 1024 * 16
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = [error.stdout, error.stderr].filter(Boolean).join("\n");
|
||||
throw new Error(`@huijing/api TypeScript build failed${detail ? `\n${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function importApiModule(modulePath) {
|
||||
return import(pathToFileURL(`${workspaceRoot}/apps/api/dist/modules/${modulePath}`).href);
|
||||
}
|
||||
|
||||
async function writeDefaultBlockerLog(options) {
|
||||
const logPath = `${defaultEvidenceRoot}/${options.target}-${options.gameVersionId}.blocked.log`;
|
||||
const absoluteEvidenceRoot = await prepareSafeWorkspaceOutputRoot(defaultEvidenceRoot, "evidenceRoot");
|
||||
const absoluteLogPath = path.resolve(workspaceRoot, logPath);
|
||||
if (!isInsideOrSame(absoluteEvidenceRoot, absoluteLogPath)) throw new Error("blocker log path escaped evidence root");
|
||||
// 默认路径只记录 No-Go 原因,不记录 passed 结果;开发者工具真实导入材料必须由显式 manual passed mode 提供。
|
||||
await writeFile(
|
||||
absoluteLogPath,
|
||||
[
|
||||
"S5 No-Go: developer tool import was not executed by this CLI.",
|
||||
`target=${options.target}`,
|
||||
`gameVersionId=${options.gameVersionId}`,
|
||||
`project=${options.project}`,
|
||||
"nextAction=rerun with audited manual import log and --allow-manual-passed-evidence",
|
||||
""
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
return logPath;
|
||||
}
|
||||
|
||||
async function prepareSafeWorkspaceOutputRoot(repositoryPath, fieldPath) {
|
||||
const resolvedRoot = resolveWorkspacePath(repositoryPath, "DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", fieldPath);
|
||||
// 创建输出目录前后都检查已存在父链;否则 mkdir/writeFile 会跟随 workspace 内 symlink 写到工作区外。
|
||||
await assertSafeExistingPathSegments(resolvedRoot, repositoryPath, fieldPath);
|
||||
await mkdir(resolvedRoot, { recursive: true });
|
||||
await assertSafeExistingPathSegments(resolvedRoot, repositoryPath, fieldPath);
|
||||
|
||||
const stats = await lstat(resolvedRoot);
|
||||
if (!stats.isDirectory()) {
|
||||
throw pathBoundaryError("DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", "Devtool import evidence root is not a directory", fieldPath, {
|
||||
repositoryPath
|
||||
});
|
||||
}
|
||||
return resolvedRoot;
|
||||
}
|
||||
|
||||
async function assertSafeExistingPathSegments(resolvedPath, repositoryPath, fieldPath) {
|
||||
const workspaceRootReal = await realpath(workspaceRoot);
|
||||
const relativePath = path.relative(workspaceRoot, resolvedPath);
|
||||
let currentPath = workspaceRoot;
|
||||
|
||||
for (const segment of relativePath.split(path.sep)) {
|
||||
if (segment.length === 0) continue;
|
||||
currentPath = path.join(currentPath, segment);
|
||||
let stats;
|
||||
try {
|
||||
stats = await lstat(currentPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (stats.isSymbolicLink()) {
|
||||
throw pathBoundaryError(pathSymlinkUnsupportedReason, "Devtool import evidence paths do not support symlinks", fieldPath, {
|
||||
repositoryPath
|
||||
});
|
||||
}
|
||||
|
||||
const currentReal = await realpath(currentPath);
|
||||
if (!isInsideOrSame(workspaceRootReal, currentReal)) {
|
||||
throw pathBoundaryError(pathRealpathEscapeReason, "Devtool import evidence real path escaped workspace root", fieldPath, {
|
||||
repositoryPath,
|
||||
realPath: currentReal
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkspacePath(repositoryPath, reasonCode, fieldPath) {
|
||||
assertControlledRelativePath(repositoryPath, fieldPath);
|
||||
const resolved = path.resolve(workspaceRoot, repositoryPath);
|
||||
if (!isInsideOrSame(workspaceRoot, resolved)) {
|
||||
throw pathBoundaryError(reasonCode, "Path escaped workspace root", fieldPath, { repositoryPath });
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function pathBoundaryError(reasonCode, message, fieldPath, details = {}) {
|
||||
return Object.assign(new Error(message), {
|
||||
reasonCode,
|
||||
path: fieldPath,
|
||||
details
|
||||
});
|
||||
}
|
||||
|
||||
async function moveDefaultEvidenceToOut(defaultPath, outPath) {
|
||||
const absoluteDefaultPath = path.resolve(workspaceRoot, defaultPath);
|
||||
const absoluteOutPath = path.resolve(workspaceRoot, outPath);
|
||||
if (!isInsideOrSame(workspaceRoot, absoluteDefaultPath) || !isInsideOrSame(workspaceRoot, absoluteOutPath)) {
|
||||
throw new Error("evidence output path escaped workspace root");
|
||||
}
|
||||
await mkdir(path.dirname(absoluteOutPath), { recursive: true });
|
||||
await rename(absoluteDefaultPath, absoluteOutPath);
|
||||
}
|
||||
|
||||
function toCamelCase(value) {
|
||||
return value.replace(/-([a-z])/g, (_match, char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function toKebabCase(value) {
|
||||
return value.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function assertSafeFileSegment(value, fieldPath) {
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(value) || value === "." || value === "..") {
|
||||
throw new Error(`Unsafe --${fieldPath}: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertControlledRelativePath(value, fieldPath) {
|
||||
if (!isControlledRelativePath(value)) throw new Error(`Unsafe --${toKebabCase(fieldPath)} path: ${value}`);
|
||||
}
|
||||
|
||||
function isControlledRelativePath(value) {
|
||||
if (value.length === 0 || value.startsWith("/") || value.startsWith("\\") || value.includes("\\") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
let decodedValue;
|
||||
try {
|
||||
decodedValue = decodeURIComponent(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const candidate of [value, decodedValue]) {
|
||||
if (candidate.startsWith("/") || candidate.startsWith("\\") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) return false;
|
||||
const segments = candidate.split("/");
|
||||
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isInsideOrSame(rootPath, candidatePath) {
|
||||
return candidatePath === rootPath || candidatePath.startsWith(`${rootPath}${path.sep}`);
|
||||
}
|
||||
|
||||
function isoTimestamp() {
|
||||
return "2026-06-05T00:00:00.000Z";
|
||||
}
|
||||
|
||||
function stableJsonText(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function printJson(value) {
|
||||
process.stdout.write(stableJsonText(value));
|
||||
}
|
||||
|
||||
function errorPayload(status, error) {
|
||||
return {
|
||||
status,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
...(error && typeof error === "object" && "reasonCode" in error ? { reasonCode: error.reasonCode, path: error.path } : {})
|
||||
};
|
||||
}
|
||||
228
scripts/smoke-devtool-import.spec.mjs
Normal file
228
scripts/smoke-devtool-import.spec.mjs
Normal file
@ -0,0 +1,228 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { lstat, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const workspaceRoot = process.cwd();
|
||||
const evidenceRoot = path.join(workspaceRoot, "docs/evidence/devtool-import");
|
||||
const gameVersionId = "simulation-fixture-v1";
|
||||
const target = "wechat_minigame";
|
||||
const projectPath = `dist/packages/${gameVersionId}/${target}`;
|
||||
const outPath = `docs/evidence/devtool-import/${target}-${gameVersionId}.json`;
|
||||
const blockerPath = path.join(evidenceRoot, `${target}-${gameVersionId}.blocker.json`);
|
||||
const evidencePath = path.join(workspaceRoot, outPath);
|
||||
const logPath = `docs/evidence/devtool-import/${target}-${gameVersionId}.manual.log`;
|
||||
const blockerLogPath = path.join(evidenceRoot, `${target}-${gameVersionId}.blocked.log`);
|
||||
const outsideRoots = [];
|
||||
const swappedPaths = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(path.join(workspaceRoot, "dist/packages/simulation-fixture-v1"), { recursive: true, force: true });
|
||||
await restoreSwappedPaths();
|
||||
await cleanGeneratedEvidenceFiles();
|
||||
await execFileAsync("node", ["scripts/build-minigame.mjs", "--target", "all", "--fixture", "simulation"], {
|
||||
cwd: workspaceRoot,
|
||||
maxBuffer: 1024 * 1024 * 8
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(path.join(workspaceRoot, "dist/packages/simulation-fixture-v1"), { recursive: true, force: true });
|
||||
await restoreSwappedPaths();
|
||||
await cleanGeneratedEvidenceFiles();
|
||||
for (const outsideRoot of outsideRoots.splice(0)) {
|
||||
await rm(outsideRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("smoke-devtool-import CLI", () => {
|
||||
it("fails closed and writes a blocker when manual passed evidence is not explicitly allowed", async () => {
|
||||
await assert.rejects(
|
||||
execFileAsync(
|
||||
"node",
|
||||
[
|
||||
"scripts/smoke-devtool-import.mjs",
|
||||
"--target",
|
||||
target,
|
||||
"--game-version-id",
|
||||
gameVersionId,
|
||||
"--project",
|
||||
projectPath,
|
||||
"--out",
|
||||
outPath
|
||||
],
|
||||
{ cwd: workspaceRoot, maxBuffer: 1024 * 1024 * 8 }
|
||||
),
|
||||
(error) => {
|
||||
assert.equal(error.code, 1);
|
||||
const output = JSON.parse(error.stdout);
|
||||
assert.equal(output.status, "NO_GO");
|
||||
assert.equal(output.result, "blocked");
|
||||
assert.equal(output.blockerPath, `docs/evidence/devtool-import/${target}-${gameVersionId}.blocker.json`);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
const blocker = JSON.parse(await readFile(blockerPath, "utf8"));
|
||||
assert.equal(blocker.result, "blocked");
|
||||
assert.equal(blocker.reasonCode, "DEVTOOL_UNAVAILABLE");
|
||||
});
|
||||
|
||||
it("fails closed before writing default blocker log when evidence root is a symlink", async () => {
|
||||
const outsideRoot = await mkdtemp(path.join(tmpdir(), "huijing-s5-smoke-devtool-outside-"));
|
||||
outsideRoots.push(outsideRoot);
|
||||
await swapPathForSymlink(evidenceRoot, outsideRoot);
|
||||
|
||||
await assert.rejects(
|
||||
execFileAsync(
|
||||
"node",
|
||||
[
|
||||
"scripts/smoke-devtool-import.mjs",
|
||||
"--target",
|
||||
target,
|
||||
"--game-version-id",
|
||||
gameVersionId,
|
||||
"--project",
|
||||
projectPath,
|
||||
"--out",
|
||||
outPath
|
||||
],
|
||||
{ cwd: workspaceRoot, maxBuffer: 1024 * 1024 * 8 }
|
||||
),
|
||||
(error) => {
|
||||
assert.equal(error.code, 1);
|
||||
const output = JSON.parse(error.stdout);
|
||||
assert.equal(output.status, "FAIL");
|
||||
assert.equal(output.reasonCode, "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED");
|
||||
assert.equal(output.path, "evidenceRoot");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
await assert.rejects(readFile(path.join(outsideRoot, `${target}-${gameVersionId}.blocked.log`), "utf8"), { code: "ENOENT" });
|
||||
await assert.rejects(readFile(path.join(outsideRoot, `${target}-${gameVersionId}.blocker.json`), "utf8"), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("fails closed before writing default blocker log when evidence root parent is a symlink", async () => {
|
||||
const outsideRoot = await mkdtemp(path.join(tmpdir(), "huijing-s5-smoke-devtool-parent-outside-"));
|
||||
outsideRoots.push(outsideRoot);
|
||||
const symlinkParent = path.join(workspaceRoot, "docs/evidence");
|
||||
await swapPathForSymlink(symlinkParent, outsideRoot);
|
||||
|
||||
await assert.rejects(
|
||||
execFileAsync(
|
||||
"node",
|
||||
[
|
||||
"scripts/smoke-devtool-import.mjs",
|
||||
"--target",
|
||||
target,
|
||||
"--game-version-id",
|
||||
gameVersionId,
|
||||
"--project",
|
||||
projectPath,
|
||||
"--out",
|
||||
outPath
|
||||
],
|
||||
{ cwd: workspaceRoot, maxBuffer: 1024 * 1024 * 8 }
|
||||
),
|
||||
(error) => {
|
||||
assert.equal(error.code, 1);
|
||||
const output = JSON.parse(error.stdout);
|
||||
assert.equal(output.status, "FAIL");
|
||||
assert.equal(output.reasonCode, "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED");
|
||||
assert.equal(output.path, "evidenceRoot");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
await assert.rejects(readFile(path.join(outsideRoot, `devtool-import/${target}-${gameVersionId}.blocked.log`), "utf8"), { code: "ENOENT" });
|
||||
await assert.rejects(readFile(path.join(outsideRoot, `devtool-import/${target}-${gameVersionId}.blocker.json`), "utf8"), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("records passed evidence only with explicit audited manual evidence inputs", async () => {
|
||||
await mkdir(evidenceRoot, { recursive: true });
|
||||
await writeFile(path.join(workspaceRoot, logPath), "manual devtool import passed\n", "utf8");
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
"node",
|
||||
[
|
||||
"scripts/smoke-devtool-import.mjs",
|
||||
"--target",
|
||||
target,
|
||||
"--game-version-id",
|
||||
gameVersionId,
|
||||
"--project",
|
||||
projectPath,
|
||||
"--out",
|
||||
outPath,
|
||||
"--allow-manual-passed-evidence",
|
||||
"--tool-name",
|
||||
"WeChat DevTools",
|
||||
"--tool-version",
|
||||
"1.06.2504010",
|
||||
"--log",
|
||||
logPath
|
||||
],
|
||||
{ cwd: workspaceRoot, maxBuffer: 1024 * 1024 * 8 }
|
||||
);
|
||||
const output = JSON.parse(stdout);
|
||||
|
||||
assert.equal(output.status, "PASS");
|
||||
assert.equal(output.result, "passed");
|
||||
assert.equal(output.evidencePath, outPath);
|
||||
|
||||
const evidence = JSON.parse(await readFile(evidencePath, "utf8"));
|
||||
assert.equal(evidence.result, "passed");
|
||||
assert.equal(evidence.target, target);
|
||||
assert.equal(evidence.toolName, "WeChat DevTools");
|
||||
assert.equal(evidence.importedProjectPath, projectPath);
|
||||
});
|
||||
});
|
||||
|
||||
async function cleanGeneratedEvidenceFiles() {
|
||||
await rm(blockerPath, { force: true });
|
||||
await rm(blockerLogPath, { force: true });
|
||||
await rm(evidencePath, { force: true });
|
||||
await rm(path.join(workspaceRoot, logPath), { force: true });
|
||||
}
|
||||
|
||||
async function swapPathForSymlink(targetPath, outsideRoot) {
|
||||
const backupPath = await mkdtemp(path.join(tmpdir(), "huijing-s5-smoke-devtool-backup-"));
|
||||
await rm(backupPath, { recursive: true, force: true });
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
let hasBackup = true;
|
||||
try {
|
||||
await rename(targetPath, backupPath);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
hasBackup = false;
|
||||
}
|
||||
|
||||
await symlink(outsideRoot, targetPath);
|
||||
swappedPaths.push({ targetPath, backupPath, hasBackup });
|
||||
}
|
||||
|
||||
async function restoreSwappedPaths() {
|
||||
for (const swap of swappedPaths.splice(0).reverse()) {
|
||||
try {
|
||||
const stats = await lstat(swap.targetPath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
await rm(swap.targetPath, { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
if (swap.hasBackup) {
|
||||
await mkdir(path.dirname(swap.targetPath), { recursive: true });
|
||||
await rename(swap.backupPath, swap.targetPath);
|
||||
} else {
|
||||
await rm(swap.backupPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user