510 lines
15 KiB
TypeScript
510 lines
15 KiB
TypeScript
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
|
|
import { expect, it } from "vitest";
|
|
import { parse } from "yaml";
|
|
import { z } from "zod";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import {
|
|
executeWorkflowFixture as execFileAsync,
|
|
workflowGit as git,
|
|
} from "../../support/workflowGit.js";
|
|
|
|
async function commit(directory: string) {
|
|
await git(directory, ["add", "."]);
|
|
await git(directory, ["commit", "-m", "fix: fixture change"]);
|
|
return git(directory, ["rev-parse", "HEAD"]);
|
|
}
|
|
|
|
async function change(
|
|
directory: string,
|
|
paths: readonly string[],
|
|
content = "Changed fixture content\n",
|
|
) {
|
|
for (const path of paths) {
|
|
const file = join(directory, path);
|
|
await mkdir(dirname(file), { recursive: true });
|
|
await writeFile(file, content);
|
|
}
|
|
return commit(directory);
|
|
}
|
|
|
|
async function fixture() {
|
|
const directory = await createTestTempDirectory("rea-ci-scope-");
|
|
await git(directory, ["init", "--initial-branch=main"]);
|
|
const base = await change(
|
|
directory,
|
|
["src/provider.ts", "README.md"],
|
|
"Initial fixture content\n",
|
|
);
|
|
return { directory, base };
|
|
}
|
|
|
|
async function classify(
|
|
directory: string,
|
|
base: string,
|
|
head: string,
|
|
event = "pull_request",
|
|
full = false,
|
|
) {
|
|
const output = join(directory, "scope-output");
|
|
await writeFile(output, "");
|
|
const result = await execFileAsync(
|
|
process.execPath,
|
|
[new URL("../../../scripts/ci/plan.mjs", import.meta.url).pathname],
|
|
{
|
|
cwd: directory,
|
|
env: {
|
|
...process.env,
|
|
BASE_SHA: base,
|
|
HEAD_SHA: head,
|
|
GITHUB_EVENT_NAME: event,
|
|
CI_FULL: String(full),
|
|
GITHUB_OUTPUT: output,
|
|
},
|
|
},
|
|
);
|
|
return z.record(z.string(), z.unknown()).parse(JSON.parse(result.stdout));
|
|
}
|
|
|
|
it("keeps every translated root README and authored guide on the documentation lane", async () => {
|
|
const { directory, base } = await fixture();
|
|
const readmes = (await readdir(new URL("../../../", import.meta.url))).filter(
|
|
(name) => /^README(?:_.*)?\.md$/u.test(name),
|
|
);
|
|
expect(readmes).toContain("README.md");
|
|
const head = await change(directory, [
|
|
...readmes,
|
|
"docs/guide.md",
|
|
"AGENTS.md",
|
|
"CONTRIBUTING.md",
|
|
]);
|
|
expect(await classify(directory, base, head)).toMatchObject({
|
|
code: false,
|
|
package: false,
|
|
docs: true,
|
|
});
|
|
});
|
|
|
|
it("does not charge a documentation PR for unrelated implementation updates on main", async () => {
|
|
const { directory, base } = await fixture();
|
|
await git(directory, ["switch", "-c", "docs/change"]);
|
|
const head = await change(directory, ["README_zh-TW.md"]);
|
|
await git(directory, ["switch", "main"]);
|
|
const advancedBase = await change(directory, ["src/anotherProvider.ts"]);
|
|
expect(advancedBase).not.toBe(base);
|
|
expect(await classify(directory, advancedBase, head)).toMatchObject({
|
|
code: false,
|
|
package: false,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
"src/README.md",
|
|
"README.md.ts",
|
|
"bridge/ghidra/provider.py",
|
|
"tests/fixture.test.ts",
|
|
".github/workflows/ci.yml",
|
|
"unknown-file",
|
|
])(
|
|
"retains complete checks when documentation is mixed with %s",
|
|
async (path) => {
|
|
const { directory, base } = await fixture();
|
|
const head = await change(directory, ["README.md", path]);
|
|
expect(await classify(directory, base, head)).toMatchObject({ code: true });
|
|
},
|
|
);
|
|
|
|
it("retains complete checks when implementation is renamed into documentation", async () => {
|
|
const { directory, base } = await fixture();
|
|
await mkdir(join(directory, "docs"));
|
|
await rename(
|
|
join(directory, "src/provider.ts"),
|
|
join(directory, "docs/provider.md"),
|
|
);
|
|
const head = await commit(directory);
|
|
expect(await classify(directory, base, head)).toMatchObject({
|
|
code: true,
|
|
package: true,
|
|
});
|
|
});
|
|
|
|
it("retains the complete main-push baseline even for documentation", async () => {
|
|
const { directory, base } = await fixture();
|
|
const head = await change(directory, ["README_ja.md"]);
|
|
expect(await classify(directory, base, head, "push")).toMatchObject({
|
|
code: true,
|
|
package: true,
|
|
});
|
|
});
|
|
|
|
it("fails classification rather than treating an invalid Git comparison as a scope", async () => {
|
|
const { directory, base } = await fixture();
|
|
await expect(classify(directory, "missing-ref", base)).rejects.toMatchObject({
|
|
code: 1,
|
|
});
|
|
expect(await readFile(join(directory, "scope-output"), "utf8")).toBe("");
|
|
});
|
|
|
|
it("retains native Inspector verification on every documented package host", async () => {
|
|
const stepSchema = z.object({
|
|
run: z.string().optional(),
|
|
if: z.string().optional(),
|
|
});
|
|
const steps = z.array(stepSchema);
|
|
const workflow = z
|
|
.object({
|
|
jobs: z.object({
|
|
"package-e2e": z.object({
|
|
steps,
|
|
}),
|
|
"windows-curated": z.object({ steps }),
|
|
}),
|
|
})
|
|
.parse(
|
|
parse(
|
|
await readFile(
|
|
new URL("../../../.github/workflows/ci.yml", import.meta.url),
|
|
"utf8",
|
|
),
|
|
),
|
|
);
|
|
const packageLane = workflow.jobs["package-e2e"];
|
|
const packageInspector = stepSchema.parse(
|
|
packageLane.steps.find((step) => step.run === "npm run verify:inspector"),
|
|
);
|
|
expect(packageInspector.if).toBeUndefined();
|
|
expect(
|
|
(await classify((await fixture()).directory, "", "", "push")).matrix,
|
|
).toEqual({
|
|
include: [
|
|
{ os: "ubuntu-latest", platform: "linux", architecture: "x64" },
|
|
{ os: "ubuntu-24.04-arm", platform: "linux", architecture: "arm64" },
|
|
{ os: "macos-15", platform: "darwin", architecture: "arm64" },
|
|
{ os: "macos-15-intel", platform: "darwin", architecture: "x64" },
|
|
],
|
|
});
|
|
const windowsInspector = stepSchema.parse(
|
|
workflow.jobs["windows-curated"].steps.find(
|
|
(step) => step.run === "npm run verify:inspector",
|
|
),
|
|
);
|
|
expect(windowsInspector.if).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"src/evm/client.ts",
|
|
{ code: true, runtime: true, evm: true, browser: false, package: false },
|
|
],
|
|
[
|
|
"src/browser/execution/evaluate.ts",
|
|
{
|
|
code: true,
|
|
runtime: true,
|
|
browser: true,
|
|
web_runtime: true,
|
|
evm: false,
|
|
package: false,
|
|
},
|
|
],
|
|
[
|
|
"tests/module/example.test.ts",
|
|
{ code: true, runtime: false, browser: false, package: false },
|
|
],
|
|
["website/src/index.ts", { code: false, website: true, package: false }],
|
|
[
|
|
"website/locales/zh-CN/messages.po",
|
|
{ code: false, website: true, package: false },
|
|
],
|
|
["scripts/website_i18n.py", { code: false, website: true, package: false }],
|
|
["README_ja.md", { code: false, docs: true, website: true, package: false }],
|
|
[
|
|
".github/workflows/website-pages.yml",
|
|
{ code: false, website: true, package: false },
|
|
],
|
|
[
|
|
"src/process/ProviderProcess.ts",
|
|
{ code: true, package: true, evm: true, browser: true },
|
|
],
|
|
[
|
|
"package-lock.json",
|
|
{ code: true, package: true, evm: true, browser: true },
|
|
],
|
|
[
|
|
".github/workflows/real-evm-interface.yml",
|
|
{ code: false, evm: true, browser: false, package: false },
|
|
],
|
|
["unknown-input", { code: true, package: true, evm: true, browser: true }],
|
|
])("selects the appropriate lanes for %s", async (path, expected) => {
|
|
const { directory, base } = await fixture();
|
|
const head = await change(directory, [path]);
|
|
expect(await classify(directory, base, head)).toMatchObject(expected);
|
|
});
|
|
|
|
it("scopes package script edits while retaining broad dependency checks", async () => {
|
|
const { directory } = await fixture();
|
|
const before = {
|
|
name: "fixture",
|
|
scripts: { "verify:evm": "node scripts/verify-evm-interface.mjs" },
|
|
};
|
|
const base = await change(
|
|
directory,
|
|
["package.json"],
|
|
JSON.stringify(before),
|
|
);
|
|
const after = {
|
|
...before,
|
|
scripts: {
|
|
"verify:evm": "node scripts/verify-evm-interface.mjs --verbose",
|
|
},
|
|
};
|
|
const head = await change(directory, ["package.json"], JSON.stringify(after));
|
|
expect(await classify(directory, base, head)).toMatchObject({
|
|
code: true,
|
|
evm: true,
|
|
browser: false,
|
|
package: false,
|
|
});
|
|
const dependency = await change(
|
|
directory,
|
|
["package.json"],
|
|
JSON.stringify({ ...after, dependencies: { example: "1.0.0" } }),
|
|
);
|
|
expect(await classify(directory, head, dependency)).toMatchObject({
|
|
code: true,
|
|
browser: true,
|
|
package: true,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["browser", "real-browser"],
|
|
["binary_layout", "real-binary-layout"],
|
|
["evm", "real-evm-interface"],
|
|
["javascript_recovery", "real-javascript-recovery"],
|
|
["recorded_crash", "real-recorded-crash"],
|
|
["web_network", "real-web-network-captures"],
|
|
["web_runtime", "real-web-runtime"],
|
|
["web_source_map", "real-web-source-map"],
|
|
["nativeaot", "nativeaot-fixtures"],
|
|
["website", "website-check"],
|
|
])(
|
|
"routes %s through the selector without a duplicate PR trigger",
|
|
async (scope, name) => {
|
|
const workflow = z
|
|
.object({
|
|
on: z.object({ workflow_call: z.null() }).passthrough(),
|
|
})
|
|
.parse(
|
|
parse(
|
|
await readFile(
|
|
new URL(`../../../.github/workflows/${name}.yml`, import.meta.url),
|
|
"utf8",
|
|
),
|
|
),
|
|
);
|
|
expect(workflow.on).not.toHaveProperty("pull_request");
|
|
const ci = z
|
|
.object({
|
|
jobs: z.record(
|
|
z.string(),
|
|
z.object({
|
|
uses: z.string().optional(),
|
|
if: z.string().optional(),
|
|
}),
|
|
),
|
|
})
|
|
.parse(
|
|
parse(
|
|
await readFile(
|
|
new URL("../../../.github/workflows/ci.yml", import.meta.url),
|
|
"utf8",
|
|
),
|
|
),
|
|
);
|
|
expect(ci.jobs[scope]).toMatchObject({
|
|
uses: `./.github/workflows/${name}.yml`,
|
|
if: `needs.changes.outputs.${scope} == 'true'`,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("rejects skipped selected work while allowing unselected lanes to skip", async () => {
|
|
const command = new URL("../../../scripts/ci/required.mjs", import.meta.url)
|
|
.pathname;
|
|
const jobs = {
|
|
changes: { result: "success", outputs: { evm: "true" } },
|
|
static: { result: "success" },
|
|
test: { result: "success" },
|
|
evm: { result: "success" },
|
|
browser: { result: "skipped" },
|
|
};
|
|
const run = (result: string) =>
|
|
execFileAsync(process.execPath, [command], {
|
|
env: {
|
|
...process.env,
|
|
CI_JOBS: JSON.stringify({ ...jobs, evm: { result } }),
|
|
},
|
|
});
|
|
await expect(run("success")).resolves.toMatchObject({ stderr: "" });
|
|
for (const result of ["failure", "cancelled", "skipped"]) {
|
|
await expect(run(result)).rejects.toMatchObject({ code: 1 });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
".github/workflows/real-termux.yml",
|
|
{ workflows: true, code: false, package: false, website: false },
|
|
],
|
|
[
|
|
"src/domain/javascript/example.test.ts",
|
|
{ code: true, runtime: false, javascript_recovery: false, package: false },
|
|
],
|
|
[
|
|
"src/process/capture/example.test.ts",
|
|
{ code: true, runtime: false, browser: false, package: false },
|
|
],
|
|
[".nvmrc", { code: true, package: true, browser: true, evm: true }],
|
|
[
|
|
"tsconfig.build.json",
|
|
{ code: true, package: true, browser: true, evm: true },
|
|
],
|
|
["docs/.vitepress/config.ts", { code: false, docs: true, package: false }],
|
|
[
|
|
"src/contracts/evm/tools.ts",
|
|
{ code: true, docs: true, evm: true, package: false },
|
|
],
|
|
["scripts/unknown-website-helper.mjs", { code: true, package: true }],
|
|
[
|
|
".github/workflows/release.yml",
|
|
{ workflows: true, code: true, package: false, website: false },
|
|
],
|
|
[
|
|
".github/workflows/real-ghidra-windows.yml",
|
|
{ workflows: true, code: true, package: false, website: false },
|
|
],
|
|
[
|
|
"tests/conformance/c/fixture.c",
|
|
{ code: true, fixtures: true, readiness: true, package: false },
|
|
],
|
|
[
|
|
"tests/conformance/swift/fixture.swift",
|
|
{ code: true, fixtures: true, readiness: true, package: false },
|
|
],
|
|
[
|
|
"tests/conformance/readiness/javascript-cli/package.json",
|
|
{ code: true, fixtures: false, readiness: true, package: false },
|
|
],
|
|
[
|
|
"tests/conformance/ghidra/no-return.c",
|
|
{ code: true, fixtures: false, readiness: false, package: false },
|
|
],
|
|
])("preserves scope boundaries for %s", async (path, expected) => {
|
|
const { directory, base } = await fixture();
|
|
const head = await change(directory, [path]);
|
|
expect(await classify(directory, base, head)).toMatchObject(expected);
|
|
});
|
|
|
|
it("unions provider scopes across mixed changes and deletions", async () => {
|
|
const { directory } = await fixture();
|
|
const base = await change(directory, ["src/evm/client.ts"]);
|
|
await git(directory, ["rm", "src/evm/client.ts"]);
|
|
const head = await change(directory, ["src/browser/execution/evaluate.ts"]);
|
|
expect(await classify(directory, base, head)).toMatchObject({
|
|
evm: true,
|
|
browser: true,
|
|
runtime: true,
|
|
package: false,
|
|
});
|
|
});
|
|
|
|
it("fails without emitting a plan when package metadata is malformed", async () => {
|
|
const { directory } = await fixture();
|
|
const base = await change(directory, ["package.json"], '{"scripts":{}}');
|
|
const head = await change(directory, ["package.json"], "{broken");
|
|
await expect(classify(directory, base, head)).rejects.toMatchObject({
|
|
code: 1,
|
|
});
|
|
expect(await readFile(join(directory, "scope-output"), "utf8")).toBe("");
|
|
});
|
|
|
|
it("retains a broad fallback when a provider script adds shared shell work", async () => {
|
|
const { directory } = await fixture();
|
|
const before = {
|
|
scripts: { "verify:evm": "node scripts/verify-evm-interface.mjs" },
|
|
};
|
|
const base = await change(
|
|
directory,
|
|
["package.json"],
|
|
JSON.stringify(before),
|
|
);
|
|
const after = {
|
|
scripts: {
|
|
"verify:evm": "node scripts/verify-evm-interface.mjs && npm run compile",
|
|
},
|
|
};
|
|
const head = await change(directory, ["package.json"], JSON.stringify(after));
|
|
expect(await classify(directory, base, head)).toMatchObject({
|
|
evm: true,
|
|
browser: true,
|
|
package: true,
|
|
});
|
|
});
|
|
|
|
it.each(["workflow_dispatch", "schedule"])(
|
|
"retains full selection for %s",
|
|
async (event) => {
|
|
const { directory, base } = await fixture();
|
|
expect(await classify(directory, base, base, event)).toMatchObject({
|
|
code: true,
|
|
package: true,
|
|
evm: true,
|
|
browser: true,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("honors ci:full for successive PR revisions", async () => {
|
|
const { directory, base } = await fixture();
|
|
const first = await change(directory, ["README.md"]);
|
|
const second = await change(directory, ["README_ja.md"]);
|
|
for (const head of [first, second]) {
|
|
expect(
|
|
await classify(directory, base, head, "pull_request", true),
|
|
).toMatchObject({ code: true, package: true, evm: true, browser: true });
|
|
}
|
|
});
|
|
|
|
it.each(["compile:termux", "build:termux", "build:termux:run"])(
|
|
"does not expand %s edits into unrelated providers",
|
|
async (script) => {
|
|
const { directory } = await fixture();
|
|
const base = await change(
|
|
directory,
|
|
["package.json"],
|
|
JSON.stringify({ scripts: { [script]: "npm run compile:termux" } }),
|
|
);
|
|
const head = await change(
|
|
directory,
|
|
["package.json"],
|
|
JSON.stringify({
|
|
scripts: {
|
|
[script]:
|
|
"node scripts/run-exclusive.mjs artifacts npm run compile:termux",
|
|
},
|
|
}),
|
|
);
|
|
expect(await classify(directory, base, head)).toMatchObject({
|
|
code: true,
|
|
workflows: true,
|
|
runtime: false,
|
|
package: false,
|
|
browser: false,
|
|
evm: false,
|
|
});
|
|
},
|
|
);
|