Skip to content

Commit 1f0f34e

Browse files
jamesadevineCopilot
andcommitted
test(trigger-e2e): add deterministic gate/synth-PR trigger-condition E2E suite
Add an ADO-native, non-agentic E2E test framework for the runtime trigger conditions managed by ado-script (gate.js PR filters and exec-context-pr-synth.js synthetic-PR promotion), modeled on the executor-e2e native bundle. A parameterized victim pipeline runs only the two bundles and reports the decision via REST-observable build tags; an orchestrator harness creates real PRs and queues the victim many ways, asserting the gate decision. Test-only bundle: excluded from ado-script.zip exactly like executor-e2e. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f30758a5-81c8-4024-ab01-52ad22b4f877
1 parent c005a13 commit 1f0f34e

20 files changed

Lines changed: 2167 additions & 7 deletions

File tree

scripts/ado-script/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"build:github-app-token": "ncc build src/github-app-token/index.ts -o .ado-build/github-app-token -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/github-app-token/index.js','github-app-token.js'); fs.rmSync('.ado-build/github-app-token',{recursive:true,force:true});\"",
2626
"build:prepare-pr-base": "ncc build src/prepare-pr-base/index.ts -o .ado-build/prepare-pr-base -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/prepare-pr-base/index.js','prepare-pr-base.js'); fs.rmSync('.ado-build/prepare-pr-base',{recursive:true,force:true});\"",
2727
"build:executor-e2e": "ncc build src/executor-e2e/index.ts -o .ado-build/executor-e2e -m -t && node -e \"const fs=require('node:fs'); fs.mkdirSync('test-bin',{recursive:true}); fs.copyFileSync('.ado-build/executor-e2e/index.js','test-bin/executor-e2e.js'); fs.rmSync('.ado-build/executor-e2e',{recursive:true,force:true});\"",
28+
"build:trigger-e2e": "ncc build src/trigger-e2e/index.ts -o .ado-build/trigger-e2e -m -t && node -e \"const fs=require('node:fs'); fs.mkdirSync('test-bin',{recursive:true}); fs.copyFileSync('.ado-build/trigger-e2e/index.js','test-bin/trigger-e2e.js'); fs.rmSync('.ado-build/trigger-e2e',{recursive:true,force:true});\"",
2829
"build:check": "ls -lh gate.js && wc -c gate.js",
2930
"codegen": "node -e \"require('node:fs').mkdirSync('schema', { recursive: true })\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-gate-schema --output schema/gate-spec.schema.json && npx json2ts schema/gate-spec.schema.json -o src/shared/types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust IR via cargo run -- export-gate-schema. Do not edit; run npm run codegen.\"",
3031
"test": "vitest run",

scripts/ado-script/src/__tests__/bundle-coverage.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@ const packageJsonPath = join(here, "..", "..", "package.json");
3030
* so the release glob (`ado-script/*.js`) never packages it — it is a test
3131
* harness run only by the executor-e2e pipeline, never downloaded by compiled
3232
* agentic pipelines at runtime.
33+
*
34+
* `trigger-e2e` is the analogous deterministic trigger-condition (gate /
35+
* synth-PR) E2E harness. Same treatment: its own `build:trigger-e2e` emits to
36+
* `test-bin/`, so it is never packaged in `ado-script.zip`.
3337
*/
34-
const NON_BUNDLE_DIRS = new Set(["shared", "__tests__", "executor-e2e"]);
38+
const NON_BUNDLE_DIRS = new Set(["shared", "__tests__", "executor-e2e", "trigger-e2e"]);
3539

