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
208 changes: 208 additions & 0 deletions .github/workflows/wsl-e2e.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

name: wsl-e2e

on:
workflow_dispatch:
pull_request:
paths:
- "bin/**"
- "nemoclaw/**"
- "scripts/**"
- "test/**"
- ".github/workflows/wsl-e2e.yaml"
- "package.json"
- "vitest.config.ts"
push:
branches:
- main

permissions:
contents: read

concurrency:
group: wsl-e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
wsl-e2e:
runs-on: windows-latest
timeout-minutes: 90
env:
WSL_DISTRO: Ubuntu
NEMOCLAW_NON_INTERACTIVE: "1"
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
NEMOCLAW_RECREATE_SANDBOX: "1"
NEMOCLAW_SANDBOX_NAME: "e2e-wsl"
steps:
- name: Force LF line endings for checkout
shell: powershell
run: git config --global core.autocrlf false

- name: Checkout
uses: actions/checkout@v6

- name: Resolve workspace path for WSL
shell: powershell
run: |
$winPath = "${{ github.workspace }}"
$drive = $winPath.Substring(0,1).ToLower()
$rest = $winPath.Substring(2).Replace('\','/')
$wslPath = "/mnt/$drive$rest"
"WSL_WORKDIR=$wslPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
Write-Host "WSL_WORKDIR=$wslPath"

- name: Ensure Ubuntu WSL exists
shell: powershell
run: |
wsl --list --verbose 2>&1 | Out-Default
# Native commands do not throw in PowerShell; check LASTEXITCODE.
$null = wsl -d $env:WSL_DISTRO -- echo ok 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host 'Ubuntu not found - installing via wsl --install'
wsl --install -d $env:WSL_DISTRO --no-launch --web-download
if ($LASTEXITCODE -ne 0) {
throw ('wsl --install failed with exit code ' + $LASTEXITCODE)
}
# The first launch initialises the distro with the default root user.
wsl -d $env:WSL_DISTRO -- bash -c 'echo distro initialised'
if ($LASTEXITCODE -ne 0) {
throw ('distro first-launch failed with exit code ' + $LASTEXITCODE)
}
} else {
Write-Host 'Ubuntu already available'
}
wsl --set-default $env:WSL_DISTRO
if ($LASTEXITCODE -ne 0) {
throw ('wsl --set-default failed with exit code ' + $LASTEXITCODE)
}

- name: Verify WSL
shell: powershell
run: |
wsl -d $env:WSL_DISTRO -- bash -lc "uname -a"
wsl -d $env:WSL_DISTRO -- bash -lc "cat /etc/os-release"

- name: Install Ubuntu dependencies
shell: powershell
run: |
$script = @'
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y bash ca-certificates curl git jq lsb-release make python3 python3-pip rsync tar unzip xz-utils
'@
$tmp = "$env:RUNNER_TEMP\wsl-step.sh"
[IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false))
$wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/')
wsl -d $env:WSL_DISTRO -- bash -l $wslTmp

- name: Install Node.js 22 in WSL
shell: powershell
run: |
$script = @'
set -euo pipefail
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
node --version
npm --version
'@
$tmp = "$env:RUNNER_TEMP\wsl-step.sh"
[IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false))
$wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/')
wsl -d $env:WSL_DISTRO -- bash -l $wslTmp

- name: Install project dependencies and build plugin
shell: powershell
run: |
$script = @"
set -euo pipefail
cd '$env:WSL_WORKDIR'
npm install --ignore-scripts
npm run build:cli
cd nemoclaw
npm install --ignore-scripts
npm run build
"@
$tmp = "$env:RUNNER_TEMP\wsl-step.sh"
[IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false))
$wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/')
wsl -d $env:WSL_DISTRO -- bash -l $wslTmp

