From 67a852572b14dd963fa4db001cb699dcb9aeea0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:41:50 +0000 Subject: [PATCH 1/2] Spike: devcontainer level for the VS Code E2E harness Adds a second E2E level that runs the workspace extension and a remote suite in a real remote extension host inside a dev container built from this checkout's feature. A test-only resolver extension answers a devcerts-e2e authority with a VS Code server started in the container via devcontainer exec, so the cert payload crosses VS Code's real RPC and the remote gate opens on a real remoteName. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DEkrfbZo8CXdZ2K6tBWLfn --- .github/workflows/build-extensions.yml | 42 +++ AGENTS.md | 6 + package-lock.json | 14 + package.json | 4 +- .../.devcontainer/devcontainer.json | 24 ++ test/vscode-e2e/esbuild.mjs | 12 +- test/vscode-e2e/launcherCommon.mjs | 95 +++++ test/vscode-e2e/loadCheck.cjs | 42 ++- test/vscode-e2e/resolver/extension.js | 46 +++ test/vscode-e2e/resolver/package.json | 20 + test/vscode-e2e/runDevcontainer.mjs | 343 ++++++++++++++++++ test/vscode-e2e/runTests.mjs | 100 +---- test/vscode-e2e/src/remote/index.ts | 27 ++ test/vscode-e2e/src/remote/suite.ts | 160 ++++++++ test/vscode-e2e/src/suites/certFlow.ts | 2 +- 15 files changed, 825 insertions(+), 112 deletions(-) create mode 100644 test/vscode-e2e/devcontainer/.devcontainer/devcontainer.json create mode 100644 test/vscode-e2e/launcherCommon.mjs create mode 100644 test/vscode-e2e/resolver/extension.js create mode 100644 test/vscode-e2e/resolver/package.json create mode 100644 test/vscode-e2e/runDevcontainer.mjs create mode 100644 test/vscode-e2e/src/remote/index.ts create mode 100644 test/vscode-e2e/src/remote/suite.ts diff --git a/.github/workflows/build-extensions.yml b/.github/workflows/build-extensions.yml index df3a875..ce61232 100644 --- a/.github/workflows/build-extensions.yml +++ b/.github/workflows/build-extensions.yml @@ -139,6 +139,48 @@ jobs: - name: Run VS Code E2E suite run: xvfb-run -a npm run test:e2e + vscode-e2e-devcontainer: + # The second E2E level: the workspace extension and suite run in a real + # remote extension host inside a dev container built from this checkout's + # feature, reached through a test-only resolver extension. Separate from + # `vscode-e2e` because it adds Docker and a second (server) download to + # the things that can fail for reasons unrelated to the code. + name: VS Code E2E in a dev container (spike) + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + + - name: Install dependencies + run: npm ci + + - name: Typecheck E2E suite + run: npm run typecheck:e2e + + - name: Build UI Extension + run: npm run build -w src/vscode-ui-extension + + - name: Build Workspace Extension + run: npm run build -w src/vscode-workspace-extension + + - name: Build E2E suite + run: npm run build:e2e + + - name: Generate test certificate fixture + run: npm run gen:test-cert + + - name: Check E2E suite bundles load + run: npm run check:e2e-load + + - name: Install xvfb + run: sudo apt-get update -y && sudo apt-get install -y --no-install-recommends xvfb + + - name: Run VS Code E2E suite in a dev container + run: xvfb-run -a npm run test:e2e:devcontainer + windows-certificate-validation: name: Windows Certificate Validation runs-on: windows-latest diff --git a/AGENTS.md b/AGENTS.md index 6c3ba19..189a85c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,12 @@ If you change this, keep both halves. An env-var-only check would be a genuine e The alternative that avoids a product-code seam entirely is a resolver extension implementing `resolveAuthority` to populate `remoteName` for real. It is more faithful and would also unlock testing against a real container — but it rides a **proposed API**, so it needs `--enable-proposed-api` and can break between VS Code releases. Choosing between the two is the open question this spike exists to inform. +### Devcontainer level (`test/vscode-e2e/runDevcontainer.mjs`) — spike status + +The resolver alternative above, prototyped as a second level rather than a replacement: the seam-based local level stays, because it is cheap and needs no Docker. `npm run test:e2e:devcontainer` stages `.out/devcontainer-e2e/` with this checkout's feature, `devcontainer up`s it with the open-source `@devcontainers/cli`, starts a VS Code server of the **same commit** as the downloaded desktop build inside the container via `devcontainer exec` (so it gets the CLI's userEnvProbe), and launches desktop VS Code on `vscode-remote://devcerts-e2e+e2e/...`. `test/vscode-e2e/resolver/` is a ~40-line test-only extension whose only job is `registerRemoteAuthorityResolver("devcerts-e2e")` → the server's published loopback port. The UI extension and resolver load locally; the workspace extension and `remote-suite.cjs` load from the container through `vscode-remote://` development/test paths. + +That closes the gaps the local level lists: the `getAllCertMaterialV3` payload crosses VS Code's real RPC, the gate opens on a real `remoteName` (the suite asserts the seam variable is **unset**), `install.sh` runs for real and its `SSL_CERT_DIR` must reach the extension host, and the final check is `openssl verify` inside the container using only that inherited `SSL_CERT_DIR`. Still not covered: the Dev Containers extension itself (closed-source; it would also install the marketplace builds the feature names over the checkout under test), and the dotnet dev-cert / reverse-sync paths. `--container-only` stops after `devcontainer up` plus an env probe — Docker but no VS Code download. Linux-only for now (server arch is read from the container; the published port assumes Docker on the same host). + ## File Paths That Matter | Path (in container) | Purpose | diff --git a/package-lock.json b/package-lock.json index 5fdc80c..eb529d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "src/vscode-workspace-extension" ], "devDependencies": { + "@devcontainers/cli": "^0.89.0", "@eslint/js": "^10.0.1", "@vscode/test-electron": "^2.5.2", "esbuild": "^0.28.0", @@ -215,6 +216,19 @@ "resolved": "src/shared", "link": true }, + "node_modules/@devcontainers/cli": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@devcontainers/cli/-/cli-0.89.0.tgz", + "integrity": "sha512-LzaoOGKQ/Zql6PsiZ4hVIYVZagzWkD65aG/1ou5/Kly5Y1PtjLg1yn7qu+LZCzVoAl6DZZ/pbz8qOO4RLNlqMg==", + "dev": true, + "license": "MIT", + "bin": { + "devcontainer": "devcontainer.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/package.json b/package.json index 6b886a8..b0adfcc 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "typecheck:e2e": "tsc --noEmit -p test/vscode-e2e/tsconfig.json", "build:e2e": "node test/vscode-e2e/esbuild.mjs", "check:e2e-load": "node test/vscode-e2e/loadCheck.cjs", - "test:e2e": "node test/vscode-e2e/runTests.mjs" + "test:e2e": "node test/vscode-e2e/runTests.mjs", + "test:e2e:devcontainer": "node test/vscode-e2e/runDevcontainer.mjs" }, "devDependencies": { + "@devcontainers/cli": "^0.89.0", "@eslint/js": "^10.0.1", "@vscode/test-electron": "^2.5.2", "esbuild": "^0.28.0", diff --git a/test/vscode-e2e/devcontainer/.devcontainer/devcontainer.json b/test/vscode-e2e/devcontainer/.devcontainer/devcontainer.json new file mode 100644 index 0000000..58d86fb --- /dev/null +++ b/test/vscode-e2e/devcontainer/.devcontainer/devcontainer.json @@ -0,0 +1,24 @@ +// Template for the devcontainer E2E level. runDevcontainer.mjs stages this +// folder into .out/devcontainer-e2e/ and copies the feature next to it as +// .devcontainer/devcontainer-dev-certs, so the feature under test is the one +// in this checkout rather than the published ghcr.io build. +{ + "name": "devcontainer-dev-certs-e2e", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "remoteUser": "vscode", + // Explicit, because the CLI otherwise mounts the enclosing git checkout and + // points workspaceFolder at .out/devcontainer-e2e inside it. Only the staged + // folder should be visible to the container. + "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/devcerts-e2e,type=bind", + "workspaceFolder": "/workspaces/devcerts-e2e", + "features": { + "./devcontainer-dev-certs": {} + }, + // The VS Code server listens on 8123 inside the container. Published on + // loopback only, on an ephemeral host port the launcher reads back with + // `docker port`, so parallel runs don't collide. + "runArgs": ["-p", "127.0.0.1::8123"], + "containerEnv": { + "DEVCERTS_E2E": "1" + } +} diff --git a/test/vscode-e2e/esbuild.mjs b/test/vscode-e2e/esbuild.mjs index 150a321..7334d93 100644 --- a/test/vscode-e2e/esbuild.mjs +++ b/test/vscode-e2e/esbuild.mjs @@ -11,10 +11,18 @@ const repoRoot = resolve(here, "..", ".."); // node_modules. Everything else — including the shared package, which is // published as TypeScript source — is bundled in, so the suite is a single // self-contained file with no resolution surprises at runtime. +// +// Two bundles: `suite.cjs` for the local level (runTests.mjs) and +// `remote-suite.cjs` for the devcontainer level (runDevcontainer.mjs), which +// is copied into the container and loaded by the remote extension host. await esbuild.build({ - entryPoints: [resolve(here, "src", "index.ts")], + entryPoints: { + suite: resolve(here, "src", "index.ts"), + "remote-suite": resolve(here, "src", "remote", "index.ts"), + }, bundle: true, - outfile: resolve(repoRoot, ".out", "vscode-e2e", "suite.cjs"), + outdir: resolve(repoRoot, ".out", "vscode-e2e"), + outExtension: { ".js": ".cjs" }, external: ["vscode"], format: "cjs", platform: "node", diff --git a/test/vscode-e2e/launcherCommon.mjs b/test/vscode-e2e/launcherCommon.mjs new file mode 100644 index 0000000..f5b4a48 --- /dev/null +++ b/test/vscode-e2e/launcherCommon.mjs @@ -0,0 +1,95 @@ +/** + * Pieces shared by both E2E launchers: `runTests.mjs` (one local window, the + * remote-gate seam) and `runDevcontainer.mjs` (a real remote extension host + * inside a dev container). Plain Node, never loaded into VS Code. + */ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const here = dirname(fileURLToPath(import.meta.url)); +export const repoRoot = resolve(here, "..", ".."); + +export const USER_CERT_NAME = "e2e-user-cert"; +export const FIXTURE_DIR = join(repoRoot, ".out", "test-fixtures"); +export const FIXTURE_PEM = join(FIXTURE_DIR, "corp-wildcard.pem"); +export const FIXTURE_KEY = join(FIXTURE_DIR, "corp-wildcard.key"); + +/** The fixture generator is cheap and deterministic; just run it if needed. */ +export function ensureFixture() { + if (existsSync(FIXTURE_PEM) && existsSync(FIXTURE_KEY)) return; + console.log("Generating test certificate fixture..."); + const result = spawnSync( + process.execPath, + [join(repoRoot, "test", "generate-test-cert.mjs")], + { cwd: repoRoot, stdio: "inherit" } + ); + if (result.status !== 0) { + throw new Error("test/generate-test-cert.mjs failed"); + } +} + +export function requireBuilt(label, path) { + if (!existsSync(path)) { + throw new Error( + `${label} is missing (${path}). Build it first — see the usage note in ` + + "test/vscode-e2e/runTests.mjs." + ); + } + return path; +} + +export function buildSandbox() { + const root = mkdtempSync(join(tmpdir(), "devcerts-e2e-")); + const home = join(root, "home"); + const userDataDir = join(root, "user-data"); + const extensionsDir = join(root, "extensions"); + const workspaceDir = join(root, "workspace"); + const trustDir = join(home, ".aspnet", "dev-certs", "trust"); + + for (const dir of [home, userDataDir, extensionsDir, workspaceDir, trustDir]) { + mkdirSync(dir, { recursive: true }); + } + + // Machine/user settings for the run. `userCertificates` is what makes the + // host serve a certificate at all; the rest keeps the window quiet and the + // flow deterministic: + // - generateDotNetCert/autoProvision off → the dev-cert branch (and its + // modal consent prompt, and the OS trust-store write) never runs. + // - autoInject off → the suite drives the pull explicitly instead of + // racing extension activation. + // - warnOnStaleDevCerts off → no post-install warning dialog. + mkdirSync(join(userDataDir, "User"), { recursive: true }); + writeFileSync( + join(userDataDir, "User", "settings.json"), + JSON.stringify( + { + "devcontainerDevCerts.generateDotNetCert": false, + "devcontainerDevCerts.autoProvision": false, + "devcontainerDevCerts.autoInject": false, + "devcontainerDevCerts.warnOnStaleDevCerts": false, + "devcontainerDevCerts.installUserCertsToDotNetStore": false, + "devcontainerDevCerts.userCertificates": [ + { + name: USER_CERT_NAME, + pemCertPath: FIXTURE_PEM, + pemKeyPath: FIXTURE_KEY, + trustInContainer: true, + }, + ], + "security.workspace.trust.enabled": false, + "telemetry.telemetryLevel": "off", + "update.mode": "none", + "extensions.autoUpdate": false, + "extensions.autoCheckUpdates": false, + "workbench.startupEditor": "none", + }, + null, + 2 + ) + ); + + return { root, home, userDataDir, extensionsDir, workspaceDir, trustDir }; +} diff --git a/test/vscode-e2e/loadCheck.cjs b/test/vscode-e2e/loadCheck.cjs index 0504da0..4c370cd 100644 --- a/test/vscode-e2e/loadCheck.cjs +++ b/test/vscode-e2e/loadCheck.cjs @@ -26,11 +26,14 @@ const Module = require("node:module"); const path = require("node:path"); const fs = require("node:fs"); -const BUNDLE = path.resolve(__dirname, "..", "..", ".out", "vscode-e2e", "suite.cjs"); +const OUT = path.resolve(__dirname, "..", "..", ".out", "vscode-e2e"); +const BUNDLES = [path.join(OUT, "suite.cjs"), path.join(OUT, "remote-suite.cjs")]; -if (!fs.existsSync(BUNDLE)) { - console.error(`Suite bundle is missing (${BUNDLE}). Run: npm run build:e2e`); - process.exit(1); +for (const bundle of BUNDLES) { + if (!fs.existsSync(bundle)) { + console.error(`Suite bundle is missing (${bundle}). Run: npm run build:e2e`); + process.exit(1); + } } /** @@ -72,22 +75,25 @@ require.cache["vscode"] = { exports: vscodeStub, }; -try { - const suite = require(BUNDLE); - if (typeof suite.run !== "function") { +for (const bundle of BUNDLES) { + const name = path.basename(bundle); + try { + const suite = require(bundle); + if (typeof suite.run !== "function") { + console.error( + `${name} loaded but does not export run(). VS Code's ` + + "--extensionTestsPath contract requires it." + ); + process.exit(1); + } + console.log(`${name} loads cleanly and exports run().`); + } catch (err) { + console.error(`${name} threw during module initialization:`); + console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)); console.error( - "Suite bundle loaded but does not export run(). VS Code's " + - "--extensionTestsPath contract requires it." + "\nThis would fail identically inside the extension host, after the " + + "VS Code download. Fix it here." ); process.exit(1); } - console.log("E2E suite bundle loads cleanly and exports run()."); -} catch (err) { - console.error("Suite bundle threw during module initialization:"); - console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)); - console.error( - "\nThis would fail identically inside the extension host, after the " + - "VS Code download. Fix it here." - ); - process.exit(1); } diff --git a/test/vscode-e2e/resolver/extension.js b/test/vscode-e2e/resolver/extension.js new file mode 100644 index 0000000..8577efa --- /dev/null +++ b/test/vscode-e2e/resolver/extension.js @@ -0,0 +1,46 @@ +// Test-only remote authority resolver for the devcontainer E2E level. +// +// It does exactly one thing: tell VS Code that the `devcerts-e2e` authority +// lives at the VS Code server `runDevcontainer.mjs` started inside the dev +// container. Everything after that is stock VS Code: the server runs the +// workspace extension and the suite in a REAL remote extension host, so +// `vscode.env.remoteName` is set for real (no DEVCONTAINER_DEV_CERTS_TEST_REMOTE +// seam), and every cross-host executeCommand payload is genuinely serialized +// over VS Code's RPC rather than passed by reference. +// +// `registerRemoteAuthorityResolver` is a proposed API (`resolvers`). It is +// unlocked by `enabledApiProposals` in package.json plus +// `--enable-proposed-api dnegstad.devcerts-e2e-resolver` on the command line, +// which the launcher passes. Proposed APIs can change between VS Code +// releases; if this file breaks on a new stable, compare against +// src/vscode-dts/vscode.proposed.resolvers.d.ts in microsoft/vscode. +// +// Plain JS on purpose: there is nothing here worth a build step, and the +// proposed .d.ts isn't in @types/vscode. +"use strict"; +const vscode = require("vscode"); + +function required(name) { + const value = process.env[name]; + if (!value) { + throw new Error( + `${name} is not set; this resolver only works under test/vscode-e2e/runDevcontainer.mjs` + ); + } + return value; +} + +exports.activate = function activate(context) { + context.subscriptions.push( + vscode.workspace.registerRemoteAuthorityResolver("devcerts-e2e", { + resolve(_authority) { + const host = required("DEVCERTS_E2E_SERVER_HOST"); + const port = Number(required("DEVCERTS_E2E_SERVER_PORT")); + const token = required("DEVCERTS_E2E_SERVER_TOKEN"); + return new vscode.ResolvedAuthority(host, port, token); + }, + }) + ); +}; + +exports.deactivate = function deactivate() {}; diff --git a/test/vscode-e2e/resolver/package.json b/test/vscode-e2e/resolver/package.json new file mode 100644 index 0000000..c319922 --- /dev/null +++ b/test/vscode-e2e/resolver/package.json @@ -0,0 +1,20 @@ +{ + "name": "devcerts-e2e-resolver", + "displayName": "devcontainer-dev-certs E2E remote resolver (test only)", + "publisher": "dnegstad", + "version": "0.0.0", + "private": true, + "engines": { + "vscode": "^1.100.0" + }, + "extensionKind": [ + "ui" + ], + "enabledApiProposals": [ + "resolvers" + ], + "activationEvents": [ + "onResolveRemoteAuthority:devcerts-e2e" + ], + "main": "./extension.js" +} diff --git a/test/vscode-e2e/runDevcontainer.mjs b/test/vscode-e2e/runDevcontainer.mjs new file mode 100644 index 0000000..4744b5d --- /dev/null +++ b/test/vscode-e2e/runDevcontainer.mjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node +/** + * Launcher for the devcontainer level of the VS Code E2E suite. + * + * Where `runTests.mjs` loads both extensions into ONE local window and opens + * the workspace extension's remote gate with a test-only seam, this one gives + * the workspace extension a real remote: + * + * 1. Stage a workspace under .out/devcontainer-e2e/ whose devcontainer.json + * uses the feature from THIS checkout, plus the built workspace + * extension, the remote suite bundle and the expected-cert fixture. + * 2. `devcontainer up` it with the open-source Dev Containers CLI, so the + * feature's install.sh runs for real. + * 3. Download a desktop VS Code and the VS Code server for the SAME commit, + * and start the server inside the container with `devcontainer exec`, + * which applies the same userEnvProbe the Dev Containers extension does. + * 4. Launch desktop VS Code on `vscode-remote://devcerts-e2e+e2e/...`. A + * tiny test-only resolver extension (test/vscode-e2e/resolver/) answers + * that authority with the server's published port. The UI extension and + * the resolver load locally; the workspace extension and the suite load + * from the container via vscode-remote:// development/test paths. + * + * Nothing here uses the Dev Containers extension: it is closed-source, can't + * be driven headlessly in a supported way, and would install the marketplace + * builds the feature names in customizations.vscode.extensions over the + * checkout under test. + * + * Usage (from the repo root, Linux + Docker): + * npm run build -w src/vscode-ui-extension + * npm run build -w src/vscode-workspace-extension + * npm run build:e2e + * npm run test:e2e:devcontainer -- --container-only # no VS Code download + * xvfb-run -a npm run test:e2e:devcontainer + * + * `--container-only` stops after step 2 plus an environment probe. It needs + * Docker but no VS Code, so it is the cheap check for "does the feature build + * and does its environment reach a devcontainer exec'd process". + * + * The container is removed on success and left running on failure, with its + * id printed, so it can be inspected with `docker exec`. + */ +import { downloadAndUnzipVSCode, runTests } from "@vscode/test-electron"; +import { spawn, spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { + FIXTURE_PEM, + USER_CERT_NAME, + buildSandbox, + ensureFixture, + here, + repoRoot, + requireBuilt, +} from "./launcherCommon.mjs"; + +const AUTHORITY = "devcerts-e2e"; +const RESOLVER_ID = "dnegstad.devcerts-e2e-resolver"; +const SERVER_PORT = 8123; +const STAGE = join(repoRoot, ".out", "devcontainer-e2e"); +const containerOnly = process.argv.includes("--container-only"); + +const require = createRequire(import.meta.url); +const devcontainerCli = join( + dirname(require.resolve("@devcontainers/cli/package.json")), + "devcontainer.js" +); + +/** `devcontainer --workspace-folder STAGE ...rest`; options must precede an exec'd command. */ +function devcontainer(subcommand, rest = []) { + const result = spawnSync( + process.execPath, + [devcontainerCli, subcommand, "--workspace-folder", STAGE, ...rest], + { cwd: repoRoot, encoding: "utf-8" } + ); + if (result.status !== 0) { + throw new Error( + `devcontainer ${subcommand} failed (exit ${result.status})\n${result.stderr ?? ""}` + ); + } + return result.stdout ?? ""; +} + +/** `devcontainer up` prints log lines, then one JSON result line. */ +function lastJsonLine(stdout) { + const line = stdout.trim().split("\n").reverse().find((l) => l.startsWith("{")); + if (!line) throw new Error(`no JSON result from devcontainer up:\n${stdout}`); + return JSON.parse(line); +} + +function stage(workspaceExtension) { + rmSync(STAGE, { recursive: true, force: true }); + cpSync(join(here, "devcontainer"), STAGE, { recursive: true }); + cpSync( + join(repoRoot, "src", "devcontainer-feature", "src", "devcontainer-dev-certs"), + join(STAGE, ".devcontainer", "devcontainer-dev-certs"), + { recursive: true } + ); + + // Everything the container needs from the host goes under .e2e/, inside the + // workspace folder the CLI bind-mounts. No docker cp. + const e2e = join(STAGE, ".e2e"); + const remoteExt = join(e2e, "ext-remote"); + mkdirSync(remoteExt, { recursive: true }); + for (const entry of ["package.json", "package.nls.json", "dist", "l10n"]) { + const from = join(workspaceExtension, entry); + if (existsSync(from)) cpSync(from, join(remoteExt, entry), { recursive: true }); + } + for (const file of ["remote-suite.cjs", "remote-suite.cjs.map"]) { + cpSync(join(repoRoot, ".out", "vscode-e2e", file), join(e2e, file)); + } + cpSync(FIXTURE_PEM, join(e2e, "expected.pem")); + return e2e; +} + +/** Proves the feature's env sinks reach a process started the way the server will be. */ +function probeEnvironment() { + const out = devcontainer("exec", [ + "sh", + "-c", + 'printf "%s\\n%s\\n" "$HOME" "$SSL_CERT_DIR"', + ]); + const [home, sslCertDir] = out.trim().split("\n"); + const trustDir = `${home}/.aspnet/dev-certs/trust`; + if (!(sslCertDir ?? "").split(":").includes(trustDir)) { + throw new Error( + `SSL_CERT_DIR in the container does not include ${trustDir} (got '${sslCertDir}'). ` + + "The feature's environment sinks are not reaching devcontainer exec." + ); + } + console.log(`Container env OK: SSL_CERT_DIR=${sslCertDir}`); +} + +function containerArch() { + const machine = devcontainer("exec", ["uname", "-m"]).trim(); + if (machine === "x86_64") return "x64"; + if (machine === "aarch64") return "arm64"; + throw new Error(`unsupported container architecture: ${machine}`); +} + +function productCommit(productJson) { + return JSON.parse(readFileSync(productJson, "utf-8")).commit; +} + +/** + * Desktop and server must be the same commit or the server refuses the + * connection. Resolve "stable" to a concrete version once and download both + * by that version, then check the commits rather than trusting the naming. + */ +async function downloadMatchingBuilds(arch) { + const version = process.env.DEVCERTS_E2E_VSCODE_VERSION ?? "stable"; + const desktopExe = await downloadAndUnzipVSCode({ version }); + const desktopRoot = findUp(desktopExe, "resources"); + const desktopProduct = join(desktopRoot, "resources", "app", "product.json"); + const desktopVersion = JSON.parse( + readFileSync(join(desktopRoot, "resources", "app", "package.json"), "utf-8") + ).version; + + const serverBin = await downloadAndUnzipVSCode({ + version: desktopVersion, + platform: `server-linux-${arch}`, + }); + const serverRoot = dirname(dirname(serverBin)); + const desktopCommit = productCommit(desktopProduct); + const serverCommit = productCommit(join(serverRoot, "product.json")); + if (desktopCommit !== serverCommit) { + throw new Error( + `VS Code desktop (${desktopCommit}) and server (${serverCommit}) commits differ` + ); + } + console.log(`VS Code ${desktopVersion} (${desktopCommit})`); + return { desktopExe, serverRoot }; +} + +function findUp(start, marker) { + let dir = dirname(start); + while (dir !== dirname(dir)) { + if (existsSync(join(dir, marker))) return dir; + dir = dirname(dir); + } + throw new Error(`could not find '${marker}' above ${start}`); +} + +/** + * Starts the server in the container and resolves once it is listening. + * Runs through `devcontainer exec` so the server inherits the probed user + * environment, exactly as a server the Dev Containers extension starts would. + */ +function startServer({ remoteE2e, token }) { + const child = spawn( + process.execPath, + [ + devcontainerCli, + "exec", + "--workspace-folder", + STAGE, + "--remote-env", + `DEVCERTS_E2E_USER_CERT_NAME=${USER_CERT_NAME}`, + "--remote-env", + `DEVCERTS_E2E_EXPECTED_PEM=${remoteE2e}/expected.pem`, + `${remoteE2e}/server/bin/code-server`, + "--host", + "0.0.0.0", + "--port", + String(SERVER_PORT), + "--connection-token", + token, + "--accept-server-license-terms", + "--telemetry-level", + "off", + "--server-data-dir", + "/tmp/devcerts-e2e-server", + ], + { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] } + ); + + return new Promise((resolvePromise, reject) => { + const timer = setTimeout( + () => reject(new Error("VS Code server did not start listening within 60s")), + 60_000 + ); + const onData = (chunk) => { + const text = chunk.toString(); + process.stdout.write(`[server] ${text}`); + if (text.includes(`listening on ${SERVER_PORT}`)) { + clearTimeout(timer); + resolvePromise(child); + } + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.on("exit", (code) => { + clearTimeout(timer); + reject(new Error(`VS Code server exited early (code ${code})`)); + }); + }); +} + +function publishedPort(containerId) { + const out = spawnSync("docker", ["port", containerId, `${SERVER_PORT}/tcp`], { + encoding: "utf-8", + }); + const first = (out.stdout ?? "").trim().split("\n")[0] ?? ""; + const match = /^(.+):(\d+)$/.exec(first); + if (out.status !== 0 || !match) { + throw new Error(`could not read the published port for ${SERVER_PORT}: ${out.stderr}`); + } + return { host: match[1], port: match[2] }; +} + +async function main() { + ensureFixture(); + + const uiExtension = resolve(repoRoot, "src", "vscode-ui-extension"); + const workspaceExtension = resolve(repoRoot, "src", "vscode-workspace-extension"); + requireBuilt("UI extension bundle", join(uiExtension, "dist", "extension.js")); + requireBuilt("workspace extension bundle", join(workspaceExtension, "dist", "extension.js")); + requireBuilt( + "remote suite bundle", + resolve(repoRoot, ".out", "vscode-e2e", "remote-suite.cjs") + ); + + const localE2e = stage(workspaceExtension); + + console.log("Building and starting the dev container..."); + const up = lastJsonLine(devcontainer("up", ["--remove-existing-container"])); + if (up.outcome !== "success") throw new Error(`devcontainer up: ${JSON.stringify(up)}`); + const { containerId, remoteWorkspaceFolder } = up; + console.log(`Container: ${containerId} (workspace ${remoteWorkspaceFolder})`); + + let exitCode = 0; + try { + probeEnvironment(); + if (containerOnly) { + console.log("--container-only: skipping the VS Code run."); + } else { + await runVsCode({ containerId, remoteWorkspaceFolder, localE2e, uiExtension }); + } + } catch (err) { + exitCode = 1; + console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)); + } + + if (exitCode === 0) { + spawnSync("docker", ["rm", "-f", containerId], { stdio: "ignore" }); + } else { + console.error(`Container left running for inspection: ${containerId}`); + } + process.exitCode = exitCode; +} + +async function runVsCode({ containerId, remoteWorkspaceFolder, localE2e, uiExtension }) { + let server; + const sandbox = buildSandbox(); + let ok = false; + try { + const { desktopExe, serverRoot } = await downloadMatchingBuilds(containerArch()); + cpSync(serverRoot, join(localE2e, "server"), { recursive: true }); + + const remoteE2e = `${remoteWorkspaceFolder}/.e2e`; + const token = randomBytes(16).toString("hex"); + server = await startServer({ remoteE2e, token }); + const { host, port } = publishedPort(containerId); + + const remote = (p) => `vscode-remote://${AUTHORITY}+e2e${p}`; + await runTests({ + vscodeExecutablePath: desktopExe, + extensionDevelopmentPath: [ + uiExtension, + join(here, "resolver"), + remote(`${remoteE2e}/ext-remote`), + ], + extensionTestsPath: remote(`${remoteE2e}/remote-suite.cjs`), + launchArgs: [ + `--folder-uri=${remote(remoteWorkspaceFolder)}`, + `--enable-proposed-api=${RESOLVER_ID}`, + "--user-data-dir", + sandbox.userDataDir, + "--extensions-dir", + sandbox.extensionsDir, + "--disable-extensions", + "--disable-gpu", + ], + extensionTestsEnv: { + // Host side only. The UI extension writes nothing for a user cert, but + // keep its os.homedir() in a sandbox anyway, as runTests.mjs does. + HOME: sandbox.home, + USERPROFILE: sandbox.home, + DEVCERTS_E2E_SERVER_HOST: host, + DEVCERTS_E2E_SERVER_PORT: port, + DEVCERTS_E2E_SERVER_TOKEN: token, + }, + }); + ok = true; + } finally { + server?.kill(); + if (ok) rmSync(sandbox.root, { recursive: true, force: true }); + else console.error(`Host sandbox left in place: ${sandbox.root}`); + } +} + +await main(); diff --git a/test/vscode-e2e/runTests.mjs b/test/vscode-e2e/runTests.mjs index 196e6e9..3ecd16a 100644 --- a/test/vscode-e2e/runTests.mjs +++ b/test/vscode-e2e/runTests.mjs @@ -19,96 +19,16 @@ * deliberately left behind on failure so the artifacts can be inspected. */ import { runTests } from "@vscode/test-electron"; -import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const here = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(here, "..", ".."); - -const USER_CERT_NAME = "e2e-user-cert"; -const FIXTURE_DIR = join(repoRoot, ".out", "test-fixtures"); -const FIXTURE_PEM = join(FIXTURE_DIR, "corp-wildcard.pem"); -const FIXTURE_KEY = join(FIXTURE_DIR, "corp-wildcard.key"); - -/** The fixture generator is cheap and deterministic; just run it if needed. */ -function ensureFixture() { - if (existsSync(FIXTURE_PEM) && existsSync(FIXTURE_KEY)) return; - console.log("Generating test certificate fixture..."); - const result = spawnSync( - process.execPath, - [join(repoRoot, "test", "generate-test-cert.mjs")], - { cwd: repoRoot, stdio: "inherit" } - ); - if (result.status !== 0) { - throw new Error("test/generate-test-cert.mjs failed"); - } -} - -function requireBuilt(label, path) { - if (!existsSync(path)) { - throw new Error( - `${label} is missing (${path}). Build it first — see the usage note in ` + - "test/vscode-e2e/runTests.mjs." - ); - } - return path; -} - -function buildSandbox() { - const root = mkdtempSync(join(tmpdir(), "devcerts-e2e-")); - const home = join(root, "home"); - const userDataDir = join(root, "user-data"); - const extensionsDir = join(root, "extensions"); - const workspaceDir = join(root, "workspace"); - const trustDir = join(home, ".aspnet", "dev-certs", "trust"); - - for (const dir of [home, userDataDir, extensionsDir, workspaceDir, trustDir]) { - mkdirSync(dir, { recursive: true }); - } - - // Machine/user settings for the run. `userCertificates` is what makes the - // host serve a certificate at all; the rest keeps the window quiet and the - // flow deterministic: - // - generateDotNetCert/autoProvision off → the dev-cert branch (and its - // modal consent prompt, and the OS trust-store write) never runs. - // - autoInject off → the suite drives the pull explicitly instead of - // racing extension activation. - // - warnOnStaleDevCerts off → no post-install warning dialog. - mkdirSync(join(userDataDir, "User"), { recursive: true }); - writeFileSync( - join(userDataDir, "User", "settings.json"), - JSON.stringify( - { - "devcontainerDevCerts.generateDotNetCert": false, - "devcontainerDevCerts.autoProvision": false, - "devcontainerDevCerts.autoInject": false, - "devcontainerDevCerts.warnOnStaleDevCerts": false, - "devcontainerDevCerts.installUserCertsToDotNetStore": false, - "devcontainerDevCerts.userCertificates": [ - { - name: USER_CERT_NAME, - pemCertPath: FIXTURE_PEM, - pemKeyPath: FIXTURE_KEY, - trustInContainer: true, - }, - ], - "security.workspace.trust.enabled": false, - "telemetry.telemetryLevel": "off", - "update.mode": "none", - "extensions.autoUpdate": false, - "extensions.autoCheckUpdates": false, - "workbench.startupEditor": "none", - }, - null, - 2 - ) - ); - - return { root, home, userDataDir, extensionsDir, workspaceDir, trustDir }; -} +import { rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { + FIXTURE_PEM, + USER_CERT_NAME, + buildSandbox, + ensureFixture, + repoRoot, + requireBuilt, +} from "./launcherCommon.mjs"; async function main() { ensureFixture(); diff --git a/test/vscode-e2e/src/remote/index.ts b/test/vscode-e2e/src/remote/index.ts new file mode 100644 index 0000000..bf61d4a --- /dev/null +++ b/test/vscode-e2e/src/remote/index.ts @@ -0,0 +1,27 @@ +/** + * Entry point for the devcontainer level, loaded by the REMOTE extension host + * inside the container (`--extensionTestsPath=vscode-remote://devcerts-e2e+…`). + * + * A separate bundle from `../index.ts` on purpose: the local suites lean on the + * DEVCONTAINER_DEV_CERTS_TEST_REMOTE seam and a mkdtemp sandbox, and neither + * exists here. This level asserts the opposite of the seam — that the gate + * opens on a real `remoteName` — so mixing them would make each suite's + * preconditions false for the other. + */ +// Must be the first import; see the note in ../index.ts. +import "reflect-metadata"; +import { runRegistered } from "../runner"; +import { registerDevcontainerTests } from "./suite"; + +export async function run(): Promise { + console.log(""); + console.log("devcontainer-dev-certs :: VS Code E2E (devcontainer)"); + console.log(""); + + registerDevcontainerTests(); + + const summary = await runRegistered(); + if (summary.failed > 0) { + throw new Error(`${summary.failed} E2E test(s) failed`); + } +} diff --git a/test/vscode-e2e/src/remote/suite.ts b/test/vscode-e2e/src/remote/suite.ts new file mode 100644 index 0000000..552e491 --- /dev/null +++ b/test/vscode-e2e/src/remote/suite.ts @@ -0,0 +1,160 @@ +/** + * The devcontainer level: the same user-cert vertical slice as + * `../suites/certFlow.ts`, but with the workspace extension and this suite + * running in a real remote extension host inside a container built from the + * feature in this checkout, and the UI extension in the local window. + * + * What this closes relative to the local level: + * - The host↔remote hop is real. `getAllCertMaterialV3` crosses VS Code's + * RPC, so the payload is genuinely serialized; `wireGuard.ts` is a + * stand-in for this, not a substitute. + * - The remote gate opens on a real `vscode.env.remoteName`, not the + * DEVCONTAINER_DEV_CERTS_TEST_REMOTE seam. + * - The feature's `install.sh` ran for real, and its environment sinks have + * to reach the extension host the way they would for a user. + * - The install lands in the container user's real `$HOME`, under the + * container's real users and permissions — no sandbox redirect. + * + * What it still does not cover: the Dev Containers extension itself (the + * launcher starts the VS Code server with `devcontainer exec`, which applies + * the same userEnvProbe but is not the same code), and the dotnet dev-cert / + * reverse-sync paths, which write the host's real OS trust store. + */ +import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + getDotNetRootStorePath, + getPemFileNameForUser, + getPfxFileName, +} from "@devcontainer-dev-certs/shared"; +import type { CertBundleV3, CertMaterialV3 } from "@devcontainer-dev-certs/shared"; +import { assert, assertEqual, test } from "../runner"; +import { WORKSPACE_EXTENSION_ID } from "../env"; +import { pemBody } from "../suites/certFlow"; + +const AUTHORITY = "devcerts-e2e"; +const TRUST_DIR = path.join(os.homedir(), ".aspnet", "dev-certs", "trust"); + +function required(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error( + `${name} is not set. The devcontainer suite must be launched via ` + + "test/vscode-e2e/runDevcontainer.mjs, which passes it to the VS Code " + + "server with `devcontainer exec --remote-env`." + ); + } + return value; +} + +async function fetchBundle(): Promise { + const bundle = await vscode.commands.executeCommand( + "devcontainer-dev-certs.getAllCertMaterialV3", + { includeDotNetDev: false, includeUserCerts: true } + ); + assert(bundle !== undefined && bundle !== null, "host returned no bundle"); + return bundle; +} + +function userCert(bundle: CertBundleV3, name: string): CertMaterialV3 { + const material = bundle.certs.find((c) => c.name === name); + assert( + material !== undefined, + `bundle should carry the configured user cert '${name}' ` + + `(got: ${bundle.certs.map((c) => c.name).join(", ") || "nothing"})` + ); + return material; +} + +export function registerDevcontainerTests(): void { + const userCertName = required("DEVCERTS_E2E_USER_CERT_NAME"); + // The host-side fixture, staged into the container by the launcher. + const expectedPemPath = required("DEVCERTS_E2E_EXPECTED_PEM"); + + test("this extension host is a real remote, not the test seam", () => { + assertEqual(vscode.env.remoteName, AUTHORITY, "vscode.env.remoteName"); + assertEqual( + process.env.DEVCONTAINER_DEV_CERTS_TEST_REMOTE, + undefined, + "the seam variable must not be set at this level, or a green run " + + "would prove nothing about the real gate" + ); + assertEqual(process.platform, "linux", "the remote should be the container"); + }); + + test("workspace extension runs here and passes the real remote gate", async () => { + const ext = vscode.extensions.getExtension(WORKSPACE_EXTENSION_ID); + assert(ext !== undefined, `${WORKSPACE_EXTENSION_ID} should load in the remote host`); + assertEqual( + ext.extensionKind, + vscode.ExtensionKind.Workspace, + "workspace extension should run in the remote extension host" + ); + await ext.activate(); + const registered = new Set(await vscode.commands.getCommands(true)); + assert( + registered.has("devcontainer-dev-certs.injectCert"), + "injectCert should be registered — its absence means the remote gate closed" + ); + }); + + test("the feature's environment reaches the extension host", () => { + // install.sh writes SSL_CERT_DIR to profile.d / /etc/environment / the + // bashrc bridge. None of that is visible to a process unless whatever + // launched it went through a shell that read one of them — which is the + // property this asserts, end to end. + const sslCertDir = process.env.SSL_CERT_DIR ?? ""; + assert( + sslCertDir.split(":").includes(TRUST_DIR), + `SSL_CERT_DIR should include ${TRUST_DIR} (got: '${sslCertDir}')` + ); + assertEqual( + process.env.DEVCONTAINER_DEV_CERTS_GENERATE_DOTNET, + "true", + "feature options should be exported to the extension host" + ); + }); + + test("user cert crosses the real host↔remote RPC intact", async () => { + const material = userCert(await fetchBundle(), userCertName); + assertEqual(material.kind, "user", "cert kind"); + assertEqual( + pemBody(Buffer.from(material.pemCertBase64, "base64").toString("utf-8")), + pemBody(fs.readFileSync(expectedPemPath, "utf-8")), + "the certificate on the wire should be the one configured on the host" + ); + }); + + test("injectCert installs into the container and OpenSSL trusts it", async () => { + const material = userCert(await fetchBundle(), userCertName); + + await vscode.commands.executeCommand("devcontainer-dev-certs.injectCert"); + + const pemPath = path.join(TRUST_DIR, getPemFileNameForUser(userCertName)); + assert(fs.existsSync(pemPath), `${pemPath} should exist after inject`); + + const rootPfx = path.join( + getDotNetRootStorePath(), + getPfxFileName(material.thumbprint) + ); + assert(fs.existsSync(rootPfx), `${rootPfx} should exist`); + + // The real consumer check. No -CApath: openssl falls back to its default + // lookup, which reads SSL_CERT_DIR from the environment — the one this + // extension host inherited from the feature. So this passes only if + // install.sh's sinks, the extension's install, and the pure-TS c_rehash + // all line up inside a real container. + const verify = spawnSync("openssl", ["verify", pemPath], { + encoding: "utf-8", + env: process.env, + }); + assertEqual( + verify.status, + 0, + `openssl verify via SSL_CERT_DIR should succeed\n${verify.stdout}${verify.stderr}` + ); + }); +} diff --git a/test/vscode-e2e/src/suites/certFlow.ts b/test/vscode-e2e/src/suites/certFlow.ts index 1a3ff8e..6ee51c3 100644 --- a/test/vscode-e2e/src/suites/certFlow.ts +++ b/test/vscode-e2e/src/suites/certFlow.ts @@ -48,7 +48,7 @@ function isInside(root: string, candidate: string): boolean { } /** Strip PEM armor and whitespace, leaving the base64 DER body. */ -function pemBody(pem: string): string { +export function pemBody(pem: string): string { const begin = pem.indexOf("-----BEGIN CERTIFICATE-----"); const end = pem.indexOf("-----END CERTIFICATE-----", begin); assert(begin >= 0 && end > begin, "input should contain a PEM certificate"); From 031705ecf00c43534f5ccddb7c63ecd4af1d824b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 00:47:16 +0000 Subject: [PATCH 2/2] Kill the devcontainer exec process when server startup times out Without this the rejected promise left the child and its pipes alive, so the launcher could hang until CI's job limit instead of failing at 60s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DEkrfbZo8CXdZ2K6tBWLfn --- test/vscode-e2e/runDevcontainer.mjs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/vscode-e2e/runDevcontainer.mjs b/test/vscode-e2e/runDevcontainer.mjs index 4744b5d..c8d170e 100644 --- a/test/vscode-e2e/runDevcontainer.mjs +++ b/test/vscode-e2e/runDevcontainer.mjs @@ -216,10 +216,15 @@ function startServer({ remoteE2e, token }) { ); return new Promise((resolvePromise, reject) => { - const timer = setTimeout( - () => reject(new Error("VS Code server did not start listening within 60s")), - 60_000 - ); + // On timeout the caller never receives `child`, so its finally can't kill + // it; a live child and open pipes would keep Node running until CI's job + // limit. Kill it and drop the pipes here before rejecting. + const timer = setTimeout(() => { + child.kill("SIGKILL"); + child.stdout.destroy(); + child.stderr.destroy(); + reject(new Error("VS Code server did not start listening within 60s")); + }, 60_000); const onData = (chunk) => { const text = chunk.toString(); process.stdout.write(`[server] ${text}`);