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
35 changes: 35 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,41 @@ 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,29 @@ 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: [],
});
});
});
239 changes: 239 additions & 0 deletions test/e2e-scenario/framework-tests/e2e-phase-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, expectTypeOf, it } from "vitest";

import {
HostCliClient,
SandboxClient,
type CommandRunner,
} from "../framework/clients/index.ts";
import type { E2EScenarioFixtures } from "../framework/e2e-test.ts";
import {
buildBackupContainerName,
LifecyclePhaseFixture,
type LifecycleCleanup,
} from "../framework/phases/lifecycle.ts";
import type { NemoClawInstance } from "../framework/phases/index.ts";
import type {
ShellProbeResult,
ShellProbeRunOptions,
TrustedShellCommand,
} from "../framework/shell-probe.ts";

interface RunnerCall {
command: string;
args: string[];
options?: ShellProbeRunOptions;
}

interface CleanupCall {
name: string;
run: () => Promise<void> | void;
}

function shellResult(exitCode: number, output = ""): ShellProbeResult {
return {
command: [],
exitCode,
signal: null,
timedOut: false,
stdout: exitCode === 0 ? output : "",
stderr: exitCode === 0 ? "" : output,
artifacts: {
stdout: "/tmp/stdout.txt",
stderr: "/tmp/stderr.txt",
result: "/tmp/result.json",
},
};
}

class FakeRunner implements CommandRunner {
readonly calls: RunnerCall[] = [];
private readonly responses: ShellProbeResult[] = [];

enqueue(response: ShellProbeResult): void {
this.responses.push(response);
}

async run(
command: TrustedShellCommand,
options?: ShellProbeRunOptions,
): Promise<ShellProbeResult> {
this.calls.push({ command: command.command, args: [...command.args], options });
const response = this.responses.shift();
if (!response) {
throw new Error(
`FakeRunner response missing for command: ${command.command} ${command.args.join(" ")}`,
);
}
return response;
}
}

class FakeCleanup implements LifecycleCleanup {
readonly calls: CleanupCall[] = [];

add(name: string, run: () => Promise<void> | void): void {
this.calls.push({ name, run });
}
}

function instance(overrides: Partial<NemoClawInstance> = {}): NemoClawInstance {
return {
onboarding: "cloud-openclaw",
sandboxName: "e2e-ubuntu-repo-cloud-openclaw",
agent: "openclaw",
provider: "nvidia",
providerEnv: "cloud",
gatewayUrl: "http://127.0.0.1:18789",
result: shellResult(0),
...overrides,
};
}

function fixture(runner: FakeRunner, cleanup: FakeCleanup): LifecyclePhaseFixture {
const host = new HostCliClient(runner);
const sandbox = new SandboxClient(runner);
return new LifecyclePhaseFixture(host, sandbox, cleanup);
}

describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", () => {
it("stops the gateway, discovers the labeled container, and stops it", async () => {
const runner = new FakeRunner();
runner.enqueue(shellResult(0)); // openshell gateway stop
runner.enqueue(shellResult(0, "openshell-cluster-e2e-ubuntu-repo-cloud-openclaw\n")); // discover
runner.enqueue(shellResult(0)); // docker stop
const cleanup = new FakeCleanup();

const result = await fixture(runner, cleanup).simulate(
"post-reboot-recovery",
instance(),
);

expect(result.profile).toBe("post-reboot-recovery");
expect(result.steps.map((step) => step.id)).toEqual([
"gateway-stop",
"docker-stop:openshell-cluster-e2e-ubuntu-repo-cloud-openclaw",
]);
expect(runner.calls.map((call) => ({ command: call.command, args: call.args }))).toEqual([
{ command: "openshell", args: ["gateway", "stop"] },
{
command: "docker",
args: [
"ps",
"-a",
"--filter",
"label=openshell.ai/sandbox-name=e2e-ubuntu-repo-cloud-openclaw",
"--format",
"{{.Names}}",
],
},
{ command: "docker", args: ["stop", "openshell-cluster-e2e-ubuntu-repo-cloud-openclaw"] },
]);
expect(cleanup.calls.map((call) => call.name)).toEqual([
"lifecycle.docker-start:openshell-cluster-e2e-ubuntu-repo-cloud-openclaw",
]);
});

