diff --git a/test/e2e/fixtures/phases/lifecycle.ts b/test/e2e/fixtures/phases/lifecycle.ts index 50d6363e5e7..7110af8df6c 100644 --- a/test/e2e/fixtures/phases/lifecycle.ts +++ b/test/e2e/fixtures/phases/lifecycle.ts @@ -43,6 +43,9 @@ const NEMOCLAW_OPENSHELL_GATEWAY_USER_SERVICE_MARKER_LINE = const NEMOCLAW_INSTALLER = fileURLToPath( new URL("../../../../scripts/install.sh", import.meta.url), ); +const NEMOCLAW_OPENSHELL_INSTALLER = fileURLToPath( + new URL("../../../../scripts/install-openshell.sh", import.meta.url), +); const USER_SERVICE_STAGE_RESULT_PREFIX = "NEMOCLAW_E2E_GATEWAY_USER_SERVICE="; type UserServiceStageResult = "upstream" | "existing" | "staged"; @@ -143,6 +146,23 @@ export function buildOpenShellGatewayUserServiceRestartScript(): string { ].join("\n"); } +export function buildOpenShellGatewayUserServiceDiagnosticsScript(): string { + return [ + "set +e", + "service=openshell-gateway", + 'if ! systemctl --user cat "$service" >/dev/null 2>&1; then', + " service=nemoclaw-openshell-gateway", + "fi", + 'printf "OpenShell gateway user service: %s\\n" "$service"', + 'systemctl --user show "$service" --no-pager --property=ActiveState --property=SubState --property=Result --property=ExecMainCode --property=ExecMainStatus', + 'systemctl --user status "$service" --no-pager --full', + "if command -v journalctl >/dev/null 2>&1; then", + ' journalctl --user --unit "$service" --no-pager --lines=200', + "fi", + "exit 0", + ].join("\n"); +} + export type LifecycleProfile = "post-reboot-recovery" | "dcode-rebuild-invalid-credential"; export interface LifecycleCleanup { @@ -205,6 +225,8 @@ function instanceName(instance: NemoClawInstance | string): string { } export class LifecyclePhaseFixture { + private postRebootUserServiceStage: UserServiceStageResult | undefined; + constructor( private readonly host: HostCliClient, private readonly sandbox: SandboxClient, @@ -212,6 +234,33 @@ export class LifecyclePhaseFixture { private readonly gateway?: GatewayClient, ) {} + /** + * Ensure OpenShell is installed and stage the OpenShell gateway user service + * before onboarding. Onboarding must see the service so it writes the + * Docker-driver environment that the unit needs after a user-manager restart. + */ + async preparePostReboot(): Promise { + if (this.postRebootUserServiceStage) return this.postRebootUserServiceStage; + + if (!(await this.host.isCommandAvailable("openshell-gateway"))) { + const install = await this.host.command("bash", [NEMOCLAW_OPENSHELL_INSTALLER], { + artifactName: "lifecycle-prereq-install-openshell", + env: buildAvailabilityProbeEnv(), + timeoutMs: 10 * 60_000, + }); + assertExitZero(install, "install OpenShell before reboot lifecycle onboarding"); + } + + const stage = await this.ensureOpenShellGatewayUserService(); + this.postRebootUserServiceStage = stage; + if (stage === "staged") { + this.cleanup.add("lifecycle.remove-staged-gateway-user-service", async () => { + await this.removeStagedOpenShellGatewayUserService(); + }); + } + return stage; + } + async rebuildSandbox( instance: NemoClawInstance | string, options: RebuildSandboxOptions = {}, @@ -319,12 +368,17 @@ export class LifecyclePhaseFixture { * - `docker start` the labeled container so the sandbox returns * to a usable state for any teardown that expects it live; * - remove a user service staged only for this source-checkout - * fixture, then restore the original gateway runtime shape. + * fixture after the sandbox cleanup has used it. */ async simulatePostReboot( instance: NemoClawInstance, options: PostRebootOptions = {}, ): Promise { + if (!this.postRebootUserServiceStage) { + throw new Error( + "OpenShell gateway user service must be prepared before post-reboot onboarding.", + ); + } const mode: PostRebootMode = options.mode ?? "stop-original"; const steps: LifecycleResult["steps"] = []; @@ -337,17 +391,6 @@ export class LifecyclePhaseFixture { ); } const originalName = containerNames[0]; - const userServiceStage = await this.ensureOpenShellGatewayUserService(); - let previousRuntime: HostGatewayRuntime | null = null; - if (userServiceStage === "staged") { - this.cleanup.add("lifecycle.remove-staged-gateway-user-service", async () => { - await this.removeStagedOpenShellGatewayUserService(); - await this.startGatewayRuntime(previousRuntime, { - sandboxName: instance.sandboxName, - }); - await this.waitForGatewayConnected(); - }); - } const stop = await this.host.command("docker", ["stop", originalName], { artifactName: `lifecycle-post-reboot-docker-stop-${originalName}`, @@ -385,7 +428,7 @@ export class LifecyclePhaseFixture { }); } - previousRuntime = await this.restartGatewayRuntime({ + const previousRuntime = await this.restartGatewayRuntime({ delayMs: 0, requireUserService: true, sandboxName: instance.sandboxName, @@ -623,8 +666,19 @@ export class LifecyclePhaseFixture { } if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, intervalMs)); } + const diagnostics = await this.host.command( + "sh", + ["-lc", buildOpenShellGatewayUserServiceDiagnosticsScript()], + { + artifactName: "lifecycle-gateway-user-service-diagnostics", + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }, + ); throw new Error( - `gateway did not become healthy after restart: ${lastError instanceof Error ? lastError.message : String(lastError ?? "unknown")}`, + `gateway did not become healthy after restart: ${ + lastError instanceof Error ? lastError.message : String(lastError ?? "unknown") + }; service diagnostics: ${diagnostics.artifacts.result}`, ); } diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev-env.ts b/test/e2e/live/openclaw-plugin-runtime-exdev-env.ts deleted file mode 100644 index 4e8c65a7971..00000000000 --- a/test/e2e/live/openclaw-plugin-runtime-exdev-env.ts +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -export const RELEASE_BASELINE_TEST_SELECTOR = "release-baseline"; -export const CURRENT_LIFECYCLE_TEST_SELECTOR = "current-lifecycle"; -export const RELEASE_SANDBOX_BASE_IMAGE_REF = "ghcr.io/nvidia/nemoclaw/sandbox-base:v0.0.71"; - -export type OpenClawPluginRuntimeExdevSelector = - | typeof RELEASE_BASELINE_TEST_SELECTOR - | typeof CURRENT_LIFECYCLE_TEST_SELECTOR; - -export function buildOpenClawPluginRuntimeExdevBaseImageEnv( - selector: OpenClawPluginRuntimeExdevSelector, -): NodeJS.ProcessEnv { - return selector === RELEASE_BASELINE_TEST_SELECTOR - ? { NEMOCLAW_SANDBOX_BASE_IMAGE_REF: RELEASE_SANDBOX_BASE_IMAGE_REF } - : {}; -} diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev-fixture.ts b/test/e2e/live/openclaw-plugin-runtime-exdev-fixture.ts new file mode 100644 index 00000000000..d7eba969092 --- /dev/null +++ b/test/e2e/live/openclaw-plugin-runtime-exdev-fixture.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const RELEASE_BASELINE_TEST_SELECTOR = "release-baseline"; +export const CURRENT_LIFECYCLE_TEST_SELECTOR = "current-lifecycle"; +export const RELEASE_SANDBOX_BASE_IMAGE_REF = "ghcr.io/nvidia/nemoclaw/sandbox-base:v0.0.71"; +const RELEASE_OPENCLAW_MODULE_PATH = "/usr/local/lib/node_modules/openclaw"; +const CURRENT_OPENCLAW_MODULE_PATH = + "/usr/local/lib/nemoclaw/openclaw-runtime/node_modules/openclaw"; + +export type OpenClawPluginRuntimeExdevSelector = + | typeof RELEASE_BASELINE_TEST_SELECTOR + | typeof CURRENT_LIFECYCLE_TEST_SELECTOR; + +export type OpenClawPluginRuntimeExdevFixture = { + selector: OpenClawPluginRuntimeExdevSelector; + source: "release" | "current"; + baseImageEnv: NodeJS.ProcessEnv; + openClawModulePath: typeof RELEASE_OPENCLAW_MODULE_PATH | typeof CURRENT_OPENCLAW_MODULE_PATH; +}; + +export function resolveOpenClawPluginRuntimeExdevFixture( + selector: OpenClawPluginRuntimeExdevSelector, +): OpenClawPluginRuntimeExdevFixture { + if (selector === RELEASE_BASELINE_TEST_SELECTOR) { + return { + selector, + source: "release", + baseImageEnv: { + NEMOCLAW_SANDBOX_BASE_IMAGE_REF: RELEASE_SANDBOX_BASE_IMAGE_REF, + }, + openClawModulePath: RELEASE_OPENCLAW_MODULE_PATH, + }; + } + return { + selector, + source: "current", + baseImageEnv: {}, + openClawModulePath: CURRENT_OPENCLAW_MODULE_PATH, + }; +} diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts index 17665c90651..be2ce2ada0e 100644 --- a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts +++ b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts @@ -29,12 +29,12 @@ import { CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; import type { TestProgress } from "../fixtures/progress.ts"; import { parseJsonFromText } from "./json-envelope.ts"; import { - buildOpenClawPluginRuntimeExdevBaseImageEnv, CURRENT_LIFECYCLE_TEST_SELECTOR, - type OpenClawPluginRuntimeExdevSelector, + type OpenClawPluginRuntimeExdevFixture, RELEASE_BASELINE_TEST_SELECTOR, RELEASE_SANDBOX_BASE_IMAGE_REF, -} from "./openclaw-plugin-runtime-exdev-env.ts"; + resolveOpenClawPluginRuntimeExdevFixture, +} from "./openclaw-plugin-runtime-exdev-fixture.ts"; import { createOpenShellDriverConfigTestWrapper, type OpenShellComponents, @@ -392,9 +392,13 @@ type CustomPluginBuildContext = { pluginDirPath: string; }; +type PreparedCustomPluginBuildContext = CustomPluginBuildContext & { + runtimeOpenClawVersion: string; +}; + function createCustomPluginBuildContext(): CustomPluginBuildContext { const nonce = randomUUID(); - const sourceParentDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-v0.0.71-weather-")); + const sourceParentDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-weather-plugin-")); const sourceRoot = path.join(sourceParentDir, "NemoClaw"); return { sourceParentDir, @@ -469,31 +473,40 @@ function writeCustomPluginVersion( function createCustomPluginDockerfile( context: CustomPluginBuildContext, - builderImageRef: string, -): void { + fixture: OpenClawPluginRuntimeExdevFixture, +): string { const sourceDockerfile = path.join(context.sourceRoot, "Dockerfile"); const source = fs.readFileSync(sourceDockerfile, "utf8"); const baseImageAnchor = "ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest\n"; - const builderImageAnchor = `FROM ${RELEASE_BUILDER_IMAGE_REF} AS builder\n`; + const builderImageRef = + fixture.source === "release" ? RELEASE_BUILDER_IMAGE_REF : CURRENT_BUILDER_IMAGE_REF; + const builderImageAnchor = `FROM ${builderImageRef} AS builder\n`; const runtimeAnchor = "FROM ${BASE_IMAGE}\n"; expect( source.match(/^ARG BASE_IMAGE=ghcr\.io\/nvidia\/nemoclaw\/sandbox-base:latest$/gm)?.length, ).toBe(1); expect(source.split(builderImageAnchor)).toHaveLength(2); expect(source.match(/^FROM \$\{BASE_IMAGE\}$/gm)?.length, "expected one runtime stage").toBe(1); + const runtimeOpenClawDeclarations = [...source.matchAll(/^ARG OPENCLAW_VERSION=([0-9.]+)$/gm)]; + expect(runtimeOpenClawDeclarations, "expected one OpenClaw version declaration").toHaveLength(1); + const runtimeOpenClawVersion = runtimeOpenClawDeclarations[0]?.[1]; + expect(runtimeOpenClawVersion, "source Dockerfile must declare an OpenClaw version").toMatch( + /^\d+(?:\.\d+)+$/, + ); expect( - source.match(/^ARG OPENCLAW_VERSION=([0-9.]+)$/m)?.[1], + fixture.source !== "release" || runtimeOpenClawVersion === WEATHER_OPENCLAW_VERSION, "weather fixture SDK must match the v0.0.71 managed runtime target", - ).toBe(WEATHER_OPENCLAW_VERSION); + ).toBe(true); expect( WEATHER_FIXTURE_PACKAGE.devDependencies?.openclaw, "weather fixture devDependency must match its declared OpenClaw build target", ).toBe(WEATHER_OPENCLAW_VERSION); - const runtime = source - .replace(baseImageAnchor, `ARG BASE_IMAGE=${RELEASE_SANDBOX_BASE_IMAGE_REF}\n`) - .replace(builderImageAnchor, `FROM ${builderImageRef} AS builder\n`) - .replace(runtimeAnchor, "FROM ${BASE_IMAGE} AS nemoclaw-runtime\n"); + const selectedSource = + fixture.source === "release" + ? source.replace(baseImageAnchor, `ARG BASE_IMAGE=${RELEASE_SANDBOX_BASE_IMAGE_REF}\n`) + : source; + const runtime = selectedSource.replace(runtimeAnchor, "FROM ${BASE_IMAGE} AS nemoclaw-runtime\n"); const pluginDirName = path.basename(context.pluginDirPath); const versionSourceName = path.basename(context.versionSourcePath); const extension = String.raw` @@ -533,7 +546,7 @@ USER sandbox RUN test ! -e /opt/weather-plugin/node_modules/openclaw \ && HOME=/sandbox openclaw plugins install /opt/weather-plugin \ && test -L /sandbox/.openclaw/extensions/weather/node_modules/openclaw \ - && test "$(realpath /sandbox/.openclaw/extensions/weather/node_modules/openclaw)" = /usr/local/lib/node_modules/openclaw \ + && test "$(realpath /sandbox/.openclaw/extensions/weather/node_modules/openclaw)" = "${fixture.openClawModulePath}" \ && HOME=/sandbox openclaw plugins enable weather \ && HOME=/sandbox openclaw plugins inspect weather --json > /dev/null @@ -552,6 +565,7 @@ RUN chown sandbox:sandbox /sandbox/.openclaw/openclaw.json \ encoding: "utf8", flag: "wx", }); + return String(runtimeOpenClawVersion); } async function buildAndVerifyTaggedCli( @@ -651,15 +665,17 @@ async function assertWeatherPluginRuntime( sandbox: SandboxClient, phase: string, expectedFixtureVersion: WeatherFixtureVersion, + expectedOpenClawVersion: string, + expectedOpenClawModulePath: OpenClawPluginRuntimeExdevFixture["openClawModulePath"], ): Promise { const imageProbe = await sandbox.execShell( SANDBOX_NAME, trustedSandboxShellScript(`set -eu test -s /tmp/gateway.log test -s /usr/local/share/nemoclaw/e2e-weather-plugin.sha256 -test "$(openclaw --version 2>/dev/null | awk '{print $2}')" = "${WEATHER_OPENCLAW_VERSION}" +test "$(openclaw --version 2>/dev/null | awk '{print $2}')" = "${expectedOpenClawVersion}" test -L /sandbox/.openclaw/extensions/weather/node_modules/openclaw -test "$(realpath /sandbox/.openclaw/extensions/weather/node_modules/openclaw)" = /usr/local/lib/node_modules/openclaw +test "$(realpath /sandbox/.openclaw/extensions/weather/node_modules/openclaw)" = "${expectedOpenClawModulePath}" expected=$(cat /usr/local/share/nemoclaw/e2e-weather-plugin.sha256) actual=$(cd /sandbox/.openclaw/extensions/weather && sha256sum dist/index.js dist/version.js | sha256sum | cut -d ' ' -f 1) [ "$expected" = "$actual" ] @@ -888,47 +904,55 @@ const runtimeDepsReplacementProbe = trustedSandboxShellScript(runtimeDepsReplace async function prepareCustomPluginSource( host: HostCliClient, cleanup: CleanupRegistry, - builderImageRef: string, -): Promise { + fixture: OpenClawPluginRuntimeExdevFixture, +): Promise { const context = createCustomPluginBuildContext(); - cleanup.add("remove v0.0.71 custom-plugin source worktree", () => + cleanup.add(`remove ${fixture.source} custom-plugin source clone`, () => fs.rmSync(context.sourceParentDir, { recursive: true, force: true }), ); - const cloneRelease = await host.command( - "git", - [ - "clone", - "--depth", - "1", - "--branch", - NEMOCLAW_RELEASE_TAG, - "--single-branch", - NEMOCLAW_SOURCE_REPOSITORY, - context.sourceRoot, - ], - { - artifactName: "clone-nemoclaw-v0-0-71-plugin-source", - env: liveEnv(), - timeoutMs: 180_000, - }, - ); - expect(cloneRelease.exitCode, resultText(cloneRelease)).toBe(0); - const releaseHead = await host.command("git", ["-C", context.sourceRoot, "rev-parse", "HEAD"], { - artifactName: "verify-nemoclaw-v0-0-71-plugin-source", + const currentHead = await host.command("git", ["-C", REPO_ROOT, "rev-parse", "HEAD"], { + artifactName: "resolve-current-nemoclaw-plugin-source", env: liveEnv(), timeoutMs: 30_000, }); - expect(releaseHead.exitCode, resultText(releaseHead)).toBe(0); - expect(releaseHead.stdout.trim()).toBe(NEMOCLAW_RELEASE_COMMIT); - createCustomPluginDockerfile(context, builderImageRef); - return context; + expect(currentHead.exitCode, resultText(currentHead)).toBe(0); + const expectedSourceHead = + fixture.source === "release" ? NEMOCLAW_RELEASE_COMMIT : currentHead.stdout.trim(); + const cloneArgs = + fixture.source === "release" + ? [ + "clone", + "--depth", + "1", + "--branch", + NEMOCLAW_RELEASE_TAG, + "--single-branch", + NEMOCLAW_SOURCE_REPOSITORY, + context.sourceRoot, + ] + : ["clone", "--local", "--no-hardlinks", REPO_ROOT, context.sourceRoot]; + const cloneSource = await host.command("git", cloneArgs, { + artifactName: `clone-${fixture.source}-nemoclaw-plugin-source`, + env: liveEnv(), + timeoutMs: 180_000, + }); + expect(cloneSource.exitCode, resultText(cloneSource)).toBe(0); + const sourceHead = await host.command("git", ["-C", context.sourceRoot, "rev-parse", "HEAD"], { + artifactName: `verify-${fixture.source}-nemoclaw-plugin-source`, + env: liveEnv(), + timeoutMs: 30_000, + }); + expect(sourceHead.exitCode, resultText(sourceHead)).toBe(0); + expect(sourceHead.stdout.trim()).toBe(expectedSourceHead); + const runtimeOpenClawVersion = createCustomPluginDockerfile(context, fixture); + return { ...context, runtimeOpenClawVersion }; } async function startDeploymentFixture( artifacts: ArtifactSink, cleanup: CleanupRegistry, progress: TestProgress, - selector: OpenClawPluginRuntimeExdevSelector, + fixture: OpenClawPluginRuntimeExdevFixture, ): Promise { const fake = await startFakeOpenAiCompatibleServer({ apiKey: "nemoclaw-exdev-dummy-key", @@ -948,7 +972,7 @@ async function startDeploymentFixture( }); return liveEnv({ - ...buildOpenClawPluginRuntimeExdevBaseImageEnv(selector), + ...fixture.baseImageEnv, COMPATIBLE_API_KEY: "nemoclaw-exdev-dummy-key", NEMOCLAW_ENDPOINT_URL: fake.baseUrl, NEMOCLAW_MODEL: "nemoclaw-exdev-probe", @@ -989,6 +1013,7 @@ test("the release-baseline custom plugin loads with its exact NemoClaw and OpenS ], }, }, async ({ artifacts, cleanup, host, progress, sandbox, skip }) => { + const fixture = resolveOpenClawPluginRuntimeExdevFixture(RELEASE_BASELINE_TEST_SELECTOR); await artifacts.target.declare({ id: "openclaw-plugin-runtime-exdev-release", boundary: "fresh-openclaw-sandbox-exec", @@ -999,7 +1024,7 @@ test("the release-baseline custom plugin loads with its exact NemoClaw and OpenS "release-matched peer/dev dependencies prune private OpenClaw and link the host runtime", "the release weather plugin loads from the custom image without an EXDEV bootstrap failure", ], - selector: RELEASE_BASELINE_TEST_SELECTOR, + selector: fixture.selector, nemoclawSourceRelease: NEMOCLAW_RELEASE_TAG, nemoclawSourceCommit: NEMOCLAW_RELEASE_COMMIT, taggedOpenshellVersion: NEMOCLAW_RELEASE_OPENSHELL_VERSION, @@ -1035,11 +1060,7 @@ test("the release-baseline custom plugin loads with its exact NemoClaw and OpenS ); progress.phase("clone and build the tagged plugin fixture"); - const customPluginContext = await prepareCustomPluginSource( - host, - cleanup, - RELEASE_BUILDER_IMAGE_REF, - ); + const customPluginContext = await prepareCustomPluginSource(host, cleanup, fixture); await buildAndVerifyTaggedCli(host, customPluginContext); progress.phase("install tagged OpenShell and onboard the release sandbox"); await stopOpenShellGatewayBeforeVersionSwitch(host, "existing"); @@ -1051,12 +1072,7 @@ test("the release-baseline custom plugin loads with its exact NemoClaw and OpenS ); const taggedOpenShellWrapper = createOpenShellTmpfsWrapper(taggedPinnedOpenshell.cli); cleanup.add("remove v0.0.71 EXDEV OpenShell PATH wrapper", taggedOpenShellWrapper.remove); - const deploymentEnv = await startDeploymentFixture( - artifacts, - cleanup, - progress, - RELEASE_BASELINE_TEST_SELECTOR, - ); + const deploymentEnv = await startDeploymentFixture(artifacts, cleanup, progress, fixture); const taggedSandboxEnv = withOpenShellWrapperEnv( deploymentEnv, taggedOpenShellWrapper, @@ -1158,13 +1174,14 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu ], }, }, async ({ artifacts, cleanup, host, progress, sandbox, skip }) => { + const fixture = resolveOpenClawPluginRuntimeExdevFixture(CURRENT_LIFECYCLE_TEST_SELECTOR); await artifacts.target.declare({ id: "openclaw-plugin-runtime-exdev", boundary: "fresh-openclaw-sandbox-exec", regressionTargets: ["#6108", "#3513", "#3127"], contract: [ "the current CLI uses OpenShell 0.0.85 for current lifecycle coverage", - "the current CLI selects and validates its compatible sandbox base image", + "the CLI and Dockerfile use the same checkout source and a compatible sandbox base image", "gateway log, runtime inspection, tools.catalog, and tools.invoke prove weather/get_weather", "custom-plugin v1 survives restart, recreation installs v2, and rebuild installs v3", "workspace state survives both onboarding recreation and rebuild", @@ -1174,12 +1191,12 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu `legacy source-side staging fails with EXDEV across the same ${EXDEV_TMPFS_SOURCE} to plugin-runtime-deps boundary`, "OpenClaw-style target-side plugin runtime-deps replacement completes without EXDEV", ], - selector: CURRENT_LIFECYCLE_TEST_SELECTOR, - nemoclawSourceRelease: NEMOCLAW_RELEASE_TAG, - nemoclawSourceCommit: NEMOCLAW_RELEASE_COMMIT, + selector: fixture.selector, + nemoclawSource: "current-checkout", currentOpenshellVersion: CURRENT_OPENSHELL_VERSION, sandboxBaseImageResolution: "current-cli", - openclawVersion: WEATHER_OPENCLAW_VERSION, + pluginBuildOpenClawVersion: WEATHER_OPENCLAW_VERSION, + runtimeOpenClawVersionSource: "current-source", }); await requireDocker( @@ -1224,17 +1241,8 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu progress.phase("clone and prepare the current plugin fixture"); const policySourceSnapshot = snapshotPolicySources(); - const customPluginContext = await prepareCustomPluginSource( - host, - cleanup, - CURRENT_BUILDER_IMAGE_REF, - ); - const deploymentEnv = await startDeploymentFixture( - artifacts, - cleanup, - progress, - CURRENT_LIFECYCLE_TEST_SELECTOR, - ); + const customPluginContext = await prepareCustomPluginSource(host, cleanup, fixture); + const deploymentEnv = await startDeploymentFixture(artifacts, cleanup, progress, fixture); progress.phase("install and validate current OpenShell"); await stopOpenShellGatewayBeforeVersionSwitch(host, "existing"); const pinnedOpenshell = await installAndResolvePinnedOpenShell( @@ -1292,7 +1300,13 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu const tmpfsMountedAfterOnboard = await assertExdevTmpfsMounted(sandbox, "after-onboard"); assertPolicySourcesUnchanged(policySourceSnapshot, "onboard"); - const weatherAfterOnboard = await assertWeatherPluginRuntime(sandbox, "after-onboard", "v1"); + const weatherAfterOnboard = await assertWeatherPluginRuntime( + sandbox, + "after-onboard", + "v1", + customPluginContext.runtimeOpenClawVersion, + fixture.openClawModulePath, + ); progress.phase("restart the gateway and confirm plugin v1"); const restart = await host.command("node", [CLI_ENTRYPOINT, SANDBOX_NAME, "gateway", "restart"], { @@ -1301,7 +1315,13 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu timeoutMs: 180_000, }); expect(restart.exitCode, resultText(restart)).toBe(0); - const weatherAfterRestart = await assertWeatherPluginRuntime(sandbox, "after-restart", "v1"); + const weatherAfterRestart = await assertWeatherPluginRuntime( + sandbox, + "after-restart", + "v1", + customPluginContext.runtimeOpenClawVersion, + fixture.openClawModulePath, + ); expect(weatherAfterRestart.imageMarker).toBe(weatherAfterOnboard.imageMarker); const workspaceMarker = `plugin-lifecycle-${randomUUID()}`; @@ -1338,7 +1358,13 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu expect(recreate.exitCode, resultText(recreate)).toBe(0); const tmpfsMountedAfterRecreate = await assertExdevTmpfsMounted(sandbox, "after-recreate"); assertPolicySourcesUnchanged(policySourceSnapshot, "recreate"); - const weatherAfterRecreate = await assertWeatherPluginRuntime(sandbox, "after-recreate", "v2"); + const weatherAfterRecreate = await assertWeatherPluginRuntime( + sandbox, + "after-recreate", + "v2", + customPluginContext.runtimeOpenClawVersion, + fixture.openClawModulePath, + ); expect(weatherAfterRecreate.imageMarker).not.toBe(weatherAfterOnboard.imageMarker); await assertWorkspaceMarker(sandbox, "after-recreate", workspaceMarker); @@ -1354,7 +1380,13 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu expect(rebuild.exitCode, resultText(rebuild)).toBe(0); const tmpfsMountedAfterRebuild = await assertExdevTmpfsMounted(sandbox, "after-rebuild"); assertPolicySourcesUnchanged(policySourceSnapshot, "rebuild"); - const weatherAfterRebuild = await assertWeatherPluginRuntime(sandbox, "after-rebuild", "v3"); + const weatherAfterRebuild = await assertWeatherPluginRuntime( + sandbox, + "after-rebuild", + "v3", + customPluginContext.runtimeOpenClawVersion, + fixture.openClawModulePath, + ); expect(weatherAfterRebuild.imageMarker).not.toBe(weatherAfterRecreate.imageMarker); await assertWorkspaceMarker(sandbox, "after-rebuild", workspaceMarker); @@ -1397,6 +1429,7 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu rebuildExitCode: rebuild.exitCode, filesystemProbeExitCode: df.exitCode, runtimeDepsProbeExitCode: probe.exitCode, + runtimeOpenClawVersion: customPluginContext.runtimeOpenClawVersion, testOnlyTmpfsSource: EXDEV_TMPFS_SOURCE, assertions: { weatherAfterOnboard: diff --git a/test/e2e/live/rebuild-openclaw-old-base-context.ts b/test/e2e/live/rebuild-openclaw-old-base-context.ts index 700f5798005..60ace4e3b9b 100644 --- a/test/e2e/live/rebuild-openclaw-old-base-context.ts +++ b/test/e2e/live/rebuild-openclaw-old-base-context.ts @@ -18,12 +18,22 @@ export function oldBaseContextSources(): string[] { export function directDockerfileBaseCopySources(dockerfilePath = DOCKERFILE_BASE): string[] { const text = fs.readFileSync(dockerfilePath, "utf8"); const sources: string[] = []; + let logicalLine = ""; + let instructionStartLine = 0; for (const [lineIndex, rawLine] of text.split(/\r?\n/).entries()) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; - const instructionMatch = /^(\S+)\b([\s\S]*)$/.exec(line); + if (!logicalLine) instructionStartLine = lineIndex + 1; + const continues = line.endsWith("\\"); + const segment = continues ? line.slice(0, -1).trimEnd() : line; + logicalLine = `${logicalLine} ${segment}`.trim(); + if (continues) continue; + + const instructionMatch = /^(\S+)\b([\s\S]*)$/.exec(logicalLine); + const rawInstruction = logicalLine; + logicalLine = ""; if (!instructionMatch || instructionMatch[1].toUpperCase() !== "COPY") continue; const tokens = instructionMatch[2].trim().split(/\s+/).filter(Boolean); @@ -34,14 +44,22 @@ export function directDockerfileBaseCopySources(dockerfilePath = DOCKERFILE_BASE ); if (hasStageSource) continue; - if (nonFlagTokens.length !== 2 || nonFlagTokens[0]?.startsWith("[")) { + if (nonFlagTokens.length < 2 || nonFlagTokens[0]?.startsWith("[")) { throw new Error( - `Unsupported direct Dockerfile.base COPY form at line ${lineIndex + 1}: ${rawLine}`, + `Unsupported direct Dockerfile.base COPY form at line ${instructionStartLine}: ${rawInstruction}`, ); } - validateOldBaseContextSource(nonFlagTokens[0]); - sources.push(nonFlagTokens[0]); + for (const source of nonFlagTokens.slice(0, -1)) { + validateOldBaseContextSource(source); + sources.push(source); + } + } + + if (logicalLine) { + throw new Error( + `Unsupported unterminated Dockerfile.base instruction at line ${instructionStartLine}: ${logicalLine}`, + ); } return sources; diff --git a/test/e2e/live/registry-targets.test.ts b/test/e2e/live/registry-targets.test.ts index 68b5df9c3a3..713e34bd28c 100644 --- a/test/e2e/live/registry-targets.test.ts +++ b/test/e2e/live/registry-targets.test.ts @@ -41,6 +41,7 @@ const SELECTED_TARGET_ID = process.env.TARGET_ID; const REGISTRY_TARGET_PHASES = [ "resolve the target contract and run plan", "confirm the target environment is ready", + "prepare the target lifecycle prerequisites", "onboard the registry-selected sandbox", "execute the target lifecycle boundary", "verify the expected sandbox state", @@ -103,6 +104,19 @@ for (const target of listTargets()) { progress.phase("confirm the target environment is ready"); const ready = await environment.assertReady(target.environment); + const profile = target.environment.lifecycle; + const lifecycleProfile = isLifecycleProfile(profile) ? profile : undefined; + if (profile && !lifecycleProfile) { + throw new Error( + `target '${target.id}' declares lifecycle '${profile}' which is not ` + + `dispatched by LifecyclePhaseFixture; update the fixture and the ` + + `SUPPORTED_LIFECYCLES whitelist together.`, + ); + } + progress.phase("prepare the target lifecycle prerequisites"); + await (lifecycleProfile === "post-reboot-recovery" + ? lifecycle.preparePostReboot() + : Promise.resolve()); progress.phase("onboard the registry-selected sandbox"); const instance = await onboard.from(ready, { sandboxName: `e2e-${target.id}` }); @@ -112,29 +126,21 @@ for (const target of listTargets()) { // runtime-support.ts). Profiles dispatch through // LifecyclePhaseFixture before state validation. let lifecycleResult: Awaited> | undefined; - const profile = target.environment.lifecycle; // Every registry target crosses the optional lifecycle boundary before // state validation. progress.phase("execute the target lifecycle boundary"); - if (profile) { - if (!isLifecycleProfile(profile)) { - throw new Error( - `target '${target.id}' declares lifecycle '${profile}' which is not ` + - `dispatched by LifecyclePhaseFixture; update the fixture and the ` + - `SUPPORTED_LIFECYCLES whitelist together.`, - ); - } + if (lifecycleProfile) { lifecycleResult = - profile === "dcode-rebuild-invalid-credential" + lifecycleProfile === "dcode-rebuild-invalid-credential" ? await lifecycle.simulate( - profile, + lifecycleProfile, instance, dcodeInvalidCredentialRebuildOptionsFromRegistryEntry( readRegistrySandboxEntry(instance.sandboxName), secrets.required(HOSTED_INFERENCE_SECRET), ), ) - : await lifecycle.simulate(profile, instance); + : await lifecycle.simulate(lifecycleProfile, instance); } progress.phase("verify the expected sandbox state"); diff --git a/test/e2e/manifests/openclaw-nvidia-post-reboot-recovery.yaml b/test/e2e/manifests/openclaw-nvidia-post-reboot-recovery.yaml index 8361f7992db..99232862f1f 100644 --- a/test/e2e/manifests/openclaw-nvidia-post-reboot-recovery.yaml +++ b/test/e2e/manifests/openclaw-nvidia-post-reboot-recovery.yaml @@ -22,11 +22,13 @@ spec: policyTier: balanced messaging: [] # Lifecycle phase opt-in. The Vitest live runner dispatches this - # profile through `LifecyclePhaseFixture.simulate(...)`, which: - # 1. `docker stop`s the labeled sandbox container (gateway is - # left HEALTHY; see definitions/baseline.ts for why a real - # gateway restart can't be expressed from `ubuntu-latest`), - # 2. invokes `nemoclaw status` so any destructive + # profile through `LifecyclePhaseFixture`, which: + # 1. ensures OpenShell is installed and stages the managed gateway + # user service before onboarding so NemoClaw writes the service's + # Docker-driver environment, + # 2. `docker stop`s the labeled sandbox container, then restarts + # the gateway through that service, + # 3. invokes `nemoclaw status` so any destructive # registry/container path runs against host-observable # state. # The host-side state-validation probes diff --git a/test/e2e/support/e2e-phase-lifecycle.test.ts b/test/e2e/support/e2e-phase-lifecycle.test.ts index d2be7cb3bd0..257708a567d 100644 --- a/test/e2e/support/e2e-phase-lifecycle.test.ts +++ b/test/e2e/support/e2e-phase-lifecycle.test.ts @@ -109,16 +109,58 @@ function fixture(runner: FakeRunner, cleanup: FakeCleanup): LifecyclePhaseFixtur return new LifecyclePhaseFixture(host, sandbox, cleanup); } +async function preparedPostRebootFixture( + runner: FakeRunner, + cleanup: FakeCleanup, + stage: "upstream" | "existing" | "staged" = "existing", +): Promise { + runner.enqueue(shellResult(0)); // openshell-gateway available + runner.enqueue(shellResult(0, `NEMOCLAW_E2E_GATEWAY_USER_SERVICE=${stage}\n`)); + const prepared = fixture(runner, cleanup); + await prepared.preparePostReboot(); + return prepared; +} + function restoreEnv(name: string, value: string | undefined): void { Reflect.deleteProperty(process.env, name); Object.assign(process.env, value === undefined ? {} : { [name]: value }); } +describe("LifecyclePhaseFixture.preparePostReboot", () => { + it("installs OpenShell and stages the gateway user service when openshell-gateway is unavailable", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(1)); // openshell-gateway unavailable + runner.enqueue(shellResult(0)); // install OpenShell + runner.enqueue(shellResult(0, "NEMOCLAW_E2E_GATEWAY_USER_SERVICE=staged\n")); + const cleanup = new FakeCleanup(); + + const result = await fixture(runner, cleanup).preparePostReboot(); + + expect(result).toBe("staged"); + expect(runner.calls.map((call) => `${call.command} ${call.args.join(" ")}`)).toEqual([ + expect.stringContaining('bash -lc command -v "$1"'), + expect.stringContaining("bash "), + expect.stringContaining("bash -lc set -eu"), + ]); + expect(runner.calls[1]?.options?.artifactName).toBe("lifecycle-prereq-install-openshell"); + expect(cleanup.calls.map((call) => call.name)).toEqual([ + "lifecycle.remove-staged-gateway-user-service", + ]); + }); + + it("rejects post-reboot simulation that was not prepared before onboarding", async () => { + await expect( + fixture(new FakeRunner(), new FakeCleanup()).simulate("post-reboot-recovery", instance()), + ).rejects.toThrow(/must be prepared before post-reboot onboarding/); + }); +}); + describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", () => { it("stops the labeled container, restarts the gateway service, then runs status", async () => { const runner = new FakeRunner(); + const cleanup = new FakeCleanup(); + const prepared = await preparedPostRebootFixture(runner, cleanup, "staged"); runner.enqueue(shellResult(0, "openshell-cluster-e2e-ubuntu-repo-cloud-openclaw\n")); // discover - runner.enqueue(shellResult(0, "NEMOCLAW_E2E_GATEWAY_USER_SERVICE=staged\n")); // stage service runner.enqueue(shellResult(0)); // docker stop runner.enqueue(shellResult(0)); // forward stop runner.enqueue(shellResult(0)); // gateway stop @@ -127,9 +169,8 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", runner.enqueue(shellResult(0)); // user service restart runner.enqueue(shellResult(0, "Connected to nemoclaw\n")); // openshell status runner.enqueue(shellResult(1, "Removed stale local registry entry.\n")); // status (non-zero on unfixed) - const cleanup = new FakeCleanup(); - const result = await fixture(runner, cleanup).simulate("post-reboot-recovery", instance()); + const result = await prepared.simulate("post-reboot-recovery", instance()); expect(result.profile).toBe("post-reboot-recovery"); expect(result.steps.map((step) => step.id)).toEqual([ @@ -139,8 +180,9 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", "nemoclaw-status:e2e-ubuntu-repo-cloud-openclaw", ]); expect(runner.calls.map((call) => `${call.command} ${call.args.join(" ")}`)).toEqual([ - "docker ps -a --filter label=openshell.ai/sandbox-name=e2e-ubuntu-repo-cloud-openclaw --format {{.Names}}", + expect.stringContaining('bash -lc command -v "$1"'), expect.stringContaining("bash -lc set -eu"), + "docker ps -a --filter label=openshell.ai/sandbox-name=e2e-ubuntu-repo-cloud-openclaw --format {{.Names}}", "docker stop openshell-cluster-e2e-ubuntu-repo-cloud-openclaw", "sh -lc command -v openshell >/dev/null 2>&1 && openshell forward stop 18789 || true", "sh -lc command -v openshell >/dev/null 2>&1 && openshell gateway stop -g nemoclaw || true", @@ -158,8 +200,9 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", it("tolerates a non-zero status exit (the bug succeeds at destroying state)", async () => { const runner = new FakeRunner(); + const cleanup = new FakeCleanup(); + const prepared = await preparedPostRebootFixture(runner, cleanup); runner.enqueue(shellResult(0, "container-1\n")); // discover - runner.enqueue(shellResult(0, "NEMOCLAW_E2E_GATEWAY_USER_SERVICE=existing\n")); // service runner.enqueue(shellResult(0)); // docker stop runner.enqueue(shellResult(0)); // forward stop runner.enqueue(shellResult(0)); // gateway stop @@ -168,9 +211,8 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", runner.enqueue(shellResult(0)); // user service restart runner.enqueue(shellResult(0, "Connected to nemoclaw\n")); // openshell status runner.enqueue(shellResult(1, "Removed stale local registry entry.\n")); // status non-zero - const cleanup = new FakeCleanup(); - const result = await fixture(runner, cleanup).simulate("post-reboot-recovery", instance()); + const result = await prepared.simulate("post-reboot-recovery", instance()); // simulate() does not throw; the post-status invariants belong // to the state-validation phase that runs after. @@ -179,39 +221,41 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", it("fails when no Docker container carries the OpenShell sandbox-name label", async () => { const runner = new FakeRunner(); - runner.enqueue(shellResult(0, "\n")); // discover returns nothing const cleanup = new FakeCleanup(); + const prepared = await preparedPostRebootFixture(runner, cleanup); + runner.enqueue(shellResult(0, "\n")); // discover returns nothing - await expect( - fixture(runner, cleanup).simulate("post-reboot-recovery", instance()), - ).rejects.toThrow(/expected at least one Docker container labeled/); + await expect(prepared.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(1, "Cannot connect to the Docker daemon")); const cleanup = new FakeCleanup(); + const prepared = await preparedPostRebootFixture(runner, cleanup); + runner.enqueue(shellResult(1, "Cannot connect to the Docker daemon")); - await expect( - fixture(runner, cleanup).simulate("post-reboot-recovery", instance()), - ).rejects.toThrow(/could not query Docker for label/); + await expect(prepared.simulate("post-reboot-recovery", instance())).rejects.toThrow( + /could not query Docker for label/, + ); }); it("fails when the managed OpenShell gateway user service is unavailable", async () => { const runner = new FakeRunner(); + const cleanup = new FakeCleanup(); + const prepared = await preparedPostRebootFixture(runner, cleanup); runner.enqueue(shellResult(0, "container-1\n")); // discover - runner.enqueue(shellResult(0, "NEMOCLAW_E2E_GATEWAY_USER_SERVICE=existing\n")); // service runner.enqueue(shellResult(0)); // docker stop runner.enqueue(shellResult(0)); // forward stop runner.enqueue(shellResult(0)); // gateway stop runner.enqueue(shellResult(0)); // pid stop runner.enqueue(shellResult(0)); // container stop runner.enqueue(shellResult(75, "")); // no managed user service available - const cleanup = new FakeCleanup(); - await expect( - fixture(runner, cleanup).simulate("post-reboot-recovery", instance()), - ).rejects.toThrow(/OpenShell gateway user service is not available/); + await expect(prepared.simulate("post-reboot-recovery", instance())).rejects.toThrow( + /OpenShell gateway user service is not available/, + ); expect(runner.calls.map((call) => `${call.command} ${call.args.join(" ")}`)).toEqual( expect.arrayContaining([expect.stringContaining('systemctl --user cat "$service"')]), @@ -222,8 +266,9 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", 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(); + const cleanup = new FakeCleanup(); + const prepared = await preparedPostRebootFixture(runner, cleanup); runner.enqueue(shellResult(0, "openshell-cluster-e2e-x\n")); // discover - runner.enqueue(shellResult(0, "NEMOCLAW_E2E_GATEWAY_USER_SERVICE=existing\n")); // service runner.enqueue(shellResult(0)); // docker stop runner.enqueue(shellResult(0)); // docker rename runner.enqueue(shellResult(0)); // forward stop @@ -233,9 +278,8 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (rename-to-gpu-bac runner.enqueue(shellResult(0)); // user service restart runner.enqueue(shellResult(0, "Connected to nemoclaw\n")); // openshell status runner.enqueue(shellResult(1, "Removed stale local registry entry.\n")); // status - const cleanup = new FakeCleanup(); - const result = await fixture(runner, cleanup).simulate( + const result = await prepared.simulate( "post-reboot-recovery", instance({ sandboxName: "e2e-x" }), { mode: "rename-to-gpu-backup" }, @@ -335,6 +379,29 @@ describe("LifecyclePhaseFixture gateway runtime restart helpers", () => { ]); }); + it("captures the OpenShell gateway user service status and journal when gateway health never recovers", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(1, "Connection refused")); // openshell status + runner.enqueue(shellResult(0, "ActiveState=failed\nResult=exit-code\n")); // diagnostics + const cleanup = new FakeCleanup(); + const fx = fixture(runner, cleanup); + + await expect(fx.waitForGatewayConnected({ attempts: 1, intervalMs: 1 })).rejects.toThrow( + /service diagnostics: \/tmp\/result\.json/, + ); + + expect(runner.calls).toHaveLength(2); + expect(runner.calls[1]).toMatchObject({ + command: "sh", + options: { + artifactName: "lifecycle-gateway-user-service-diagnostics", + }, + }); + expect(runner.calls[1]?.args[1]).toContain( + 'journalctl --user --unit "$service" --no-pager --lines=200', + ); + }); + it("stops only the exact gateway container when a sandbox has the gateway-name prefix", async () => { const runner = new FakeRunner(); runner.enqueue(shellResult(0, "12345\n")); // resolveHostRuntime pid probe diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-env.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-env.test.ts deleted file mode 100644 index c18f2a3b1d8..00000000000 --- a/test/e2e/support/openclaw-plugin-runtime-exdev-env.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { - buildOpenClawPluginRuntimeExdevBaseImageEnv, - CURRENT_LIFECYCLE_TEST_SELECTOR, - RELEASE_BASELINE_TEST_SELECTOR, - RELEASE_SANDBOX_BASE_IMAGE_REF, -} from "../live/openclaw-plugin-runtime-exdev-env.ts"; - -describe("OpenClaw plugin runtime EXDEV base image selection", () => { - it("pins the release baseline to its matching sandbox base image", () => { - expect(buildOpenClawPluginRuntimeExdevBaseImageEnv(RELEASE_BASELINE_TEST_SELECTOR)).toEqual({ - NEMOCLAW_SANDBOX_BASE_IMAGE_REF: RELEASE_SANDBOX_BASE_IMAGE_REF, - }); - }); - - it("does not override base-image resolution for the current-lifecycle test", () => { - expect(buildOpenClawPluginRuntimeExdevBaseImageEnv(CURRENT_LIFECYCLE_TEST_SELECTOR)).toEqual( - {}, - ); - }); -}); diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-fixture.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-fixture.test.ts new file mode 100644 index 00000000000..592f849d763 --- /dev/null +++ b/test/e2e/support/openclaw-plugin-runtime-exdev-fixture.test.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + CURRENT_LIFECYCLE_TEST_SELECTOR, + RELEASE_BASELINE_TEST_SELECTOR, + RELEASE_SANDBOX_BASE_IMAGE_REF, + resolveOpenClawPluginRuntimeExdevFixture, +} from "../live/openclaw-plugin-runtime-exdev-fixture.ts"; + +describe("OpenClaw plugin runtime EXDEV fixture selection", () => { + it("keeps the release baseline on its matching source and sandbox base image", () => { + expect(resolveOpenClawPluginRuntimeExdevFixture(RELEASE_BASELINE_TEST_SELECTOR)).toEqual({ + selector: RELEASE_BASELINE_TEST_SELECTOR, + source: "release", + baseImageEnv: { + NEMOCLAW_SANDBOX_BASE_IMAGE_REF: RELEASE_SANDBOX_BASE_IMAGE_REF, + }, + openClawModulePath: "/usr/local/lib/node_modules/openclaw", + }); + }); + + it("uses checkout source with CLI-selected base-image resolution and the managed OpenClaw module path", () => { + expect(resolveOpenClawPluginRuntimeExdevFixture(CURRENT_LIFECYCLE_TEST_SELECTOR)).toEqual({ + selector: CURRENT_LIFECYCLE_TEST_SELECTOR, + source: "current", + baseImageEnv: {}, + openClawModulePath: "/usr/local/lib/nemoclaw/openclaw-runtime/node_modules/openclaw", + }); + }); +}); diff --git a/test/e2e/support/rebuild-openclaw-old-base-context.test.ts b/test/e2e/support/rebuild-openclaw-old-base-context.test.ts index f0c026217ab..4ad599b0bb7 100644 --- a/test/e2e/support/rebuild-openclaw-old-base-context.test.ts +++ b/test/e2e/support/rebuild-openclaw-old-base-context.test.ts @@ -60,6 +60,28 @@ describe("rebuild-openclaw old-base build context", () => { ]); }); + it("returns every source from a multiline direct COPY instruction", () => { + const dockerfilePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "e2e-rebuild-openclaw-dockerfile-")), + "Dockerfile.base", + ); + testFiles.push(path.dirname(dockerfilePath)); + fs.writeFileSync( + dockerfilePath, + [ + "COPY agents/openclaw/openclaw-runtime/package.json \\", + " agents/openclaw/openclaw-runtime/package-lock.json \\", + " /usr/local/lib/nemoclaw/openclaw-runtime/", + ].join("\n"), + "utf8", + ); + + expect(directDockerfileBaseCopySources(dockerfilePath)).toEqual([ + "agents/openclaw/openclaw-runtime/package.json", + "agents/openclaw/openclaw-runtime/package-lock.json", + ]); + }); + it("rejects out-of-context direct Dockerfile.base COPY sources before staging", () => { const parentRelativeDockerfilePath = path.join( fs.mkdtempSync(path.join(os.tmpdir(), "e2e-rebuild-openclaw-dockerfile-")),