Skip to content
Closed
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
1 change: 1 addition & 0 deletions .github/workflows/e2e-scenarios.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ jobs:
[ubuntu-repo-cloud-openclaw-slack]=ubuntu-latest
[ubuntu-repo-cloud-openclaw-telegram]=ubuntu-latest
[ubuntu-repo-cloud-openclaw-token-rotation]=ubuntu-latest
[ubuntu-repo-docker-post-reboot-recovery]=ubuntu-latest
[ubuntu-repo-openai-compatible-openclaw]=ubuntu-latest
)
selected=""
Expand Down
7 changes: 6 additions & 1 deletion src/commands/sandbox/agents/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ export default class SandboxAgentsListCommand extends NemoClawCommand {
public async run(): Promise<void> {
this.parsed = true;
const [sandboxName, ...extraArgs] = this.argv;
if (!sandboxName || sandboxName.trim() === "" || sandboxName === "--help" || sandboxName === "-h") {
if (
!sandboxName ||
sandboxName.trim() === "" ||
sandboxName === "--help" ||
sandboxName === "-h"
) {
printAgentsPassthroughHelp("list");
return;
}
Expand Down
14 changes: 9 additions & 5 deletions test/e2e-scenario/docs/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,22 @@ that owns the work instead.

The one repo-local exception is the machine-readable deletion gate inventory at
`test/e2e-scenario/migration/legacy-inventory.json`. Keep that file focused on
script-level migration state that prevents accidental legacy E2E deletion. It
deletion-readiness evidence that prevents accidental legacy E2E deletion. It
must cover every direct legacy shell entrypoint under `test/e2e/test-*.sh`,
plus any explicitly retained bridge entrypoints such as Brev. It is not a
progress dashboard or owner queue:
plus any explicitly retained bridge entrypoints such as Brev. It also tracks
coarse internal legacy runner surfaces such as the YAML/bash scenario workers,
validation suites, TypeScript shell-runner orchestrators, and runtime helper
libraries so those surfaces cannot be removed without #4357 evidence. It is not
a progress dashboard or owner queue:

- `not-migrated`: legacy coverage still has no equivalent Vitest scenario.
- `bridge-probe`: coverage is temporarily represented by a bridge path.
- `covered`: equivalent Vitest live scenario coverage exists.
- `retired`: maintainers agreed the legacy coverage is no longer required.

Do not set `deletionReady: true` unless the entry is `covered` or `retired` and
the deletion approval is recorded through #4357.
Do not set `deletionReady: true` on a script entry or internal surface unless
the record is `covered` or `retired` and the deletion approval is recorded
through #4357.

After #4357 completes final legacy E2E reconciliation, remove the inventory if
there are no remaining legacy entrypoints to guard. If maintainers keep it, keep
Expand Down
9 changes: 6 additions & 3 deletions test/e2e-scenario/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,12 @@ is tracked in #4941.
The narrow repo-local exception is
`test/e2e-scenario/migration/legacy-inventory.json`, a machine-readable deletion
gate for direct legacy `test/e2e/test-*.sh` entrypoints and explicit bridge
entrypoints. It should prevent accidental deletions, not become a parallel
status table. Remove it after #4357 completes final legacy E2E reconciliation,
or keep it only as an audit artifact if maintainers still need that record.
entrypoints. It also tracks coarse internal legacy runner surfaces such as
scenario shell workers, validation suites, shell-runner orchestrators, and
runtime helper libraries so they cannot be removed without #4357 evidence. It
should prevent accidental deletions, not become a parallel status table. Remove
it after #4357 completes final legacy E2E reconciliation, or keep it only as an
audit artifact if maintainers still need that record.

The old workflow-level parity report has been removed. Use scenario framework
tests, the coverage report, PR review, and the audit issues to decide what to
Expand Down
29 changes: 29 additions & 0 deletions test/e2e-scenario/framework-tests/e2e-expected-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,35 @@ describe("probesForState maps typed expected-state into probe ids", () => {
};
expect(probesForState(state)).toEqual([]);
});

it("localRegistry.expected=present emits the local-registry-entry-present probe", () => {
const state: ExpectedState = {
id: "synthetic-local-registry",
cli: { installed: true },
localRegistry: { expected: "present" },
};
expect(probesForState(state)).toEqual(["cli-installed", "local-registry-entry-present"]);
});

it("dockerSandboxContainer.expected=present emits the docker-sandbox-container-present probe", () => {
const state: ExpectedState = {
id: "synthetic-docker-container",
cli: { installed: true },
dockerSandboxContainer: { expected: "present" },
};
expect(probesForState(state)).toEqual(["cli-installed", "docker-sandbox-container-present"]);
});

it("localRegistry/dockerSandboxContainer 'absent' emits no probe today", () => {
// Negative-direction probes haven't landed yet. Pin the gap so a
// future negative-scenario PR is forced to add the absent probes.
const state: ExpectedState = {
id: "synthetic-host-absent",
localRegistry: { expected: "absent" },
dockerSandboxContainer: { expected: "absent" },
};
expect(probesForState(state)).toEqual([]);
});
});

