diff --git a/test/e2e-scenario/framework-tests/e2e-clients.test.ts b/test/e2e-scenario/framework-tests/e2e-clients.test.ts new file mode 100644 index 00000000000..25c893d5f1a --- /dev/null +++ b/test/e2e-scenario/framework-tests/e2e-clients.test.ts @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { assertExitZero, type CommandRunner } from "../framework/clients/index.ts"; +import { + GatewayClient, + HostCliClient, + ProviderClient, + SandboxClient, + StateClient, + trustedProviderEndpoint, +} from "../framework/clients/index.ts"; +import type { ShellProbeResult, ShellProbeRunOptions, TrustedShellCommand } from "../framework/shell-probe.ts"; + +interface RunnerCall { + command: string; + args: string[]; + options?: ShellProbeRunOptions; +} + +class FakeRunner implements CommandRunner { + readonly calls: RunnerCall[] = []; + stdout = ""; + stderr = ""; + exitCode: number | null = 0; + signal: NodeJS.Signals | null = null; + + async run(command: TrustedShellCommand, options?: ShellProbeRunOptions): Promise { + this.calls.push({ command: command.command, args: [...command.args], options }); + return { + command: [command.command, ...command.args], + exitCode: this.exitCode, + signal: this.signal, + timedOut: false, + stdout: this.stdout, + stderr: this.stderr, + artifacts: { + stdout: "/tmp/stdout.txt", + stderr: "/tmp/stderr.txt", + result: "/tmp/result.json", + }, + }; + } +} + +describe("E2E fixture clients", () => { + it("host client runs the configured NemoClaw CLI", async () => { + const runner = new FakeRunner(); + runner.stdout = "nemoclaw 0.1.0\n"; + const host = new HostCliClient(runner, { cliPath: "./bin/nemoclaw.js" }); + + await host.expectNemoclawAvailable(); + + expect(runner.calls).toEqual([ + { + command: "./bin/nemoclaw.js", + args: ["--version"], + options: { artifactName: "nemoclaw-version" }, + }, + ]); + }); + + it("host client propagates cwd, env, and timeout options", async () => { + const runner = new FakeRunner(); + const host = new HostCliClient(runner, { cliPath: "nemoclaw", cwd: "/tmp/project" }); + + await host.nemoclaw(["status"], { + env: { NEMOCLAW_TEST_VALUE: "1" }, + timeoutMs: 123, + }); + + expect(runner.calls[0]).toEqual({ + command: "nemoclaw", + args: ["status"], + options: { + artifactName: "nemoclaw-status", + cwd: "/tmp/project", + env: { NEMOCLAW_TEST_VALUE: "1" }, + timeoutMs: 123, + }, + }); + }); + + it("gateway client delegates through NemoClaw gateway status", async () => { + const runner = new FakeRunner(); + const host = new HostCliClient(runner, { cliPath: "nemoclaw" }); + const gateway = new GatewayClient(host); + + await gateway.expectHealthy(); + + expect(runner.calls[0]).toEqual({ + command: "nemoclaw", + args: ["gateway", "status"], + options: { artifactName: "gateway-status" }, + }); + }); + + it("sandbox client builds OpenShell sandbox commands", async () => { + const runner = new FakeRunner(); + const sandbox = new SandboxClient(runner, { openshellPath: "openshell" }); + + await sandbox.exec("assistant", ["echo", "ok"]); + + expect(runner.calls[0]).toEqual({ + command: "openshell", + args: ["sandbox", "exec", "assistant", "--", "echo", "ok"], + options: { + artifactName: "sandbox-exec-assistant", + }, + }); + }); + + it("sandbox client rejects flag-shaped sandbox names before command construction", async () => { + const runner = new FakeRunner(); + const sandbox = new SandboxClient(runner, { openshellPath: "openshell" }); + + await expect(() => sandbox.status("--bad")).toThrow(/sandbox name is invalid/); + expect(runner.calls).toEqual([]); + }); + + it("sandbox client preserves shell-looking payloads as argv after --", async () => { + const runner = new FakeRunner(); + const sandbox = new SandboxClient(runner, { openshellPath: "openshell" }); + + await sandbox.exec("assistant", ["sh", "-c", "echo '$TOKEN' && rm -rf /tmp/not-real"]); + + expect(runner.calls[0]?.args).toEqual([ + "sandbox", + "exec", + "assistant", + "--", + "sh", + "-c", + "echo '$TOKEN' && rm -rf /tmp/not-real", + ]); + }); + + it("provider client parses JSON from curl output", async () => { + const runner = new FakeRunner(); + runner.stdout = JSON.stringify({ ok: true }); + const provider = new ProviderClient(runner); + + await expect(provider.getJson(trustedProviderEndpoint("http://127.0.0.1:8080/health"))).resolves.toEqual({ ok: true }); + expect(runner.calls[0]).toEqual({ + command: "curl", + args: ["-fsS", "http://127.0.0.1:8080/health"], + options: { + artifactName: "curl-http-127.0.0.1-8080-health", + redactionValues: [], + }, + }); + }); + + it("provider client does not follow redirects after endpoint validation", async () => { + const runner = new FakeRunner(); + runner.stdout = JSON.stringify({ ok: true }); + const provider = new ProviderClient(runner); + const endpoint = trustedProviderEndpoint("https://api.example.test/v1/models", { + allowedHosts: ["api.example.test"], + }); + + await provider.getJson(endpoint); + + expect(runner.calls[0]?.args).toEqual(["-fsS", "https://api.example.test/v1/models"]); + expect(runner.calls[0]?.args).not.toContain("-L"); + }); + + it("provider endpoint rejects unsafe schemes, hosts, and userinfo", () => { + expect(() => trustedProviderEndpoint("file:///etc/passwd")).toThrow(/protocol/); + expect(() => trustedProviderEndpoint("http://example.com/health")).toThrow(/loopback/); + expect(() => trustedProviderEndpoint("https://api.example.test/models")).toThrow(/allowedHosts/); + expect(() => trustedProviderEndpoint("http://169.254.169.254/latest/meta-data")).toThrow(/blocked/); + expect(() => trustedProviderEndpoint("https://token@example.com/models")).toThrow(/credentials/); + expect(() => + trustedProviderEndpoint("https://api.example.test/models", { allowedHosts: ["api.other.test"] }), + ).toThrow(/not allowed/); + expect(() => trustedProviderEndpoint("https://10.0.0.1/models", { allowedHosts: ["10.0.0.1"] })).toThrow( + /private or link-local/, + ); + expect(() => + trustedProviderEndpoint("https://[fd00::1]/models", { allowedHosts: ["fd00::1"] }), + ).toThrow(/private or link-local/); + }); + + it("provider endpoint allows loopback HTTP, including IPv6 loopback", () => { + expect(trustedProviderEndpoint("http://127.0.0.1:8080/health").url).toBe("http://127.0.0.1:8080/health"); + expect(trustedProviderEndpoint("http://[::1]:8080/health").url).toBe("http://[::1]:8080/health"); + }); + + it("provider client sanitizes labels and redacts credential-bearing query values", async () => { + const runner = new FakeRunner(); + runner.stdout = JSON.stringify({ ok: true }); + const provider = new ProviderClient(runner); + const endpoint = trustedProviderEndpoint( + "https://api.example.test/v1/models?api_key=query-token-value", + { allowedHosts: ["api.example.test"] }, + ); + + await expect(provider.getJson(endpoint)).resolves.toEqual({ ok: true }); + + expect(runner.calls[0]?.options?.artifactName).toBe("curl-https-api.example.test-v1-models"); + expect(runner.calls[0]?.options?.redactionValues).toEqual( + expect.arrayContaining(["api_key=query-token-value", "query-token-value"]), + ); + }); + + it("provider client reports invalid JSON without echoing response body", async () => { + const runner = new FakeRunner(); + runner.stdout = "not-json with query-token-value"; + const provider = new ProviderClient(runner); + const endpoint = trustedProviderEndpoint( + "https://api.example.test/v1/models?api_key=query-token-value", + { allowedHosts: ["api.example.test"] }, + ); + + await expect(provider.getJson(endpoint)).rejects.toThrow(/provider response was not JSON/); + await expect(provider.getJson(endpoint)).rejects.not.toThrow(/query-token-value|not-json/); + }); + + it("provider client failure labels omit query strings", async () => { + const runner = new FakeRunner(); + runner.exitCode = 22; + const provider = new ProviderClient(runner); + const endpoint = trustedProviderEndpoint("https://api.example.test/v1/models?api_key=query-token-value", { + allowedHosts: ["api.example.test"], + }); + + await expect(provider.getJson(endpoint)).rejects.toThrow("curl https://api.example.test/v1/models failed: exit=22"); + await expect(provider.getJson(endpoint)).rejects.not.toThrow(/query-token-value|api_key/); + }); + + it("assertExitZero reports non-zero and signaled commands", () => { + const result: ShellProbeResult = { + command: ["cmd"], + exitCode: 7, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + artifacts: { stdout: "", stderr: "", result: "" }, + }; + + expect(() => assertExitZero(result, "cmd")).toThrow("cmd failed: exit=7"); + expect(() => assertExitZero({ ...result, exitCode: null, signal: "SIGTERM" }, "cmd")).toThrow( + "cmd failed: signal=SIGTERM", + ); + }); + + it("state client reads text and JSON files", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-e2e-state-")); + try { + const file = path.join(tmp, "state.json"); + fs.writeFileSync(file, JSON.stringify({ sandbox: "assistant" }), "utf8"); + const state = new StateClient(); + + await expect(state.exists(file)).resolves.toBe(true); + await expect(state.exists(path.join(tmp, "missing.json"))).resolves.toBe(false); + await expect(state.readJson(file)).resolves.toEqual({ sandbox: "assistant" }); + await expect(state.exists(`bad${"\0"}path`)).rejects.toThrow(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/e2e-scenario/framework/clients/command.ts b/test/e2e-scenario/framework/clients/command.ts new file mode 100644 index 00000000000..9510fdb897f --- /dev/null +++ b/test/e2e-scenario/framework/clients/command.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ShellProbeResult, ShellProbeRunOptions, TrustedShellCommand } from "../shell-probe.ts"; + +export interface CommandRunner { + run(command: TrustedShellCommand, options?: ShellProbeRunOptions): Promise; +} + +export function assertExitZero(result: ShellProbeResult, label: string): void { + if (result.exitCode === 0) return; + const fallback = result.signal + ? `signal=${result.signal}` + : `exit=${result.exitCode ?? "unknown"}`; + const detail = result.stderr.trim() || result.stdout.trim() || fallback; + throw new Error(`${label} failed: ${detail}`); +} + +export function artifactLabel(raw: string): string { + const label = raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return label || "request"; +} diff --git a/test/e2e-scenario/framework/clients/gateway.ts b/test/e2e-scenario/framework/clients/gateway.ts new file mode 100644 index 00000000000..196d57c48a0 --- /dev/null +++ b/test/e2e-scenario/framework/clients/gateway.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ShellProbeResult } from "../shell-probe.ts"; +import { assertExitZero } from "./command.ts"; +import type { HostCliClient } from "./host.ts"; + +export class GatewayClient { + private readonly host: HostCliClient; + + constructor(host: HostCliClient) { + this.host = host; + } + + status(): Promise { + return this.host.nemoclaw(["gateway", "status"], { artifactName: "gateway-status" }); + } + + async expectHealthy(): Promise { + const result = await this.status(); + assertExitZero(result, "nemoclaw gateway status"); + return result; + } +} diff --git a/test/e2e-scenario/framework/clients/host.ts b/test/e2e-scenario/framework/clients/host.ts new file mode 100644 index 00000000000..9e0ee80ebd3 --- /dev/null +++ b/test/e2e-scenario/framework/clients/host.ts @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ShellProbeResult, ShellProbeRunOptions } from "../shell-probe.ts"; +import { trustedShellCommand } from "../shell-probe.ts"; +import { artifactLabel, assertExitZero, type CommandRunner } from "./command.ts"; + +export interface HostClientOptions { + cliPath?: string; + cwd?: string; +} + +export class HostCliClient { + private readonly runner: CommandRunner; + private readonly cliPath: string; + private readonly cwd?: string; + + constructor(runner: CommandRunner, options: HostClientOptions = {}) { + this.runner = runner; + this.cliPath = options.cliPath ?? process.env.NEMOCLAW_CLI_BIN ?? "nemoclaw"; + this.cwd = options.cwd; + } + + command(command: string, args: string[] = [], options: ShellProbeRunOptions = {}): Promise { + const merged: ShellProbeRunOptions = { ...options }; + if (this.cwd && !merged.cwd) { + merged.cwd = this.cwd; + } + return this.runner.run( + trustedShellCommand({ + command, + args, + reason: `run host command ${command}`, + }), + merged, + ); + } + + nemoclaw(args: string[] = [], options: ShellProbeRunOptions = {}): Promise { + return this.command(this.cliPath, args, { + artifactName: `nemoclaw-${artifactLabel(args.join("-") || "default")}`, + ...options, + }); + } + + async expectNemoclawAvailable(): Promise { + const result = await this.nemoclaw(["--version"], { artifactName: "nemoclaw-version" }); + assertExitZero(result, "nemoclaw --version"); + return result; + } +} diff --git a/test/e2e-scenario/framework/clients/index.ts b/test/e2e-scenario/framework/clients/index.ts new file mode 100644 index 00000000000..fb4c009d001 --- /dev/null +++ b/test/e2e-scenario/framework/clients/index.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { assertExitZero, type CommandRunner } from "./command.ts"; +export { GatewayClient } from "./gateway.ts"; +export { HostCliClient } from "./host.ts"; +export { ProviderClient, trustedProviderEndpoint, type TrustedProviderEndpoint } from "./provider.ts"; +export { SandboxClient } from "./sandbox.ts"; +export { StateClient } from "./state.ts"; diff --git a/test/e2e-scenario/framework/clients/provider.ts b/test/e2e-scenario/framework/clients/provider.ts new file mode 100644 index 00000000000..0a968e6421c --- /dev/null +++ b/test/e2e-scenario/framework/clients/provider.ts @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isIP } from "node:net"; + +import type { ShellProbeResult, ShellProbeRunOptions } from "../shell-probe.ts"; +import { trustedShellCommand } from "../shell-probe.ts"; +import { artifactLabel, assertExitZero, type CommandRunner } from "./command.ts"; + +const trustedProviderEndpointBrand: unique symbol = Symbol("TrustedProviderEndpoint"); + +export interface TrustedProviderEndpoint { + readonly url: string; + readonly artifactLabel: string; + readonly logLabel: string; + readonly redactionValues: readonly string[]; + readonly [trustedProviderEndpointBrand]: true; +} + +export interface TrustedProviderEndpointOptions { + /** + * Static framework-owned trust configuration for external HTTPS provider + * endpoints. Do not populate this from scenario manifests or user input. + */ + allowedHosts?: readonly string[]; +} + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); +const BLOCKED_HOSTS = new Set(["169.254.169.254", "metadata.google.internal"]); + +function queryRedactionValues(url: URL): string[] { + const values = new Set(); + if (url.search) { + values.add(url.search.slice(1)); + } + for (const value of url.searchParams.values()) { + if (value) values.add(value); + } + return [...values]; +} + +function safeProviderLabels(url: URL): { artifactLabel: string; logLabel: string } { + const withoutQuery = `${url.protocol}//${url.host}${url.pathname}`; + return { + artifactLabel: artifactLabel(withoutQuery), + logLabel: withoutQuery, + }; +} + +function normalizeHostname(hostname: string): string { + const host = hostname.trim().toLowerCase(); + if (host.startsWith("[") && host.endsWith("]")) { + return host.slice(1, -1); + } + return host; +} + +function parseIpv4(host: string): number[] | undefined { + const parts = host.split("."); + if (parts.length !== 4) return undefined; + const octets = parts.map((part) => Number(part)); + if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) { + return undefined; + } + return octets; +} + +function isLoopbackHost(host: string): boolean { + if (LOOPBACK_HOSTS.has(host)) return true; + const ipv4 = parseIpv4(host); + return Boolean(ipv4 && ipv4[0] === 127); +} + +function isPrivateOrLinkLocalIp(host: string): boolean { + const ipVersion = isIP(host); + if (ipVersion === 4) { + const ipv4 = parseIpv4(host); + if (!ipv4) return false; + const [a, b] = ipv4; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ); + } + if (ipVersion === 6) { + if (host === "::" || host === "::1") return true; + if (host.startsWith("::ffff:")) { + return isPrivateOrLinkLocalIp(host.slice("::ffff:".length)); + } + const firstHextet = Number.parseInt(host.split(":")[0] ?? "", 16); + if (!Number.isFinite(firstHextet)) return false; + return (firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xffc0) === 0xfe80; + } + return false; +} + +export function trustedProviderEndpoint( + rawUrl: string, + options: TrustedProviderEndpointOptions = {}, +): TrustedProviderEndpoint { + let url: URL; + try { + url = new URL(rawUrl); + } catch (error) { + throw new Error(`provider endpoint URL is invalid: ${error instanceof Error ? error.message : String(error)}`); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error(`provider endpoint protocol must be http or https: ${url.protocol}`); + } + if (url.username || url.password) { + throw new Error("provider endpoint URL must not include credentials"); + } + const host = normalizeHostname(url.hostname); + if (!host) { + throw new Error("provider endpoint URL must include a host"); + } + if (BLOCKED_HOSTS.has(host)) { + throw new Error(`provider endpoint host is blocked: ${host}`); + } + if (isPrivateOrLinkLocalIp(host) && !isLoopbackHost(host)) { + throw new Error(`provider endpoint IP literal must not target private or link-local ranges: ${host}`); + } + if (url.protocol === "http:" && !isLoopbackHost(host)) { + throw new Error(`provider endpoint http URLs must target loopback hosts: ${host}`); + } + const allowedHosts = options.allowedHosts?.map(normalizeHostname); + if (!isLoopbackHost(host) && !allowedHosts) { + throw new Error(`provider endpoint external hosts require an allowedHosts entry: ${host}`); + } + if (allowedHosts && !allowedHosts.includes(host)) { + throw new Error(`provider endpoint host is not allowed: ${host}`); + } + const labels = safeProviderLabels(url); + return { + url: url.toString(), + artifactLabel: labels.artifactLabel, + logLabel: labels.logLabel, + redactionValues: queryRedactionValues(url), + [trustedProviderEndpointBrand]: true, + }; +} + +export class ProviderClient { + private readonly runner: CommandRunner; + + constructor(runner: CommandRunner) { + this.runner = runner; + } + + private curl(endpoint: TrustedProviderEndpoint, options: ShellProbeRunOptions = {}): Promise { + return this.runner.run( + trustedShellCommand({ + command: "curl", + args: ["-fsS", endpoint.url], + reason: "fetch trusted provider endpoint", + }), + { + ...options, + artifactName: options.artifactName ?? `curl-${endpoint.artifactLabel}`, + redactionValues: [...(options.redactionValues ?? []), ...endpoint.redactionValues], + }, + ); + } + + async getJson(endpoint: TrustedProviderEndpoint, options: ShellProbeRunOptions = {}): Promise { + const result = await this.curl(endpoint, options); + assertExitZero(result, `curl ${endpoint.logLabel}`); + try { + return JSON.parse(result.stdout) as T; + } catch { + throw new Error("provider response was not JSON"); + } + } +} diff --git a/test/e2e-scenario/framework/clients/sandbox.ts b/test/e2e-scenario/framework/clients/sandbox.ts new file mode 100644 index 00000000000..af0c0a4154e --- /dev/null +++ b/test/e2e-scenario/framework/clients/sandbox.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ShellProbeResult, ShellProbeRunOptions } from "../shell-probe.ts"; +import { trustedShellCommand } from "../shell-probe.ts"; +import { artifactLabel, assertExitZero, type CommandRunner } from "./command.ts"; + +export interface SandboxClientOptions { + openshellPath?: string; +} + +export class SandboxClient { + private readonly runner: CommandRunner; + private readonly openshellPath: string; + + constructor(runner: CommandRunner, options: SandboxClientOptions = {}) { + this.runner = runner; + this.openshellPath = options.openshellPath ?? process.env.OPENSHELL_BIN ?? "openshell"; + } + + openshell(args: string[] = [], options: ShellProbeRunOptions = {}): Promise { + return this.runner.run( + trustedShellCommand({ + command: this.openshellPath, + args, + reason: "run OpenShell sandbox command", + }), + { + artifactName: `openshell-${artifactLabel(args.join("-") || "default")}`, + ...options, + }, + ); + } + + list(): Promise { + return this.openshell(["sandbox", "list"], { artifactName: "sandbox-list" }); + } + + status(name: string): Promise { + validateSandboxName(name); + return this.openshell(["sandbox", "status", name], { artifactName: `sandbox-status-${name}` }); + } + + exec(name: string, command: string[], options: ShellProbeRunOptions = {}): Promise { + validateSandboxName(name); + return this.openshell(["sandbox", "exec", name, "--", ...command], { + artifactName: `sandbox-exec-${name}`, + ...options, + }); + } + + async expectRunning(name: string): Promise { + const result = await this.status(name); + assertExitZero(result, `openshell sandbox status ${name}`); + return result; + } +} + +function validateSandboxName(name: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) { + throw new Error(`sandbox name is invalid for fixture client: ${name}`); + } +} diff --git a/test/e2e-scenario/framework/clients/state.ts b/test/e2e-scenario/framework/clients/state.ts new file mode 100644 index 00000000000..5f11fa9dbae --- /dev/null +++ b/test/e2e-scenario/framework/clients/state.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs/promises"; + +export class StateClient { + async exists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch (error) { + if (isMissingPathError(error)) { + return false; + } + throw error; + } + } + + async readText(filePath: string): Promise { + return fs.readFile(filePath, "utf8"); + } + + async readJson(filePath: string): Promise { + return JSON.parse(await this.readText(filePath)) as T; + } +} + +function isMissingPathError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOENT" || code === "ENOTDIR"; +} diff --git a/test/e2e-scenario/framework/e2e-test.ts b/test/e2e-scenario/framework/e2e-test.ts index b442187f8d3..e44f50c7f27 100644 --- a/test/e2e-scenario/framework/e2e-test.ts +++ b/test/e2e-scenario/framework/e2e-test.ts @@ -4,6 +4,13 @@ import { expect, test as base } from "vitest"; import { createArtifactSink, type ArtifactSink } from "./artifacts.ts"; +import { + GatewayClient, + HostCliClient, + ProviderClient, + SandboxClient, + StateClient, +} from "./clients/index.ts"; import { assertCleanupPassed, CleanupRegistry } from "./cleanup.ts"; import { SecretStore } from "./secrets.ts"; import { ShellProbe } from "./shell-probe.ts"; @@ -13,6 +20,11 @@ export interface E2EScenarioFixtures { cleanup: CleanupRegistry; secrets: SecretStore; shellProbe: ShellProbe; + host: HostCliClient; + gateway: GatewayClient; + sandbox: SandboxClient; + provider: ProviderClient; + state: StateClient; } export const test = base.extend({ @@ -42,11 +54,28 @@ export const test = base.extend({ } }, shellProbe: async ({ artifacts, secrets, signal }, use) => { - await use(new ShellProbe({ - artifacts, - redact: (text, extraValues) => secrets.redact(text, extraValues), - signal, - })); + await use( + new ShellProbe({ + artifacts, + redact: (text, extraValues) => secrets.redact(text, extraValues), + signal, + }), + ); + }, + host: async ({ shellProbe }, use) => { + await use(new HostCliClient(shellProbe)); + }, + gateway: async ({ host }, use) => { + await use(new GatewayClient(host)); + }, + sandbox: async ({ shellProbe }, use) => { + await use(new SandboxClient(shellProbe)); + }, + provider: async ({ shellProbe }, use) => { + await use(new ProviderClient(shellProbe)); + }, + state: async ({}, use) => { + await use(new StateClient()); }, });