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 e4846ec..7210bb6 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/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.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..c8d170e --- /dev/null +++ b/test/vscode-e2e/runDevcontainer.mjs @@ -0,0 +1,348 @@ +#!/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) => { + // 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}`); + 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");