- name: Detect Docker availability in WSL
id: docker
shell: powershell
run: |
$script = @'
if docker info >/dev/null 2>&1; then
echo DOCKER_OK=1
else
echo DOCKER_OK=0
fi
'@
$tmp = "$env:RUNNER_TEMP\wsl-step.sh"
[IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false))
$wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/')
$result = wsl -d $env:WSL_DISTRO -- bash -l $wslTmp
if ($result -match 'DOCKER_OK=1') {
'docker_ok=true' | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
Write-Host 'Docker is available in WSL'
} else {
'docker_ok=false' | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
Write-Host 'Docker is not available in WSL; full E2E will be skipped'
}

- name: Run WSL compatibility test suite
shell: powershell
run: |
$script = @"
set -euo pipefail
cd '$env:WSL_WORKDIR'
export NEMOCLAW_EXEC_TIMEOUT=30000
export NEMOCLAW_TEST_TIMEOUT=60000
npx vitest run --testTimeout 60000
"@
$tmp = "$env:RUNNER_TEMP\wsl-step.sh"
[IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false))
$wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/')
wsl -d $env:WSL_DISTRO -- bash -l $wslTmp

- name: Run WSL full E2E
if: steps.docker.outputs.docker_ok == 'true'
shell: powershell
env:
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
GITHUB_TOKEN: ${{ github.token }}
run: |
$script = @"
set -euo pipefail
cd '$env:WSL_WORKDIR'
export NVIDIA_API_KEY='$env:NVIDIA_API_KEY'
export GITHUB_TOKEN='$env:GITHUB_TOKEN'
export NEMOCLAW_NON_INTERACTIVE='$env:NEMOCLAW_NON_INTERACTIVE'
export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE='$env:NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE'
export NEMOCLAW_RECREATE_SANDBOX='$env:NEMOCLAW_RECREATE_SANDBOX'
export NEMOCLAW_SANDBOX_NAME='$env:NEMOCLAW_SANDBOX_NAME'
bash test/e2e/test-full-e2e.sh
"@
$tmp = "$env:RUNNER_TEMP\wsl-step.sh"
[IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false))
$wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/')
wsl -d $env:WSL_DISTRO -- bash -l $wslTmp

- name: Explain skipped full E2E
if: steps.docker.outputs.docker_ok != 'true'
shell: powershell
run: |
Write-Host 'Skipping WSL full E2E because Docker is unavailable on this runner.'
Write-Host 'The workflow still validated the NemoClaw build and test flow inside Ubuntu WSL.'

- name: Upload install log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: wsl-e2e-install-log
path: |
C:\Users\runneradmin\AppData\Local\Temp\nemoclaw-e2e-install.log
if-no-files-found: ignore
28 changes: 14 additions & 14 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function run(args) {
return runWithEnv(args);
}

function runWithEnv(args, env = {}, timeout = 10000) {
function runWithEnv(args, env = {}, timeout = Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000)) {
try {
const out = execSync(`node "${CLI}" ${args}`, {
encoding: "utf-8",
Expand Down Expand Up @@ -1518,15 +1518,15 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
10000,
Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000),
);

expect(r.code).toBe(0);
expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy();
expect(r.out.includes("gateway identity drift after restart")).toBeTruthy();
const saved = JSON.parse(fs.readFileSync(path.join(registryDir, "sandboxes.json"), "utf8"));
expect(saved.sandboxes.alpha).toBeTruthy();
}, 10000);
}, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000));

it("recovers status after gateway runtime is reattached", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-recover-status-"));
Expand Down Expand Up @@ -1740,14 +1740,14 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
10000,
Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000),
);

expect(r.code).toBe(0);
expect(r.out.includes("Recovered NemoClaw gateway runtime")).toBeFalsy();
expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy();
expect(r.out.includes("verify the active gateway")).toBeTruthy();
}, 10000);
}, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000));

it("matches ANSI-decorated gateway transport errors when printing lifecycle hints", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-transport-hint-"));
Expand Down Expand Up @@ -1804,12 +1804,12 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
10000,
Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000),
);

