mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
refactor(ci): unify lane ownership and guard Python tests (#8150)
* refactor(ci): unify lane ownership and guard Python tests * fix(ci): reject entirely skipped bridge subtests
This commit is contained in:
1 parent
8f920bb65b
commit
cd878ad57d
8 files changed
+370
-99
No files matched your search
+8
-6
@@ -857,9 +857,10 @@ bridges, analysis-efficiency inputs, or the Python test runner select a separate
|
||||
required Python job. That job installs locked dependencies and prepares the
|
||||
compiled runtime before the suite, because analysis-efficiency checks import
|
||||
built process modules. Bridge tests are discovered from the source tree. The
|
||||
mitmproxy, native, and pwndbg families must each keep a test module, and a
|
||||
newly added failing test fails the job. Portable test shards and documentation
|
||||
checks remain independent of static results, so they can still provide
|
||||
mitmproxy, native, and pwndbg families must each contain a test module and
|
||||
execute at least one non-skipped case or subtest. A parent case with only skipped
|
||||
subtests does not satisfy this requirement. A newly added failing test fails the job.
|
||||
Portable test shards and documentation checks remain independent of static results, so they can still provide
|
||||
diagnostic feedback. The required gate rejects skipped selected lanes when
|
||||
static checks fail; on the full tier it also requires the deep static job.
|
||||
Passing revisions retain all selected coverage. This ordering reduces peak
|
||||
@@ -1577,9 +1578,10 @@ initial proposal measures only leaf producer-test omissions; it does not promise
|
||||
the savings of a broader affected-test planner.
|
||||
|
||||
`scripts/ci/plan.mjs` selects lanes using the ownership table in
|
||||
`scripts/ci/scopes.mjs`. Provider implementation changes run their real-provider
|
||||
verifiers and the Linux x64 installed-package/Inspector baseline. The `apple`
|
||||
scope owns the Apple artifact lane; the `package` scope owns the multi-host
|
||||
`scripts/ci/scopes.mjs`. Each lane record owns its path and test selection
|
||||
rules and required fast/full jobs; workflow contract tests verify the mapped jobs.
|
||||
Provider implementation changes run their real-provider verifiers and the Linux
|
||||
x64 installed-package/Inspector baseline. The `apple` scope owns the Apple artifact lane; the `package` scope owns the multi-host
|
||||
package matrix and the Windows curated lane, including Windows native controls,
|
||||
Inspector, setup, configuration and packaging entrypoints. Runtime sources under
|
||||
`src/` or `bridge/` without an owner, and the authored skill, run the Linux suite
|
||||
|
||||
+18
-34
@@ -4,19 +4,11 @@ import { matchesGlob } from "node:path";
|
||||
import {
|
||||
CI_FLAGS as flags,
|
||||
MANUAL_VERIFIERS,
|
||||
SCOPES,
|
||||
CI_LANES,
|
||||
WINDOWS_EXECUTABLE_SELECTION_INPUTS,
|
||||
} from "./scopes.mjs";
|
||||
import { planTestSelection } from "./test-selection.mjs";
|
||||
|
||||
// Test files select the host/runtime lanes that execute their focused suites.
|
||||
const HOST_TEST_OWNERS = [
|
||||
"apple",
|
||||
"package",
|
||||
"sqlite",
|
||||
"packet_capture",
|
||||
"signal_recording",
|
||||
];
|
||||
// Actionlint validates workflow syntax only. The semantic suite asserts these
|
||||
// workflows' release, publication and self-hosted runner safeguards.
|
||||
const GUARDED_WORKFLOWS = new Set([
|
||||
@@ -155,11 +147,11 @@ function classifyPackage(base, head) {
|
||||
const scriptOwners =
|
||||
simple &&
|
||||
entries.every((entry) =>
|
||||
Object.values(SCOPES).some((patterns) => matchesAny(entry, patterns)),
|
||||
Object.values(CI_LANES).some(({ paths }) => matchesAny(entry, paths)),
|
||||
)
|
||||
? Object.entries(SCOPES)
|
||||
.filter(([, patterns]) =>
|
||||
entries.some((entry) => matchesAny(entry, patterns)),
|
||||
? Object.entries(CI_LANES)
|
||||
.filter(([, { paths }]) =>
|
||||
entries.some((entry) => matchesAny(entry, paths)),
|
||||
)
|
||||
.map(([owner]) => owner)
|
||||
: [];
|
||||
@@ -225,16 +217,12 @@ if (tier === "reused") {
|
||||
file,
|
||||
WINDOWS_EXECUTABLE_SELECTION_INPUTS,
|
||||
);
|
||||
if (matchesAny(file, SCOPES.reverse_tools)) selected.reverse_tools = true;
|
||||
if (matchesAny(file, SCOPES.python)) selected.python = true;
|
||||
// Both live capture lanes exercise Linux resource admission.
|
||||
if (
|
||||
matchesAny(file, ["src/process/LinuxResourceLimits*"]) ||
|
||||
file === "scripts/ci/require-provider-tests.mjs"
|
||||
) {
|
||||
selected.packet_capture = true;
|
||||
selected.signal_recording = true;
|
||||
}
|
||||
for (const [
|
||||
owner,
|
||||
{ paths, earlyAll, earlyInputs = [] },
|
||||
] of Object.entries(CI_LANES))
|
||||
if (matchesAny(file, earlyAll ? paths : earlyInputs))
|
||||
selected[owner] = true;
|
||||
if (
|
||||
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test(
|
||||
file,
|
||||
@@ -248,13 +236,13 @@ if (tier === "reused") {
|
||||
file.startsWith("docs/") &&
|
||||
(/\.(?:[cm]?[jt]s|[jt]sx)$/u.test(file) ||
|
||||
file.endsWith("/package.json"));
|
||||
selected.website ||= matchesAny(file, SCOPES.website);
|
||||
selected.website ||= matchesAny(file, CI_LANES.website.paths);
|
||||
continue;
|
||||
}
|
||||
if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) {
|
||||
selected.code = true;
|
||||
for (const owner of HOST_TEST_OWNERS)
|
||||
selected[owner] ||= matchesAny(file, SCOPES[owner]);
|
||||
for (const [owner, { paths, testInputs }] of Object.entries(CI_LANES))
|
||||
if (testInputs && matchesAny(file, paths)) selected[owner] = true;
|
||||
continue;
|
||||
}
|
||||
if (matchesAny(file, MANUAL_VERIFIERS)) {
|
||||
@@ -274,19 +262,15 @@ if (tier === "reused") {
|
||||
if (tier !== "full" && SHARED_BOUNDARY.test(file)) {
|
||||
selected.code = true;
|
||||
selected.runtime = true;
|
||||
const owners = Object.entries(SCOPES)
|
||||
.filter(([, patterns]) =>
|
||||
patterns.some((pattern) => matchesGlob(file, pattern)),
|
||||
)
|
||||
const owners = Object.entries(CI_LANES)
|
||||
.filter(([, { paths }]) => matchesAny(file, paths))
|
||||
.map(([owner]) => owner);
|
||||
if (owners.length === 1) selected[owners[0]] = true;
|
||||
reasons.push(`Shared boundary on fast tier: ${file}`);
|
||||
continue;
|
||||
}
|
||||
const owners = Object.entries(SCOPES)
|
||||
.filter(([, patterns]) =>
|
||||
patterns.some((pattern) => matchesGlob(file, pattern)),
|
||||
)
|
||||
const owners = Object.entries(CI_LANES)
|
||||
.filter(([, { paths }]) => matchesAny(file, paths))
|
||||
.map(([owner]) => owner);
|
||||
for (const owner of owners) selected[owner] = true;
|
||||
if (
|
||||
|
||||
+3
-19
@@ -1,4 +1,4 @@
|
||||
import { CI_FLAGS } from "./scopes.mjs";
|
||||
import { CI_FLAGS, CI_LANES } from "./scopes.mjs";
|
||||
import { recordQualification } from "./qualification.mjs";
|
||||
|
||||
const jobs = JSON.parse(process.env.CI_JOBS);
|
||||
@@ -30,24 +30,8 @@ if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length)
|
||||
throw new Error("Full qualification requires every canonical CI scope");
|
||||
const required = reused ? ["changes"] : ["changes", "static", "test"];
|
||||
if (outputs.tier === "full") required.push("static-deep");
|
||||
const lanes = {
|
||||
code: [
|
||||
"build",
|
||||
"test-shard",
|
||||
...(outputs.tier === "fast" ? ["test-source"] : []),
|
||||
],
|
||||
reverse_tools: ["reverse-tools-quality"],
|
||||
runtime: ["windows-native-build", "package-e2e"],
|
||||
package: ["windows-curated"],
|
||||
sqlite: ["sqlite-runtime"],
|
||||
apple: ["apple-artifacts"],
|
||||
fixtures: ["conformance-fixtures"],
|
||||
readiness: ["reconstruction-readiness"],
|
||||
docs: ["docs"],
|
||||
workflows: ["static"],
|
||||
python: [outputs.tier === "full" ? "static-deep" : "python"],
|
||||
};
|
||||
for (const scope of selectedScopes) required.push(...(lanes[scope] ?? [scope]));
|
||||
for (const scope of selectedScopes)
|
||||
required.push(...CI_LANES[scope].required[outputs.tier]);
|
||||
const failed = [...new Set(required)].filter(
|
||||
(name) => jobs[name]?.result !== "success",
|
||||
);
|
||||
|
||||
+75
-17
@@ -8,10 +8,16 @@ export const WINDOWS_EXECUTABLE_SELECTION_INPUTS = [
|
||||
"tests/boundary/process/windowsExecutableSelection.test.ts",
|
||||
];
|
||||
|
||||
// Inputs whose changes must select both real capture lanes, even at shared boundaries.
|
||||
const CAPTURE_SHARED_INPUTS = [
|
||||
"src/process/LinuxResourceLimits*",
|
||||
"scripts/ci/require-provider-tests.mjs",
|
||||
];
|
||||
|
||||
// Ownership of the native-host, real-provider and website verification lanes.
|
||||
// Shared build inputs are classified once in plan.mjs rather than repeated in
|
||||
// every workflow.
|
||||
export const SCOPES = {
|
||||
const PATH_SCOPES = {
|
||||
python: [
|
||||
"bridge/**/*.py",
|
||||
"tests/support/analysis-efficiency/**",
|
||||
@@ -35,9 +41,8 @@ export const SCOPES = {
|
||||
"src/composition/captures.ts",
|
||||
"src/cli/captureCommands.ts",
|
||||
"src/server/registerCaptureTools.ts",
|
||||
"src/process/LinuxResourceLimits*",
|
||||
...CAPTURE_SHARED_INPUTS,
|
||||
"scripts/verify-packet-capture.mjs",
|
||||
"scripts/ci/require-provider-tests.mjs",
|
||||
".github/workflows/real-packet-capture.yml",
|
||||
],
|
||||
signal_recording: [
|
||||
@@ -51,9 +56,8 @@ export const SCOPES = {
|
||||
"src/composition/signals.ts",
|
||||
"src/cli/signalCommands.ts",
|
||||
"src/server/registerSignalTools.ts",
|
||||
"src/process/LinuxResourceLimits*",
|
||||
...CAPTURE_SHARED_INPUTS,
|
||||
"scripts/verify-signal-recording.mjs",
|
||||
"scripts/ci/require-provider-tests.mjs",
|
||||
".github/workflows/real-signal-recording.yml",
|
||||
],
|
||||
readiness: [
|
||||
@@ -609,19 +613,73 @@ export const SCOPES = {
|
||||
],
|
||||
};
|
||||
|
||||
export const CI_FLAGS = [
|
||||
...new Set([
|
||||
"code",
|
||||
"runtime",
|
||||
"package",
|
||||
"apple",
|
||||
"fixtures",
|
||||
"readiness",
|
||||
"docs",
|
||||
"workflows",
|
||||
...Object.keys(SCOPES),
|
||||
// Each lane owns its path inputs, selection policy, and required jobs. The
|
||||
// workflow stays explicit; contract tests bind it to this registry.
|
||||
const sameJobs = (paths, jobs, selection = {}) => ({
|
||||
paths,
|
||||
required: { fast: jobs, full: jobs },
|
||||
...selection,
|
||||
});
|
||||
export const CI_LANES = {
|
||||
code: {
|
||||
paths: [],
|
||||
required: {
|
||||
fast: ["build", "test-shard", "test-source"],
|
||||
full: ["build", "test-shard"],
|
||||
},
|
||||
},
|
||||
runtime: sameJobs([], ["windows-native-build", "package-e2e"]),
|
||||
package: sameJobs(PATH_SCOPES.package, ["windows-curated"], {
|
||||
testInputs: true,
|
||||
}),
|
||||
apple: sameJobs(PATH_SCOPES.apple, ["apple-artifacts"], { testInputs: true }),
|
||||
fixtures: sameJobs([], ["conformance-fixtures"]),
|
||||
readiness: sameJobs(PATH_SCOPES.readiness, ["reconstruction-readiness"]),
|
||||
docs: sameJobs([], ["docs"]),
|
||||
workflows: sameJobs([], ["static"]),
|
||||
python: {
|
||||
paths: PATH_SCOPES.python,
|
||||
earlyAll: true,
|
||||
required: { fast: ["python"], full: ["static-deep"] },
|
||||
},
|
||||
packet_capture: sameJobs(PATH_SCOPES.packet_capture, ["packet_capture"], {
|
||||
testInputs: true,
|
||||
earlyInputs: CAPTURE_SHARED_INPUTS,
|
||||
}),
|
||||
signal_recording: sameJobs(
|
||||
PATH_SCOPES.signal_recording,
|
||||
["signal_recording"],
|
||||
{ testInputs: true, earlyInputs: CAPTURE_SHARED_INPUTS },
|
||||
),
|
||||
reverse_tools: sameJobs(
|
||||
PATH_SCOPES.reverse_tools,
|
||||
["reverse-tools-quality"],
|
||||
{ earlyAll: true },
|
||||
),
|
||||
sqlite: sameJobs(PATH_SCOPES.sqlite, ["sqlite-runtime"], {
|
||||
testInputs: true,
|
||||
}),
|
||||
browser: sameJobs(PATH_SCOPES.browser, ["browser"]),
|
||||
binary_layout: sameJobs(PATH_SCOPES.binary_layout, ["binary_layout"]),
|
||||
evm: sameJobs(PATH_SCOPES.evm, ["evm"]),
|
||||
apk_signature: sameJobs(PATH_SCOPES.apk_signature, ["apk_signature"]),
|
||||
javascript_recovery: sameJobs(PATH_SCOPES.javascript_recovery, [
|
||||
"javascript_recovery",
|
||||
]),
|
||||
];
|
||||
recorded_crash: sameJobs(PATH_SCOPES.recorded_crash, ["recorded_crash"]),
|
||||
web_network: sameJobs(PATH_SCOPES.web_network, ["web_network"]),
|
||||
web_runtime: sameJobs(PATH_SCOPES.web_runtime, ["web_runtime"]),
|
||||
web_source_map: sameJobs(PATH_SCOPES.web_source_map, ["web_source_map"]),
|
||||
agentenv: sameJobs(PATH_SCOPES.agentenv, ["agentenv"]),
|
||||
nativeaot: sameJobs(PATH_SCOPES.nativeaot, ["nativeaot"]),
|
||||
ghidra_bridge: sameJobs(PATH_SCOPES.ghidra_bridge, ["ghidra_bridge"]),
|
||||
website: sameJobs(PATH_SCOPES.website, ["website"]),
|
||||
};
|
||||
// An input owner omitted from the registry must never silently lose a lane.
|
||||
for (const [name, paths] of Object.entries(PATH_SCOPES))
|
||||
if (!Object.hasOwn(CI_LANES, name) || CI_LANES[name].paths !== paths)
|
||||
throw new Error(`CI lane registry does not own input scope: ${name}`);
|
||||
export const CI_FLAGS = Object.keys(CI_LANES);
|
||||
|
||||
// Verifier entrypoints that only manual, licensed, or self-hosted workflows
|
||||
// run. No selected CI lane executes them, so their edits need only the static
|
||||
|
||||
+82
-18
@@ -4,10 +4,9 @@ from pathlib import Path
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
# The previous explicit command named these families. Discovery still runs
|
||||
# every bridge test, and each named family must keep a test module.
|
||||
REQUIRED_BRIDGE_FAMILIES = ("mitmproxy", "native", "pwndbg")
|
||||
|
||||
|
||||
@@ -16,35 +15,100 @@ def fail(code, **details):
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def family_tests(family):
|
||||
return [
|
||||
path
|
||||
for path in root.joinpath("bridge", family).rglob("test_*.py")
|
||||
if path.is_file()
|
||||
class BridgeResult(unittest.TextTestResult):
|
||||
"""Record non-skipped test and subtest outcomes for required families."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.completed = set()
|
||||
self.active_test = None
|
||||
self.has_subtests = False
|
||||
|
||||
def record(self, test):
|
||||
parts = test.id().split(".")
|
||||
if len(parts) > 2 and parts[0] == "bridge":
|
||||
if parts[1] in REQUIRED_BRIDGE_FAMILIES:
|
||||
self.completed.add(parts[1])
|
||||
|
||||
def startTest(self, test):
|
||||
self.active_test = test
|
||||
self.has_subtests = False
|
||||
super().startTest(test)
|
||||
|
||||
def stopTest(self, test):
|
||||
super().stopTest(test)
|
||||
self.active_test = None
|
||||
|
||||
def addSuccess(self, test):
|
||||
# A parent with only skipped subtests also receives addSuccess.
|
||||
if not self.has_subtests:
|
||||
self.record(test)
|
||||
super().addSuccess(test)
|
||||
|
||||
def addSubTest(self, test, subtest, outcome):
|
||||
self.has_subtests = True
|
||||
if outcome is None:
|
||||
self.record(test)
|
||||
super().addSubTest(test, subtest, outcome)
|
||||
|
||||
def addSkip(self, test, reason):
|
||||
if self.active_test is not None and test is not self.active_test:
|
||||
self.has_subtests = True
|
||||
super().addSkip(test, reason)
|
||||
|
||||
def addExpectedFailure(self, test, err):
|
||||
self.record(test)
|
||||
super().addExpectedFailure(test, err)
|
||||
|
||||
|
||||
def run_bridge(modules):
|
||||
# The script directory is sys.path[0]; imports must resolve from the checkout.
|
||||
sys.path.insert(0, str(root))
|
||||
suite = unittest.defaultTestLoader.loadTestsFromNames(modules)
|
||||
result = unittest.TextTestRunner(verbosity=2, resultclass=BridgeResult).run(suite)
|
||||
missing = [
|
||||
family
|
||||
for family in REQUIRED_BRIDGE_FAMILIES
|
||||
if family not in result.completed
|
||||
]
|
||||
if missing:
|
||||
fail("no_executed_bridge_tests", families=missing)
|
||||
if not result.wasSuccessful():
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
bridge_root = root / "bridge"
|
||||
bridge_tests = sorted(
|
||||
path for path in bridge_root.rglob("test_*.py") if path.is_file()
|
||||
)
|
||||
families = {path.relative_to(bridge_root).parts[0] for path in bridge_tests}
|
||||
missing_families = [
|
||||
family for family in REQUIRED_BRIDGE_FAMILIES if not family_tests(family)
|
||||
family for family in REQUIRED_BRIDGE_FAMILIES if family not in families
|
||||
]
|
||||
if missing_families:
|
||||
fail("missing_bridge_family", families=missing_families)
|
||||
bridge_tests = sorted(root.joinpath("bridge").rglob("test_*.py"))
|
||||
if not bridge_tests:
|
||||
fail("missing_bridge_tests")
|
||||
if not list(root.joinpath("tests/support/analysis-efficiency").glob("test_*.py")):
|
||||
fail("missing_analysis_efficiency_tests")
|
||||
modules = [
|
||||
".".join(path.relative_to(root).with_suffix("").parts) for path in bridge_tests
|
||||
]
|
||||
commands = [
|
||||
[sys.executable, "-m", "unittest", "-v", *modules],
|
||||
if sys.argv[1:] == ["--bridge"]:
|
||||
run_bridge(modules)
|
||||
raise SystemExit(0)
|
||||
if sys.argv[1:]:
|
||||
fail("invalid_python_test_arguments")
|
||||
bridge_result = subprocess.run(
|
||||
[sys.executable, __file__, "--bridge"], cwd=root, check=False
|
||||
)
|
||||
if bridge_result.returncode:
|
||||
sys.exit(bridge_result.returncode)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable, "-m", "unittest", "discover",
|
||||
"-s", "tests/support/analysis-efficiency", "-p", "test_*.py", "-v",
|
||||
],
|
||||
]
|
||||
for command in commands:
|
||||
result = subprocess.run(command, cwd=root, check=False)
|
||||
if result.returncode:
|
||||
sys.exit(result.returncode)
|
||||
cwd=root,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
sys.exit(result.returncode)
|
||||
@@ -1612,7 +1612,7 @@ const workflowFile = async (name: string): Promise<unknown> =>
|
||||
|
||||
const scopesSchema = z.object({
|
||||
CI_FLAGS: z.array(z.string()),
|
||||
SCOPES: z.record(z.string(), z.array(z.string())),
|
||||
CI_LANES: z.record(z.string(), z.object({ paths: z.array(z.string()) })),
|
||||
MANUAL_VERIFIERS: z.array(z.string()),
|
||||
});
|
||||
|
||||
@@ -1634,7 +1634,7 @@ const stepRuns = z.object({
|
||||
});
|
||||
|
||||
it("owns every explicit native-host test path with the lane that runs it", async () => {
|
||||
const { SCOPES } = await loadScopes();
|
||||
const { CI_LANES } = await loadScopes();
|
||||
const ci = stepRuns.parse(await workflowFile("ci.yml"));
|
||||
const listed = (job: string) =>
|
||||
(ci.jobs[job]?.steps ?? [])
|
||||
@@ -1643,7 +1643,9 @@ it("owns every explicit native-host test path with the lane that runs it", async
|
||||
const unowned = (job: string, scope: string) =>
|
||||
listed(job).filter(
|
||||
(path) =>
|
||||
!(SCOPES[scope] ?? []).some((pattern) => matchesGlob(path, pattern)),
|
||||
!(CI_LANES[scope]?.paths ?? []).some((pattern) =>
|
||||
matchesGlob(path, pattern),
|
||||
),
|
||||
);
|
||||
expect(listed("apple-artifacts").length).toBeGreaterThan(0);
|
||||
expect(listed("windows-curated").length).toBeGreaterThan(0);
|
||||
|
||||
@@ -729,3 +729,138 @@ it("fails fast only for ordinary PR matrices and rejects partial portable feedba
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("binds each canonical lane to its explicit fast and full workflow jobs", async () => {
|
||||
const { jobs, needs } = await requiredNeeds();
|
||||
const { CI_LANES } = await import(
|
||||
new URL("../../../scripts/ci/scopes.mjs", import.meta.url).href
|
||||
);
|
||||
// Independent contract: adding or changing a lane requires a workflow and
|
||||
// gate decision, rather than silently assuming that its job has the same name.
|
||||
const expected: Record<string, { fast: string[]; full: string[] }> = {
|
||||
code: {
|
||||
fast: ["build", "test-shard", "test-source"],
|
||||
full: ["build", "test-shard"],
|
||||
},
|
||||
runtime: {
|
||||
fast: ["windows-native-build", "package-e2e"],
|
||||
full: ["windows-native-build", "package-e2e"],
|
||||
},
|
||||
package: { fast: ["windows-curated"], full: ["windows-curated"] },
|
||||
apple: { fast: ["apple-artifacts"], full: ["apple-artifacts"] },
|
||||
fixtures: {
|
||||
fast: ["conformance-fixtures"],
|
||||
full: ["conformance-fixtures"],
|
||||
},
|
||||
readiness: {
|
||||
fast: ["reconstruction-readiness"],
|
||||
full: ["reconstruction-readiness"],
|
||||
},
|
||||
docs: { fast: ["docs"], full: ["docs"] },
|
||||
workflows: { fast: ["static"], full: ["static"] },
|
||||
python: { fast: ["python"], full: ["static-deep"] },
|
||||
reverse_tools: {
|
||||
fast: ["reverse-tools-quality"],
|
||||
full: ["reverse-tools-quality"],
|
||||
},
|
||||
sqlite: { fast: ["sqlite-runtime"], full: ["sqlite-runtime"] },
|
||||
...Object.fromEntries(
|
||||
[
|
||||
"packet_capture",
|
||||
"signal_recording",
|
||||
"browser",
|
||||
"binary_layout",
|
||||
"evm",
|
||||
"apk_signature",
|
||||
"javascript_recovery",
|
||||
"recorded_crash",
|
||||
"web_network",
|
||||
"web_runtime",
|
||||
"web_source_map",
|
||||
"agentenv",
|
||||
"nativeaot",
|
||||
"ghidra_bridge",
|
||||
"website",
|
||||
].map((name) => [name, { fast: [name], full: [name] }]),
|
||||
),
|
||||
};
|
||||
expect(Object.keys(CI_LANES).sort()).toEqual(Object.keys(expected).sort());
|
||||
for (const [scope, required] of Object.entries(expected)) {
|
||||
expect(CI_LANES[scope].required, scope).toEqual(required);
|
||||
for (const name of new Set([...required.fast, ...required.full])) {
|
||||
expect(needs, `${scope}: ${name}`).toContain(name);
|
||||
expect(jobs[name], `${scope}: ${name}`).toBeDefined();
|
||||
// Static and deep-static are shared gates; other mapped jobs are admitted
|
||||
// by their scope output in the workflow itself.
|
||||
if (name !== "static" && name !== "static-deep")
|
||||
expect(String(jobs[name]?.if), `${scope}: ${name}`).toContain(
|
||||
`needs.changes.outputs.${scope}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const executeGate = (context: Record<string, unknown>) =>
|
||||
executeWorkflowFixture(
|
||||
process.execPath,
|
||||
[
|
||||
fileURLToPath(
|
||||
new URL("../../../scripts/ci/required.mjs", import.meta.url),
|
||||
),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
CI_JOBS: JSON.stringify(context),
|
||||
CI_QUALIFICATION_DIRECTORY: "",
|
||||
},
|
||||
},
|
||||
);
|
||||
const flags = Object.keys(expected);
|
||||
for (const [scope, required] of Object.entries(expected)) {
|
||||
const context: Record<string, unknown> = Object.fromEntries(
|
||||
needs.map((name) => [name, { result: "skipped" }]),
|
||||
);
|
||||
context.changes = {
|
||||
result: "success",
|
||||
outputs: {
|
||||
...Object.fromEntries(flags.map((name) => [name, "false"])),
|
||||
tier: "fast",
|
||||
[scope]: "true",
|
||||
},
|
||||
};
|
||||
context.static = { result: "success" };
|
||||
context.test = { result: "success" };
|
||||
for (const name of required.fast) context[name] = { result: "success" };
|
||||
await expect(executeGate(context), scope).resolves.toMatchObject({
|
||||
stderr: "",
|
||||
});
|
||||
for (const name of required.fast) {
|
||||
await expect(
|
||||
executeGate({ ...context, [name]: { result: "skipped" } }),
|
||||
`${scope}: ${name}`,
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
const fullContext: Record<string, unknown> = Object.fromEntries(
|
||||
needs.map((name) => [name, { result: "success" }]),
|
||||
);
|
||||
fullContext.changes = {
|
||||
result: "success",
|
||||
outputs: {
|
||||
...Object.fromEntries(flags.map((name) => [name, "true"])),
|
||||
tier: "full",
|
||||
},
|
||||
};
|
||||
await expect(executeGate(fullContext)).resolves.toMatchObject({ stderr: "" });
|
||||
for (const name of new Set(
|
||||
Object.values(expected).flatMap(({ full }) => full),
|
||||
)) {
|
||||
await expect(
|
||||
executeGate({ ...fullContext, [name]: { result: "skipped" } }),
|
||||
name,
|
||||
).rejects.toMatchObject({ code: 1, stderr: expect.stringContaining(name) });
|
||||
}
|
||||
});
|
||||
@@ -733,7 +733,7 @@ it("records failed omissions from full reports without treating partial or unhan
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("discovers Python bridge tests and fails on a newly added failing producer test", async () => {
|
||||
it("requires executed Python cases in every bridge family", async () => {
|
||||
const root = await createTestTempDirectory("rea-python-inventory-");
|
||||
await mkdir(join(root, "scripts"), { recursive: true });
|
||||
await mkdir(join(root, "bridge", "example"), { recursive: true });
|
||||
@@ -764,6 +764,46 @@ it("discovers Python bridge tests and fails on a newly added failing producer te
|
||||
expect(discovered.stderr).toContain("bridge.native.test_case");
|
||||
expect(discovered.stderr).toContain("bridge.pwndbg.test_case");
|
||||
expect(discovered.stderr).toContain("OK");
|
||||
const native = join(root, "bridge", "native", "test_case.py");
|
||||
for (const content of [
|
||||
"# A present file can contain no tests.\n",
|
||||
"import unittest\nclass Example(unittest.TestCase):\n @unittest.skip('unavailable')\n def test_case(self): pass\n",
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self):\n for x in range(2):\n with self.subTest(x=x): self.skipTest('unavailable')\n",
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self):\n with self.subTest(outer=1):\n with self.subTest(inner=2): self.skipTest('unavailable')\n",
|
||||
]) {
|
||||
await writeFile(native, content);
|
||||
await expect(
|
||||
execute("python3", command, { cwd: root }),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining('"code": "no_executed_bridge_tests"'),
|
||||
});
|
||||
}
|
||||
await writeFile(
|
||||
native,
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self): pass\n @unittest.skip('optional')\n def test_skipped(self): pass\n",
|
||||
);
|
||||
expect((await execute("python3", command, { cwd: root })).stderr).toContain(
|
||||
"skipped 'optional'",
|
||||
);
|
||||
await writeFile(
|
||||
native,
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self):\n for x in range(2):\n with self.subTest(x=x):\n if x == 0: self.skipTest('optional')\n self.assertEqual(x, 1)\n",
|
||||
);
|
||||
expect((await execute("python3", command, { cwd: root })).stderr).toContain(
|
||||
"skipped 'optional'",
|
||||
);
|
||||
await writeFile(
|
||||
native,
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self):\n with self.subTest(value=1): self.fail('subtest failed')\n",
|
||||
);
|
||||
await expect(
|
||||
execute("python3", command, { cwd: root }),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining("subtest failed"),
|
||||
});
|
||||
await writeFile(native, passing);
|
||||
await writeFile(
|
||||
join(root, "bridge", "example", "test_second.py"),
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self): self.fail('new test executed')\n",
|
||||
@@ -780,7 +820,9 @@ it("discovers Python bridge tests and fails on a newly added failing producer te
|
||||
(error: { code?: unknown; stderr?: unknown }) => error,
|
||||
);
|
||||
expect(missingFamily).toMatchObject({ code: 1 });
|
||||
expect(JSON.parse(String(missingFamily?.stderr))).toEqual({
|
||||
expect(
|
||||
JSON.parse(String(missingFamily?.stderr).trim().split("\n").at(-1)!),
|
||||
).toEqual({
|
||||
code: "missing_bridge_family",
|
||||
families: ["mitmproxy"],
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user