Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
503 changes: 204 additions & 299 deletions .github/workflows/e2e.yaml

Large diffs are not rendered by default.

40 changes: 36 additions & 4 deletions scripts/checks/e2e-mock-parity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { stripExecutionProfileDeclarations } from "../../tools/e2e/execution-profile.mts";

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const DEFAULT_PARITY_MANIFEST = "test/e2e/mock-parity.json";

Expand All @@ -27,6 +29,15 @@ const FAST_TESTS = [
/^test\/e2e\/support\/.+\.test\.ts$/u,
/^test\/(?!e2e\/|package-contract\/).+\.test\.(?:js|ts)$/u,
] as const;
export function isMockParityRelevantSourceChange(
baseSource: string | null,
headSource: string | null,
): boolean {
if (baseSource === null || headSource === null) return true;
return (
stripExecutionProfileDeclarations(baseSource) !== stripExecutionProfileDeclarations(headSource)
);
}

function isSafeRepoPath(file: string): boolean {
return (
Expand Down Expand Up @@ -116,13 +127,34 @@ function argument(name: string): string | undefined {
return index >= 0 ? process.argv[index + 1] : undefined;
}

function sourceAtRef(ref: string, file: string): string | null {
try {
return execFileSync("git", ["show", `${ref}:${file}`], {
cwd: REPO_ROOT,
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
});
} catch {
return null;
}
}

function changedFiles(base: string, head: string): string[] {
return execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], {
cwd: REPO_ROOT,
encoding: "utf8",
})
const files = execFileSync(
"git",
["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`],
{
cwd: REPO_ROOT,
encoding: "utf8",
},
)
.split(/\r?\n/u)
.filter(Boolean);
return files.filter(
(file) =>
!LIVE_TEST.test(file) ||
isMockParityRelevantSourceChange(sourceAtRef(base, file), sourceAtRef(head, file)),
);
}

function main(): void {
Expand Down
61 changes: 61 additions & 0 deletions test/e2e-advisor-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,67 @@ describe("E2E target advisor — normalization contract", () => {
expect(normalized.noTargetE2eReason).toContain("test/e2e/live/rebuild-openclaw.test.ts");
});

it.each([
["test/e2e/live/new-hermetic-proof.test.ts", "new-hermetic-proof"],
["test/new-hermetic-integration.test.ts", "new-hermetic-integration"],
])("recognizes a source-declared profile on a newly added test (%s)", (file, id) => {
const normalized = normalizeE2eTargetAdvisorResult(
{
required: [
{
id: "e2e-all",
workflow: E2E_WORKFLOW,
selectorType: "all",
reason: "model requested fan-out",
},
],
optional: [],
confidence: "high",
},
metadata({ changedFiles: [file] }),
{
changedFileSources: {
[file]: "// @module-tag e2e-profile/hermetic\n",
},
e2eWorkflowText: "jobs:\n hermetic:\n steps: []\n",
},
);

expect(normalized.required.map((item) => item.id)).toContain(id);
expect(normalized.required.map((item) => item.id)).not.toContain("e2e-all");
expect(normalized.noTargetE2eReason).toBeNull();
});

it.each([
["has its profile tag removed", "// profile removed\n"],
["is deleted", null],
])("treats the analyzed change as authoritative when a tagged test %s", (_case, source) => {
const file = "test/e2e/live/docs-validation.test.ts";
const normalized = normalizeE2eTargetAdvisorResult(
{
required: [
{
id: "e2e-all",
workflow: E2E_WORKFLOW,
selectorType: "all",
reason: "model requested fan-out",
},
],
optional: [],
confidence: "high",
},
metadata({ changedFiles: [file] }),
{
changedFileSources: { [file]: source },
e2eWorkflowText: "jobs:\n hermetic:\n steps: []\n",
},
);

expect(normalized.required.map((item) => item.id)).not.toContain("docs-validation");
expect(normalized.required.map((item) => item.id)).not.toContain("e2e-all");
expect(normalized.noTargetE2eReason).toContain(file);
});

it("keeps the deterministic floor while suppressing unwired-test fan-out", () => {
const normalized = normalizeE2eTargetAdvisorResult(
{
Expand Down
36 changes: 35 additions & 1 deletion test/e2e-mock-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,51 @@
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";
import { type MockParityManifest, validateMockParity } from "../scripts/checks/e2e-mock-parity";
import {
isMockParityRelevantSourceChange,
type MockParityManifest,
validateMockParity,
} from "../scripts/checks/e2e-mock-parity";

const live = "test/e2e/live/example.test.ts";
const fast = "test/e2e/support/example.test.ts";
const TAGGED_NEW_SOURCE = "// @module-tag e2e-profile/hermetic\n";
const exists = (file: string) => file === live || file === fast;

function manifest(entries: MockParityManifest["entries"]): MockParityManifest {
return { version: 1, entries };
}

describe("changed live E2E mock parity", () => {
it("treats an execution-profile declaration-only diff as metadata", () => {
expect(
isMockParityRelevantSourceChange(
"// SPDX-License-Identifier: Apache-2.0\n\nexport {};\n",
"// SPDX-License-Identifier: Apache-2.0\n// @module-tag e2e-profile/hermetic\n\nexport {};\n",
),
).toBe(false);
expect(
isMockParityRelevantSourceChange(
"// @module-tag e2e-profile/hermetic\n\nexport {};\n",
"// @module-tag e2e-profile/hermetic\n\nexport const changed = true;\n",
),
).toBe(true);
expect(
isMockParityRelevantSourceChange(
"export const fixture = `before\nafter`;\n",
"export const fixture = `before\n// @module-tag e2e-profile/hermetic\nafter`;\n",
),
).toBe(true);
expect(
isMockParityRelevantSourceChange(
"// SPDX-License-Identifier: Apache-2.0\n\nexport {};\n",
"// SPDX-License-Identifier: Apache-2.0\n/* @module-tag e2e-profile/hermetic */\n\nexport {};\n",
),
).toBe(false);
expect(isMockParityRelevantSourceChange(null, null)).toBe(true);
expect(isMockParityRelevantSourceChange(null, TAGGED_NEW_SOURCE)).toBe(true);
});

it("accepts a changed live E2E mapped to a fast PR test", () => {
expect(
validateMockParity({
Expand Down
27 changes: 27 additions & 0 deletions test/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,33 @@ The former top-level `test/e2e/test-*.sh` suite has been removed. Keep real
shell, installer, process, Docker, OpenShell, `/proc`, and sandbox boundaries in
E2E tests when those boundaries are the behavior under test.

## Hermetic execution profile

Credential-free tests that can use the standard Ubuntu runner, CLI build, and
artifact policy opt into the `hermetic` execution profile beside the test:

```typescript
// @module-tag e2e-profile/hermetic
```

Discovery reads tagged files from the `e2e-live` and `integration` Vitest
projects. It derives each logical test ID from the filename and supplies only
the ID, repository-relative file, and Vitest project to the workflow. Keep the
filename stem unique and lowercase kebab-case. Do not add the test to a separate
catalog or workflow matrix.

The trusted workflow owns the runner, timeout, setup, permissions, secrets, and
artifact handling for the profile. Keep a dedicated workflow job when a test
needs different capabilities, such as credentials, a custom runner, additional
setup, or a different timeout.

Both `jobs` and `targets` selectors continue to accept the logical test ID. Run
the discovery command locally to inspect the generated matrix:

```bash
npx tsx tools/e2e/execution-profile.mts
```

## Scheduled operations

The consolidated workflow keeps its operational reporting in the same job
Expand Down
1 change: 1 addition & 0 deletions test/e2e/live/docs-validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// @module-tag e2e-profile/hermetic

import fs from "node:fs";
import fsp from "node:fs/promises";
Expand Down
1 change: 1 addition & 0 deletions test/e2e/live/onboard-negative-paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// @module-tag e2e-profile/hermetic

import fs from "node:fs";
import path from "node:path";
Expand Down
1 change: 1 addition & 0 deletions test/e2e/live/openshell-version-pin.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// @module-tag e2e-profile/hermetic

import { spawnSync } from "node:child_process";
import fs from "node:fs";
Expand Down
1 change: 1 addition & 0 deletions test/e2e/live/ubuntu-repo-cli-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// @module-tag e2e-profile/hermetic

import fs from "node:fs";
import path from "node:path";
Expand Down
8 changes: 1 addition & 7 deletions test/e2e/support/dockerhub-auth-workflow-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,7 @@ import YAML from "yaml";
import { validateE2eWorkflowBoundary } from "../../../tools/e2e/workflow-boundary.mts";
import { readWorkflow } from "../../helpers/e2e-workflow-contract";

const NO_IMAGE_E2E_JOBS = [
"docs-validation",
"gateway-drift-preflight",
"gateway-health-honest",
"onboard-negative-paths",
"openshell-version-pin",
] as const;
const NO_IMAGE_E2E_JOBS = ["gateway-health-honest", "hermetic"] as const;
const AUTH_STEP_NAME = "Authenticate to Docker Hub";
const CLEANUP_STEP_NAME = "Clean up Docker auth";
const CLEANUP_HELPER_RUN = "bash .github/scripts/docker-auth-cleanup.sh";
Expand Down
78 changes: 0 additions & 78 deletions test/e2e/support/docs-validation-workflow-boundary.test.ts

This file was deleted.

7 changes: 5 additions & 2 deletions test/e2e/support/e2e-operations-workflow-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ describe("E2E operations workflow boundary", () => {
const workflow = readE2eOperationsWorkflow();
const reportNeeds = workflow.jobs["report-to-pr"].needs as string[];
expect(workflow.jobs["notify-on-failure"]).toBeUndefined();
expect(workflow.jobs["report-to-pr"].permissions).toEqual({ "pull-requests": "write" });
expect(workflow.jobs["report-to-pr"].permissions).toEqual({
actions: "read",
"pull-requests": "write",
});
expect(workflow.jobs.scorecard.needs).toEqual(reportNeeds);
});

Expand Down Expand Up @@ -149,7 +152,7 @@ describe("E2E operations workflow boundary", () => {
"cloud-onboard must not hold issues: write",
"cloud-onboard must not hold pull-requests: write",
"report-to-pr must not hold issues: write",
"report-to-pr must hold only pull-requests: write",
"report-to-pr must hold only actions: read and pull-requests: write",
"report-to-pr must run only for manual workflow dispatches",
"report-to-pr must contain only its PR-comment step",
"report-to-pr must not use issue mutations or generic GitHub write surfaces",
Expand Down
Loading
Loading