3640
function listBundleDirs(): string[] {
3741
return readdirSync(srcDir)

scripts/ado-script/src/executor-e2e/ado-rest.ts

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,18 +264,21 @@ export class AdoRest {
264264
targetRef: string,
265265
title: string,
266266
description: string,
267+
isDraft?: boolean,
267268
): Promise<{ pullRequestId: number }> {
268269
const path = this.projPath(
269270
`_apis/git/repositories/${AdoRest.seg(repo)}/pullrequests?api-version=7.1`,
270271
);
272+
const body: Record<string, unknown> = {
273+
sourceRefName: sourceRef.startsWith("refs/") ? sourceRef : `refs/heads/${sourceRef}`,
274+
targetRefName: targetRef.startsWith("refs/") ? targetRef : `refs/heads/${targetRef}`,
275+
title,
276+
description,
277+
};
278+
if (isDraft !== undefined) body.isDraft = isDraft;
271279
const res = await this.request<{ pullRequestId: number }>(path, {
272280
method: "POST",
273-
body: {
274-
sourceRefName: sourceRef.startsWith("refs/") ? sourceRef : `refs/heads/${sourceRef}`,
275-
targetRefName: targetRef.startsWith("refs/") ? targetRef : `refs/heads/${targetRef}`,
276-
title,
277-
description,
278-
},
281+
body,
279282
});
280283
if (!res) throw new Error("createPullRequest returned no body");
281284
return res;
@@ -365,6 +368,20 @@ export class AdoRest {
365368
await this.request(path, { method: "PATCH", body: { status: "abandoned" }, allow404: true });
366369
}
367370

371+
/**
372+
* Attach one or more labels (tags) to a PR. ADO's label endpoint takes a
373+
* single `{ name }` per POST, so this loops. Used by the trigger E2E harness
374+
* to exercise the gate's `label_set_match` predicate against real PR labels.
375+
*/
376+
async setPullRequestLabels(repo: string, prId: number, labels: string[]): Promise<void> {
377+
for (const name of labels) {
378+
const path = this.projPath(
379+
`_apis/git/repositories/${AdoRest.seg(repo)}/pullRequests/${prId}/labels?api-version=7.1`,
380+
);
381+
await this.request(path, { method: "POST", body: { name } });
382+
}
383+
}
384+
368385
// ---- Wiki -------------------------------------------------------------
369386

370387
async listWikis(): Promise<{ name: string; id: string; type?: string }[]> {
@@ -451,4 +468,62 @@ export class AdoRest {
451468
const path = this.projPath(`_apis/build/builds/${buildId}?api-version=7.1`);
452469
await this.request(path, { method: "PATCH", body: { status: "cancelling" }, allow404: true });
453470
}
471+
472+
/**
473+
* Queue a new build of a registered definition. `sourceBranch` selects the
474+
* branch to build (used by the trigger E2E harness to point a queued build
475+
* at a PR's source branch so `exec-context-pr-synth` can discover the open
476+
* PR). `templateParameters` supplies the victim pipeline's runtime
477+
* parameters (e.g. the base64 GATE_SPEC / PR_SYNTH_SPEC under test).
478+
*
479+
* Returns the new build id. Note: a build queued via this REST call has
480+
* `Build.Reason = Manual`; the victim relies on the synthetic-PR flag (set
481+
* by `exec-context-pr-synth` from a real open PR) — not the build reason —
482+
* to drive full PR-gate evaluation.
483+
*/
484+
async queueBuild(
485+
definitionId: number,
486+
opts: { sourceBranch?: string; templateParameters?: Record<string, string> } = {},
487+
): Promise<{ id: number }> {
488+
const path = this.projPath(`_apis/build/builds?api-version=7.1`);
489+
const body: Record<string, unknown> = { definition: { id: definitionId } };
490+
if (opts.sourceBranch) {
491+
body.sourceBranch = opts.sourceBranch.startsWith("refs/")
492+
? opts.sourceBranch
493+
: `refs/heads/${opts.sourceBranch}`;
494+
}
495+
if (opts.templateParameters && Object.keys(opts.templateParameters).length > 0) {
496+
body.templateParameters = opts.templateParameters;
497+
}
498+
const res = await this.request<{ id: number }>(path, { method: "POST", body });
499+
if (!res) throw new Error(`queueBuild(${definitionId}) returned no body`);
500+
return res;
501+
}
502+
503+
/**
504+
* Poll a build until it reaches `status === "completed"` (or the timeout
505+
* elapses). Returns the terminal `{ status, result }`. `result` is one of
506+
* `succeeded` | `partiallySucceeded` | `failed` | `canceled`.
507+
*/
508+
async waitForBuild(
509+
buildId: number,
510+
opts: { timeoutMs?: number; pollMs?: number } = {},
511+
): Promise<{ status: string; result?: string }> {
512+
const timeoutMs =
513+
opts.timeoutMs ?? (Number(process.env.TRIGGER_E2E_BUILD_TIMEOUT_MS) || 900_000);
514+
const pollMs = opts.pollMs ?? (Number(process.env.TRIGGER_E2E_BUILD_POLL_MS) || 10_000);
515+
const deadline = Date.now() + timeoutMs;
516+
for (;;) {
517+
const build = await this.getBuild(buildId);
518+
if (build.status === "completed") {
519+
return { status: build.status, result: build.result };
520+
}
521+
if (Date.now() >= deadline) {
522+
throw new Error(
523+
`waitForBuild(${buildId}) timed out after ${timeoutMs}ms (last status='${build.status ?? "?"}')`,
524+
);
525+
}
526+
await new Promise((r) => setTimeout(r, pollMs));
527+
}
528+
}
454529
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
buildGateSpec,
5+
changeCountCheck,
6+
changedFilesCheck,
7+
draftCheck,
8+
encodeGateSpec,
9+
encodePrSynthSpec,
10+
labelsCheck,
11+
targetBranchCheck,
12+
titleCheck,
13+
} from "../gate-spec.js";
14+
15+
const factKinds = (checks: Parameters<typeof buildGateSpec>[1]) =>
16+
buildGateSpec("pull-request", checks).facts.map((f) => f.kind);
17+
18+
describe("buildGateSpec fact derivation", () => {
19+
it("emits no facts and no checks for an empty gate", () => {
20+
const spec = buildGateSpec("pull-request", []);
21+
expect(spec.facts).toEqual([]);
22+
expect(spec.checks).toEqual([]);
23+
expect(spec.context.tag_prefix).toBe("pr-gate");
24+
expect(spec.context.build_reason).toBe("PullRequest");
25+
expect(spec.context.bypass_label).toBe("PR");
26+
});
27+
28+
it("derives pr_metadata (skip_dependents) before pr_labels (fail_open) for a labels check", () => {
29+
const spec = buildGateSpec("pull-request", [labelsCheck({ anyOf: ["run-agent"] })]);
30+
const kinds = spec.facts.map((f) => f.kind);
31+
expect(kinds).toEqual(["pr_metadata", "pr_labels"]);
32+
const meta = Object.fromEntries(spec.facts.map((f) => [f.kind, f]));
33+
expect(meta.pr_metadata!.failure_policy).toBe("skip_dependents");
34+
expect(meta.pr_metadata!.dependencies).toEqual([]);
35+
expect(meta.pr_labels!.failure_policy).toBe("fail_open");
36+
expect(meta.pr_labels!.dependencies).toEqual(["pr_metadata"]);
37+
});
38+
39+
it("derives pr_metadata before pr_is_draft (fail_closed) for a draft check", () => {
40+
expect(factKinds([draftCheck(true)])).toEqual(["pr_metadata", "pr_is_draft"]);
41+
});
42+
43+
it("derives changed_files before changed_file_count for a change-count check", () => {
44+
expect(factKinds([changeCountCheck({ min: 5 })])).toEqual([
45+
"changed_files",
46+
"changed_file_count",
47+
]);
48+
});
49+
50+
it("derives only changed_files for a changed-files check", () => {
51+
expect(factKinds([changedFilesCheck({ include: ["src/**"] })])).toEqual(["changed_files"]);
52+
});
53+
54+
it("derives a single fail_closed pipeline-var fact for a title check", () => {
55+
const spec = buildGateSpec("pull-request", [titleCheck("release *")]);
56+
expect(spec.facts).toEqual([
57+
{ kind: "pr_title", failure_policy: "fail_closed", dependencies: [] },
58+
]);
59+
});
60+
61+
it("dedupes facts shared across multiple checks", () => {
62+
const kinds = factKinds([draftCheck(true), labelsCheck({ anyOf: ["x"] })]);
63+
// pr_metadata appears once, before both dependents, in enum order.
64+
expect(kinds).toEqual(["pr_metadata", "pr_is_draft", "pr_labels"]);
65+
});
66+
});
67+
68+
describe("check tag suffixes match the Rust FilterCheck::build_tag_suffix", () => {
69+
it("emits the expected suffixes", () => {
70+
const cases: [Parameters<typeof buildGateSpec>[1][number], string][] = [
71+
[titleCheck("x"), "title-mismatch"],
72+
[labelsCheck({ anyOf: ["x"] }), "labels-mismatch"],
73+
[changedFilesCheck({ include: ["src/**"] }), "changed-files-mismatch"],
74+
[draftCheck(true), "draft-mismatch"],
75+
[targetBranchCheck("main"), "target-branch-mismatch"],
76+
[changeCountCheck({ min: 1 }), "changes-mismatch"],
77+
];
78+
for (const [check, suffix] of cases) {
79+
const spec = buildGateSpec("pull-request", [check]);
80+
expect(spec.checks[0]!.tag_suffix).toBe(suffix);
81+
}
82+
});
83+
});
84+
85+
describe("encoding", () => {
86+
it("round-trips a gate spec through base64", () => {
87+
const spec = buildGateSpec("pull-request", [labelsCheck({ anyOf: ["run-agent"] })]);
88+
const decoded = JSON.parse(Buffer.from(encodeGateSpec(spec), "base64").toString("utf8"));
89+
expect(decoded).toEqual(spec);
90+
});
91+
92+
it("produces the canonical empty PR_SYNTH_SPEC base64", () => {
93+
expect(encodePrSynthSpec()).toBe(
94+
"eyJicmFuY2hlcyI6eyJpbmNsdWRlIjpbXSwiZXhjbHVkZSI6W119LCJwYXRocyI6eyJpbmNsdWRlIjpbXSwiZXhjbHVkZSI6W119fQ==",
95+
);
96+
});
97+
98+
it("encodes provided branch/path filters", () => {
99+
const decoded = JSON.parse(
100+
Buffer.from(
101+
encodePrSynthSpec({ branches: { include: ["main"] }, paths: { exclude: ["docs/**"] } }),
102+
"base64",
103+
).toString("utf8"),
104+
);
105+
expect(decoded).toEqual({
106+
branches: { include: ["main"], exclude: [] },
107+
paths: { include: [], exclude: ["docs/**"] },
108+
});
109+
});
110+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import type { AdoRest } from "../../executor-e2e/ado-rest.js";
4+
import { runScenario } from "../runner.js";
5+
import { SkipError } from "../scenario.js";
6+
import type { TriggerContext, TriggerScenario } from "../scenario.js";
7+
8+
interface FakeBuild {
9+
result?: string;
10+
tags?: string[];
11+
}
12+
13+
function makeCtx(build: FakeBuild): TriggerContext {
14+
const rest = {
15+
queueBuild: vi.fn().mockResolvedValue({ id: 42 }),
16+
waitForBuild: vi.fn().mockResolvedValue({ status: "completed", result: build.result }),
17+
getBuildTags: vi.fn().mockResolvedValue(build.tags ?? []),
18+
} as unknown as AdoRest;
19+
return {
20+
orgUrl: "https://dev.azure.com/org/",
21+
project: "proj",
22+
adoRepo: "repo",
23+
buildId: "1",
24+
token: "t",
25+
victimDefinitionId: 7,
26+
rest,
27+
log: () => {},
28+
prefix: (id) => `ado-aw-trig-1-${id}`,
29+
};
30+
}
31+
32+
function scenario(overrides: Partial<TriggerScenario<string>>): TriggerScenario<string> {
33+
return {
34+
id: "s",
35+
description: "test scenario",
36+
setup: async () => "state",
37+
queue: () => ({ templateParameters: { gateSpec: "x" } }),
38+
expected: () => ({ result: "succeeded" }),
39+
cleanup: async () => {},
40+
...overrides,
41+
};
42+
}
43+
44+
describe("runScenario", () => {
45+
it("passes when result and tags match", async () => {
46+
const ctx = makeCtx({ result: "succeeded", tags: ["trig.should-run.true"] });
47+
const res = await runScenario(
48+
ctx,
49+
scenario({ expected: () => ({ result: "succeeded", tags: ["trig.should-run.true"] }) }),
50+
);
51+
expect(res.ok).toBe(true);
52+
});
53+
54+
it("fails (assert) when a required tag is missing", async () => {
55+
const ctx = makeCtx({ result: "succeeded", tags: [] });
56+
const res = await runScenario(ctx, scenario({ expected: () => ({ tags: ["pr-gate.skipped"] }) }));
57+
expect(res.ok).toBe(false);
58+
expect(res.phase).toBe("assert");
59+
expect(res.message).toContain("pr-gate.skipped");
60+
});
61+
62+
it("fails (assert) on the wrong build result", async () => {
63+
const ctx = makeCtx({ result: "canceled" });
64+
const res = await runScenario(ctx, scenario({ expected: () => ({ result: "succeeded" }) }));
65+
expect(res.ok).toBe(false);
66+
expect(res.phase).toBe("assert");
67+
});
68+
69+
it("fails (assert) when a forbidden tag is present", async () => {
70+
const ctx = makeCtx({ result: "succeeded", tags: ["pr-gate.skipped"] });
71+
const res = await runScenario(
72+
ctx,
73+
scenario({ expected: () => ({ absentTags: ["pr-gate.skipped"] }) }),
74+
);
75+
expect(res.ok).toBe(false);
76+
expect(res.phase).toBe("assert");
77+
});
78+
79+
it("records a SkipError from setup as skipped and does NOT run cleanup", async () => {
80+
const ctx = makeCtx({ result: "succeeded" });
81+
const cleanup = vi.fn().mockResolvedValue(undefined);
82+
const res = await runScenario(
83+
ctx,
84+
scenario({
85+
setup: async () => {
86+
throw new SkipError("no repo");
87+
},
88+
cleanup,
89+
}),
90+
);
91+
expect(res.skipped).toBe(true);
92+
expect(res.ok).toBe(true);
93+
expect(cleanup).not.toHaveBeenCalled();
94+
});
95+
96+
it("runs cleanup after a successful setup even when assertion fails", async () => {
97+
const ctx = makeCtx({ result: "failed" });
98+
const cleanup = vi.fn().mockResolvedValue(undefined);
99+
const res = await runScenario(ctx, scenario({ cleanup }));
100+
expect(res.ok).toBe(false);
101+
expect(cleanup).toHaveBeenCalledOnce();
102+
});
103+
104+
it("honours a custom assert hook", async () => {
105+
const ctx = makeCtx({ result: "succeeded" });
106+
const res = await runScenario(
107+
ctx,
108+
scenario({
109+
assert: async () => {
110+
throw new Error("custom boom");
111+
},
112+
}),
113+
);
114+
expect(res.ok).toBe(false);
115+
expect(res.message).toContain("custom boom");
116+
});
117+
});

0 commit comments

Comments
 (0)