expect(r.code).toBe(0);
expect(r.out.includes("current gateway/runtime is not reachable")).toBeTruthy();
}, 10000);
}, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000));

it("matches ANSI-decorated gateway auth errors when printing lifecycle hints", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-auth-hint-"));
Expand Down Expand Up @@ -1866,14 +1866,14 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
10000,
Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000),
);

expect(r.code).toBe(0);
expect(
r.out.includes("Verify the active gateway and retry after re-establishing the runtime."),
).toBeTruthy();
}, 10000);
}, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000));

it("explains unrecoverable gateway trust rotation after restart", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-identity-drift-"));
Expand Down Expand Up @@ -1929,7 +1929,7 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
10000,
Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000),
);
expect(statusResult.code).toBe(0);
expect(statusResult.out.includes("gateway trust material rotated after restart")).toBeTruthy();
Expand Down Expand Up @@ -2006,7 +2006,7 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
10000,
Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000),
);
expect(statusResult.code).toBe(0);
expect(
Expand All @@ -2025,7 +2025,7 @@ describe("CLI dispatch", () => {
connectResult.out.includes("gateway is still refusing connections after restart"),
).toBeTruthy();
expect(connectResult.out.includes("If the gateway never becomes healthy")).toBeTruthy();
}, 10000);
}, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000));

it("explains when the named gateway is no longer configured after restart or rebuild", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-missing-"));
Expand Down Expand Up @@ -2083,14 +2083,14 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
10000,
Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000),
);
expect(statusResult.code).toBe(0);
expect(
statusResult.out.includes("gateway is no longer configured after restart/rebuild"),
).toBeTruthy();
expect(statusResult.out.includes("Start the gateway again")).toBeTruthy();
}, 10000);
}, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000));
});

describe("list shows live gateway inference", () => {
Expand Down
2 changes: 1 addition & 1 deletion test/nemoclaw-cli-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { describe, it } from "vitest";
describe("nemoclaw CLI runtime recovery", () => {
it(
"recovers sandbox status when openshell is only available via the resolved fallback path",
{ timeout: 15_000 },
{ timeout: Number(process.env.NEMOCLAW_TEST_TIMEOUT || 15_000) },
() => {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-recovery-"));
Expand Down
2 changes: 2 additions & 0 deletions test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ describe("onboard helpers", () => {
const command = getDashboardForwardStartCommand("the-crucible", {
chatUiUrl: "http://127.0.0.1:18789",
openshellBinary: "/usr/bin/openshell",
isWsl: false,
});

expect(command).toContain("--background");
Expand All @@ -460,6 +461,7 @@ describe("onboard helpers", () => {
const command = getDashboardForwardStartCommand("the-crucible", {
chatUiUrl: "http://127.0.0.1:19000",
openshellBinary: "/usr/bin/openshell",
isWsl: false,
});

expect(command).toContain("--background");
Expand Down
4 changes: 2 additions & 2 deletions test/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ selectFromList(items, options)
return spawnSync(process.execPath, ["-e", script], {
cwd: REPO_ROOT,
encoding: "utf-8",
timeout: 5000,
timeout: Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 5000),
input,
env: {
...process.env,
Expand Down Expand Up @@ -795,7 +795,7 @@ selectForRemoval(items, options)
return spawnSync(process.execPath, ["-e", script], {
cwd: REPO_ROOT,
encoding: "utf-8",
timeout: 5000,
timeout: Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 5000),
input,
env: {
...process.env,
Expand Down
2 changes: 1 addition & 1 deletion test/presets-checkbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ presetsCheckboxSelector(presets, initialSelected)
return spawnSync(process.execPath, ["-e", script], {
cwd: REPO_ROOT,
encoding: "utf-8",
timeout: 5000,
timeout: Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 5000),
env: {
...process.env,
NEMOCLAW_TEST_PRESETS: JSON.stringify(presets),
Expand Down
Loading
Loading