diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be5a8f3d5..62cfc8b46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,8 +60,8 @@ jobs: DEVSPACE_REQUIRE_PI_SANDBOX: ${{ matrix.os == 'ubuntu-latest' && '1' || '0' }} run: pnpm test - - name: Build - run: pnpm build + - name: Package install smoke test + run: pnpm test:package-install - name: Doctor run: node dist/cli.js doctor diff --git a/bin/devspace-agentd.js b/bin/devspace-agentd.js index 268c36097..e6c54ca1b 100755 --- a/bin/devspace-agentd.js +++ b/bin/devspace-agentd.js @@ -1,2 +1,4 @@ #!/usr/bin/env node -import "../dist/local-agent-daemon-main.js"; +import { runEntrypoint } from "./run-entrypoint.js"; + +await runEntrypoint("../src/local-agent-daemon-main.ts", "../dist/local-agent-daemon-main.js"); diff --git a/bin/devspace.js b/bin/devspace.js index 8fb127218..4e4788a1b 100755 --- a/bin/devspace.js +++ b/bin/devspace.js @@ -1,2 +1,4 @@ #!/usr/bin/env node -import "../dist/cli.js"; +import { runEntrypoint } from "./run-entrypoint.js"; + +await runEntrypoint("../src/cli.ts", "../dist/cli.js"); diff --git a/bin/run-entrypoint.js b/bin/run-entrypoint.js new file mode 100644 index 000000000..f32983a1c --- /dev/null +++ b/bin/run-entrypoint.js @@ -0,0 +1,20 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +export async function runEntrypoint(sourcePath, distPath) { + const sourceUrl = new URL(sourcePath, import.meta.url); + if (existsSync(fileURLToPath(sourceUrl))) { + try { + await import("tsx/esm"); + } catch (error) { + throw new Error( + "DevSpace source checkout detected, but tsx is unavailable. Run `pnpm install` in the checkout; refusing to fall back to potentially stale dist output.", + { cause: error }, + ); + } + await import(sourceUrl.href); + return; + } + + await import(new URL(distPath, import.meta.url).href); +} diff --git a/package.json b/package.json index 9a4c2a83a..1835ba80d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "schema:config": "tsx scripts/generate-config-schema.ts", "start": "node dist/cli.js serve", "test": "tsx --test --test-concurrency=1 \"src/**/*.test.ts\"", + "test:package-install": "tsx --test --test-concurrency=1 \"test/package-install-smoke.test.ts\"", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/bin-launcher.test.ts b/src/bin-launcher.test.ts new file mode 100644 index 000000000..00b43a99f --- /dev/null +++ b/src/bin-launcher.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; + +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); +const tsxRoot = join(projectRoot, "node_modules", "tsx"); + +for (const entrypoint of [ + { + bin: "devspace.js", + source: "src/cli.ts", + dist: "dist/cli.js", + }, + { + bin: "devspace-agentd.js", + source: "src/local-agent-daemon-main.ts", + dist: "dist/local-agent-daemon-main.js", + }, +]) { + testLauncher(entrypoint); +} + +testLinkedCheckoutReadsCurrentConfig(); +testMissingSourceRuntimeFailsClosed(); + +function testLinkedCheckoutReadsCurrentConfig(): void { + const root = mkdtempSync(join(tmpdir(), "devspace-bin-config-test-")); + try { + const env = writeTestDevspaceConfig(root, { tools: { mode: "codex" } }); + const output = execFileSync(process.execPath, [join(projectRoot, "bin", "devspace.js"), "config", "get"], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); + const config = JSON.parse(output) as { tools?: { mode?: string } }; + assert.equal(config.tools?.mode, "codex"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function testMissingSourceRuntimeFailsClosed(): void { + const root = mkdtempSync(join(tmpdir(), "devspace-bin-missing-tsx-test-")); + try { + cpSync(join(projectRoot, "bin"), join(root, "bin"), { recursive: true }); + mkdirSync(join(root, "src"), { recursive: true }); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync(join(root, "package.json"), JSON.stringify({ type: "module" })); + writeFileSync(join(root, "src", "cli.ts"), 'console.log("source");\n'); + writeFileSync(join(root, "dist", "cli.js"), 'console.log("stale-dist");\n'); + + assert.throws( + () => execFileSync(process.execPath, [join(root, "bin", "devspace.js")], { encoding: "utf8", stdio: "pipe" }), + /source checkout.*tsx.*pnpm install/is, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function testLauncher(entrypoint: { bin: string; source: string; dist: string }): void { + const root = mkdtempSync(join(tmpdir(), "devspace-bin-launcher-test-")); + try { + cpSync(join(projectRoot, "bin"), join(root, "bin"), { recursive: true }); + mkdirSync(dirname(join(root, entrypoint.source)), { recursive: true }); + mkdirSync(dirname(join(root, entrypoint.dist)), { recursive: true }); + mkdirSync(join(root, "node_modules"), { recursive: true }); + symlinkSync(tsxRoot, join(root, "node_modules", "tsx"), process.platform === "win32" ? "junction" : "dir"); + writeFileSync(join(root, "package.json"), JSON.stringify({ type: "module" })); + writeFileSync(join(root, entrypoint.source), 'console.log("source");\n'); + writeFileSync(join(root, entrypoint.dist), 'console.log("dist");\n'); + + const sourceOutput = execFileSync(process.execPath, [join(root, "bin", entrypoint.bin)], { + encoding: "utf8", + }).trim(); + assert.equal(sourceOutput, "source", `${entrypoint.bin} must prefer source in a linked checkout`); + + rmSync(join(root, entrypoint.source)); + const packagedOutput = execFileSync(process.execPath, [join(root, "bin", entrypoint.bin)], { + encoding: "utf8", + }).trim(); + assert.equal(packagedOutput, "dist", `${entrypoint.bin} must use dist in a published package`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} diff --git a/test/package-install-smoke.test.ts b/test/package-install-smoke.test.ts new file mode 100644 index 000000000..c53477fd8 --- /dev/null +++ b/test/package-install-smoke.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { writeTestDevspaceConfig } from "../src/test-support/config.test.js"; + +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); + +testPackedPackageLaunchers(); + +function testPackedPackageLaunchers(): void { + const root = mkdtempSync(join(tmpdir(), "devspace-packed-bin-test-")); + const installRoot = join(root, "install"); + try { + mkdirSync(installRoot, { recursive: true }); + execFileSync(npmExecutable(), ["pack", "--silent", "--pack-destination", root], { + cwd: projectRoot, + encoding: "utf8", + stdio: "pipe", + shell: process.platform === "win32", + }); + const archive = readdirSync(root).find((name) => name.endsWith(".tgz")); + assert.ok(archive, "npm pack must produce a package archive"); + + execFileSync(npmExecutable(), [ + "install", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--no-save", + "--omit=optional", + join(root, archive), + ], { + cwd: installRoot, + encoding: "utf8", + stdio: "pipe", + shell: process.platform === "win32", + }); + + const configRoot = join(root, "config"); + const env = writeTestDevspaceConfig(configRoot, { + storage: { stateDir: join(root, "state") }, + workspaces: { allowedRoots: [root], worktreeRoot: join(root, "worktrees") }, + skills: { agentDir: join(root, "agents") }, + }); + const cliOutput = execInstalledBin(installRoot, "devspace", ["config", "get"], { + ...process.env, + ...env, + }); + const config = JSON.parse(cliOutput) as { tools?: { mode?: string } }; + assert.equal(config.tools?.mode, "codex"); + + execInstalledBin(installRoot, "devspace-agentd", [], { + ...process.env, + ...env, + DEVSPACE_AGENTD_IDLE_TIMEOUT_MS: "0", + DEVSPACE_AGENTD_SHUTDOWN_TIMEOUT_MS: "1000", + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function npmExecutable(): string { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +function execInstalledBin( + installRoot: string, + name: string, + args: string[], + env: NodeJS.ProcessEnv, +): string { + const executable = join( + installRoot, + "node_modules", + ".bin", + process.platform === "win32" ? `${name}.cmd` : name, + ); + return execFileSync(executable, args, { + encoding: "utf8", + env, + stdio: "pipe", + shell: process.platform === "win32", + }); +}