games-development-ai/scripts/smoke-devtool-import.spec.mjs
2026-06-05 17:25:33 +08:00

229 lines
7.9 KiB
JavaScript

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 });
}
}
}