it("tolerates a non-zero gateway stop (post-reboot fresh runtime)", async () => {
const runner = new FakeRunner();
runner.enqueue(shellResult(1, "no gateway runtime")); // gateway stop fails
runner.enqueue(shellResult(0, "container-1\n"));
runner.enqueue(shellResult(0)); // docker stop
const cleanup = new FakeCleanup();

const result = await fixture(runner, cleanup).simulate(
"post-reboot-recovery",
instance(),
);

expect(result.steps.find((step) => step.id === "gateway-stop")).toBeTruthy();
});

it("fails when no Docker container carries the OpenShell sandbox-name label", async () => {
const runner = new FakeRunner();
runner.enqueue(shellResult(0)); // gateway stop
runner.enqueue(shellResult(0, "\n")); // discover returns nothing
const cleanup = new FakeCleanup();

await expect(
fixture(runner, cleanup).simulate("post-reboot-recovery", instance()),
).rejects.toThrow(/expected at least one Docker container labeled/);
});

it("fails when docker discover returns non-zero", async () => {
const runner = new FakeRunner();
runner.enqueue(shellResult(0)); // gateway stop
runner.enqueue(shellResult(1, "Cannot connect to the Docker daemon"));
const cleanup = new FakeCleanup();

await expect(
fixture(runner, cleanup).simulate("post-reboot-recovery", instance()),
).rejects.toThrow(/could not query Docker for label/);
});
});

describe("LifecyclePhaseFixture.simulate post-reboot-recovery (rename-to-gpu-backup)", () => {
it("stops, then renames the labeled container to a *-nemoclaw-gpu-backup-* sibling", async () => {
const runner = new FakeRunner();
runner.enqueue(shellResult(0)); // openshell gateway stop
runner.enqueue(shellResult(0, "openshell-cluster-e2e-x\n")); // discover
runner.enqueue(shellResult(0)); // docker stop
runner.enqueue(shellResult(0)); // docker rename
const cleanup = new FakeCleanup();

const result = await fixture(runner, cleanup).simulate(
"post-reboot-recovery",
instance({ sandboxName: "e2e-x" }),
{ mode: "rename-to-gpu-backup" },
);

expect(result.steps.map((step) => step.id.split("->")[0])).toContain(
"docker-rename:openshell-cluster-e2e-x",
);
const renameCall = runner.calls.find(
(call) => call.command === "docker" && call.args[0] === "rename",
);
expect(renameCall).toBeTruthy();
expect(renameCall!.args[1]).toBe("openshell-cluster-e2e-x");
expect(renameCall!.args[2]).toMatch(/^openshell-cluster-e2e-x-nemoclaw-gpu-backup-\d+$/);

// Cleanup queue now has both docker-start and docker-rename-back.
expect(cleanup.calls.map((call) => call.name.split(":")[0])).toEqual([
"lifecycle.docker-start",
"lifecycle.docker-rename-back",
]);
});
});

describe("LifecyclePhaseFixture profile dispatch", () => {
it("rejects unknown lifecycle profiles", async () => {
const runner = new FakeRunner();
const cleanup = new FakeCleanup();

await expect(
// @ts-expect-error — exhaustiveness check
fixture(runner, cleanup).simulate("not-a-profile", instance()),
).rejects.toThrow(/Unsupported lifecycle profile/);
});

it("exposes the lifecycle phase on the Vitest scenario context", () => {
expectTypeOf<E2EScenarioFixtures["lifecycle"]>().toEqualTypeOf<LifecyclePhaseFixture>();
});
});

describe("buildBackupContainerName", () => {
it("appends -nemoclaw-gpu-backup-<ts> to the original name", () => {
expect(buildBackupContainerName("openshell-cluster-foo", 1717280000000)).toBe(
"openshell-cluster-foo-nemoclaw-gpu-backup-1717280000000",
);
});

it("truncates the original name to fit within Docker's 253-char limit", () => {
const longName = "a".repeat(253);
const result = buildBackupContainerName(longName, 1717280000000);
expect(result.length).toBeLessThanOrEqual(253);
expect(result.endsWith("-nemoclaw-gpu-backup-1717280000000")).toBe(true);
});
});
Loading
Loading