describe("compiler emits state-validation phase actions from expected-state registry", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,27 @@ describe("live Vitest registry discovery support", () => {
reasons: ["runtime 'docker-missing' is not wired for live Vitest fixtures"],
});
});

it("keeps unwhitelisted lifecycle profiles skipped with the lifecycle reason", () => {
const scenario = listScenarios().find((entry) => entry.id === "ubuntu-rebuild-openclaw");

expect(scenario).toBeTruthy();
expect(liveScenarioSupport(scenario!)).toMatchObject({
supported: false,
reasons: ["lifecycle 'rebuild-current-version' is not wired for live Vitest fixtures"],
});
});

it("accepts the whitelisted post-reboot-recovery lifecycle scenario", () => {
const scenario = listScenarios().find(
(entry) => entry.id === "ubuntu-repo-docker-post-reboot-recovery",
);

expect(scenario).toBeTruthy();
expect(scenario!.environment?.lifecycle).toBe("post-reboot-recovery");
expect(liveScenarioSupport(scenario!)).toMatchObject({
supported: true,
reasons: [],
});
});
});
148 changes: 124 additions & 24 deletions test/e2e-scenario/framework-tests/e2e-migration-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ const INVENTORY_PATH = path.resolve(import.meta.dirname, "../migration/legacy-in
const REPO_ROOT = path.resolve(import.meta.dirname, "../../..");
const LEGACY_E2E_DIR = path.join(REPO_ROOT, "test/e2e");
const EXPECTED_STATUS_VALUES = ["not-migrated", "bridge-probe", "covered", "retired"] as const;
const INTERNAL_SURFACE_ROOTS = [
"test/e2e-scenario/nemoclaw_scenarios",
"test/e2e-scenario/onboarding_assertions",
"test/e2e-scenario/runtime/lib",
"test/e2e-scenario/runtime/reports",
"test/e2e-scenario/scenarios/orchestrators",
"test/e2e-scenario/validation_suites",
] as const;

type MigrationStatus = "not-migrated" | "bridge-probe" | "covered" | "retired";

Expand All @@ -26,13 +34,29 @@ interface LegacyInventoryEntry {
notes: string;
}

interface LegacyInternalSurface {
id: string;
paths: string[];
domain: string;
ownerIssue: string;
status: MigrationStatus;
replacementSurface: string;
targetVitestScenarios: string[];
bridgeProbes: string[];
retiredReason: string;
deletionReady: boolean;
deletionApprovalIssue?: string;
notes: string;
}

interface LegacyInventory {
version: number;
statusValues: MigrationStatus[];
deletionReadiness: {
requires: string[];
};
entries: LegacyInventoryEntry[];
internalSurfaces: LegacyInternalSurface[];
}

function loadInventory(): LegacyInventory {
Expand All @@ -54,16 +78,86 @@ function listLegacyShellEntrypoints(): string[] {
.sort();
}

function listRepoFilesUnder(repoRelativeDir: string): string[] {
const absoluteDir = path.join(REPO_ROOT, repoRelativeDir);
const files: string[] = [];
const visit = (dir: string) => {
for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) {
const absolutePath = path.join(dir, dirent.name);
if (dirent.isDirectory()) {
visit(absolutePath);
} else if (dirent.isFile()) {
files.push(path.relative(REPO_ROOT, absolutePath).split(path.sep).join("/"));
}
}
};
visit(absoluteDir);
return files.sort();
}

function isCoveredByInventoryPath(filePath: string, inventoryPath: string): boolean {
return filePath === inventoryPath || filePath.startsWith(`${inventoryPath}/`);
}

function expectPathListIsRepoRelative(paths: readonly string[]) {
expect(paths.length).toBeGreaterThan(0);
for (const repoRelativePath of paths) {
expect(repoRelativePath).not.toBe("");
expect(repoPathExists(repoRelativePath)).toBe(true);
}
}

function expectMigrationRecordDeletionGate(
record: Pick<
LegacyInventoryEntry | LegacyInternalSurface,
| "status"
| "targetVitestScenarios"
| "bridgeProbes"
| "retiredReason"
| "deletionReady"
| "deletionApprovalIssue"
>,
) {
if (record.status === "covered") {
expect(record.targetVitestScenarios.length).toBeGreaterThan(0);
for (const scenario of record.targetVitestScenarios) {
expect(scenario).toMatch(/^test\/e2e-scenario\/live\/.+\.test\.ts$/);
expect(repoPathExists(scenario)).toBe(true);
}
}

if (record.status === "bridge-probe") {
expect(record.bridgeProbes.length).toBeGreaterThan(0);
for (const probe of record.bridgeProbes) {
expect(repoPathExists(probe)).toBe(true);
}
}

if (record.status === "retired") {
expect(record.retiredReason).not.toBe("");
}

if (record.deletionReady) {
expect(["covered", "retired"]).toContain(record.status);
expect(record.deletionApprovalIssue).toBe("#4357");
expect(
record.status === "retired" ? record.retiredReason : record.targetVitestScenarios.length,
).toBeTruthy();
}
}

describe("E2E migration inventory deletion gates", () => {
it("uses a constrained migration vocabulary with owning issues", () => {
const inventory = loadInventory();
const statuses = new Set(inventory.statusValues);
const legacyScripts = new Set<string>();
const internalSurfaceIds = new Set<string>();

expect(inventory.version).toBe(1);
expect(inventory.statusValues).toEqual([...EXPECTED_STATUS_VALUES]);
expect(inventory.deletionReadiness.requires.length).toBeGreaterThan(0);
expect(inventory.entries.length).toBeGreaterThan(0);
expect(inventory.internalSurfaces.length).toBeGreaterThan(0);

for (const entry of inventory.entries) {
expect(statuses.has(entry.status)).toBe(true);
Expand All @@ -75,6 +169,18 @@ describe("E2E migration inventory deletion gates", () => {
expect(entry.ownerIssue).toMatch(/^#(?:3588|434[7-9]|435[0-7]|4941)$/);
expect(entry.notes).not.toBe("");
}

for (const surface of inventory.internalSurfaces) {
expect(statuses.has(surface.status)).toBe(true);
expect(surface.id).toMatch(/^[a-z0-9-]+$/);
expect(internalSurfaceIds.has(surface.id)).toBe(false);
internalSurfaceIds.add(surface.id);
expectPathListIsRepoRelative(surface.paths);
expect(surface.domain).not.toBe("");
expect(surface.ownerIssue).toMatch(/^#(?:3588|434[7-9]|435[0-7]|4941)$/);
expect(surface.replacementSurface).not.toBe("");
expect(surface.notes).not.toBe("");
}
});

it("covers every current direct legacy shell entrypoint", () => {
Expand All @@ -87,36 +193,30 @@ describe("E2E migration inventory deletion gates", () => {
expect(inventoriedShellScripts).toEqual(listLegacyShellEntrypoints());
});

it("requires coverage, retirement evidence, and #4357 approval before deletion", () => {
it("covers legacy scenario runner internal surfaces by path", () => {
const inventory = loadInventory();
const surfacePaths = inventory.internalSurfaces.flatMap((surface) => surface.paths);

for (const entry of inventory.entries) {
if (entry.status === "covered") {
expect(entry.targetVitestScenarios.length).toBeGreaterThan(0);
for (const scenario of entry.targetVitestScenarios) {
expect(scenario).toMatch(/^test\/e2e-scenario\/live\/.+\.test\.ts$/);
expect(repoPathExists(scenario)).toBe(true);
}
for (const root of INTERNAL_SURFACE_ROOTS) {
const files = listRepoFilesUnder(root);
expect(files.length).toBeGreaterThan(0);
for (const file of files) {
expect(
surfacePaths.some((surfacePath) => isCoveredByInventoryPath(file, surfacePath)),
).toBe(true);
}
}
});

if (entry.status === "bridge-probe") {
expect(entry.bridgeProbes.length).toBeGreaterThan(0);
for (const probe of entry.bridgeProbes) {
expect(repoPathExists(probe)).toBe(true);
}
}
it("requires coverage, retirement evidence, and #4357 approval before deletion", () => {
const inventory = loadInventory();

if (entry.status === "retired") {
expect(entry.retiredReason).not.toBe("");
}
for (const entry of inventory.entries) {
expectMigrationRecordDeletionGate(entry);
}

if (entry.deletionReady) {
expect(["covered", "retired"]).toContain(entry.status);
expect(entry.deletionApprovalIssue).toBe("#4357");
expect(
entry.status === "retired" ? entry.retiredReason : entry.targetVitestScenarios.length,
).toBeTruthy();
}
for (const surface of inventory.internalSurfaces) {
expectMigrationRecordDeletionGate(surface);
}
});
});
Loading
Loading