Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
554 changes: 229 additions & 325 deletions .github/workflows/e2e.yaml

Large diffs are not rendered by default.

62 changes: 58 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 ts from "typescript";

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

Expand All @@ -28,6 +30,37 @@ const FAST_TESTS = [
/^test\/(?!e2e\/|package-contract\/).+\.test\.(?:js|ts)$/u,
] as const;

function sourceTokens(source: string): string {
const sourceFile = ts.createSourceFile(
"source.ts",
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const tokens: Array<[ts.SyntaxKind, string]> = [];
const visit = (node: ts.Node): void => {
const children = node.getChildren(sourceFile);
if (children.length === 0) {
if (node.kind !== ts.SyntaxKind.EndOfFileToken) {
tokens.push([node.kind, node.getText(sourceFile)]);
}
return;
}
for (const child of children) visit(child);
};
visit(sourceFile);
return JSON.stringify(tokens);
}

export function isMockParityRelevantSourceChange(
baseSource: string | null,
headSource: string | null,
): boolean {
if (baseSource === null || headSource === null) return true;
return sourceTokens(baseSource) !== sourceTokens(headSource);
}

function isSafeRepoPath(file: string): boolean {
return (
file.length > 0 &&
Expand Down Expand Up @@ -116,13 +149,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
63 changes: 62 additions & 1 deletion test/e2e-advisor-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ describe("E2E target advisor — normalization contract", () => {
]);
});

it("suppresses fan-out for a new free-standing live test that is not workflow-wired", () => {
it("suppresses fan-out for a new E2E test that is not workflow-wired", () => {
const normalized = normalizeE2eTargetAdvisorResult(
{
required: [
Expand All @@ -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-credential-free-proof.test.ts", "new-credential-free-proof"],
["test/new-credential-free-integration.test.ts", "new-credential-free-integration"],
])("recognizes a credential-free tag 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/credential-free\n",
},
e2eWorkflowText: "jobs:\n shared-e2e:\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 credential-free tag removed", "// tag 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 shared-e2e:\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
48 changes: 47 additions & 1 deletion test/e2e-mock-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,63 @@
// 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/credential-free\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 module-tag-only diffs as metadata", () => {
expect(
isMockParityRelevantSourceChange(
"// SPDX-License-Identifier: Apache-2.0\n\nexport {};\n",
"// SPDX-License-Identifier: Apache-2.0\n// @module-tag e2e/credential-free\n\nexport {};\n",
),
).toBe(false);
expect(
isMockParityRelevantSourceChange(
`${"// @module"}-tag retired/value\n\nexport {};\n`,
"// @module-tag e2e/credential-free\n\nexport {};\n",
),
).toBe(false);
expect(
isMockParityRelevantSourceChange(
"// old terminology\nexport {};\n",
"// current terminology\nexport {};\n",
),
).toBe(false);
expect(
isMockParityRelevantSourceChange(
"// @module-tag e2e/credential-free\n\nexport {};\n",
"// @module-tag e2e/credential-free\n\nexport const changed = true;\n",
),
).toBe(true);
expect(
isMockParityRelevantSourceChange(
"export const fixture = `before\nafter`;\n",
"export const fixture = `before\n// @module-tag e2e/credential-free\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/credential-free */\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.

## Credential-free tests

Credential-free tests that can use the standard Ubuntu runner, CLI build, and
artifact policy opt into the shared E2E job with a tag beside the test:

```typescript
// @module-tag e2e/credential-free
```

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

The E2E workflow owns the shared job's runner, timeout, setup, permissions,
secrets, and artifact handling. 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 test ID. Run the
discovery command locally to inspect the generated test matrix:

```bash
npx tsx tools/e2e/credential-free-tests.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/credential-free

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/credential-free

import fs from "node:fs";
import path from "node:path";
Expand Down
7 changes: 4 additions & 3 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/credential-free

import { spawnSync } from "node:child_process";
import fs from "node:fs";
Expand All @@ -10,12 +11,12 @@ import { type ArtifactSink } from "../fixtures/artifacts.ts";
import { expect, test } from "../fixtures/e2e-test.ts";
import { REPO_ROOT } from "../fixtures/paths.ts";

// #3474). The former bash script is a hermetic installer-script behavioral
// #3474). The former bash script is a self-contained installer-script behavioral
// test: it runs scripts/install-openshell.sh under a stubbed PATH where the
// already-installed openshell reports a too-new version (0.0.73) and the
// downloaded archives produce a binary that reports the pinned 0.0.72.
//
// This is a free-standing live test (per #5049's pattern) — it does not exercise
// This credential-free E2E test does not exercise
// the registry-driven steady-state probe model. There is no OpenClaw instance,
// no environment phase, no lifecycle. The test consumes only the `artifacts`
// fixture from e2e-test.ts so failures attach the per-target artifact root.
Expand Down Expand Up @@ -98,7 +99,7 @@ function writeExecutable(target: string, contents: string): void {

// Bash helpers shared by the gh and curl stubs: write a fake archive and emit
// the same pinned digest lines the real OpenShell v0.0.72 release uses. A fake
// sha256sum below keeps this test hermetic even though the tarball bytes are
// sha256sum below keeps this test self-contained even though the tarball bytes are
// synthetic.
const SHARED_DOWNLOAD_BASH_HELPERS = `\
write_asset() {
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/credential-free

import fs from "node:fs";
import path from "node:path";
Expand Down
Loading
Loading