diff --git a/bin/fastedge-debug.js b/bin/fastedge-debug.js index cdf0a10..989207f 100755 --- a/bin/fastedge-debug.js +++ b/bin/fastedge-debug.js @@ -34,8 +34,43 @@ function resolveAppRoot(startPath) { return dir; } -process.env.WORKSPACE_PATH = resolveAppRoot( - process.argv[2] ? resolve(process.argv[2]) : process.cwd() -); +// Parse `--project-dir ` / `--project-dir=` and strip it from argv +// before the server import, so the server's own arg handling doesn't see it. +// When set, it overrides the positional fallback for resolveAppRoot — useful +// when running from a nested sandbox (e.g. `cd fastedge-test && npm run debug` +// with the project root one directory up). +function extractProjectDirFlag(argv) { + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === "--project-dir" || a === "-C") { + const value = argv[i + 1]; + if (!value) { + console.error(`Error: ${a} requires a path argument.`); + process.exit(2); + } + argv.splice(i, 2); + return value; + } + const eq = a.startsWith("--project-dir=") + ? a.slice("--project-dir=".length) + : a.startsWith("-C=") + ? a.slice("-C=".length) + : null; + if (eq !== null) { + argv.splice(i, 1); + return eq; + } + } + return null; +} + +const projectDirFlag = extractProjectDirFlag(process.argv); +const startPath = projectDirFlag + ? resolve(projectDirFlag) + : process.argv[2] + ? resolve(process.argv[2]) + : process.cwd(); + +process.env.WORKSPACE_PATH = resolveAppRoot(startPath); import("../dist/server.js"); diff --git a/docs/API.md b/docs/API.md index bda446d..e672a26 100644 --- a/docs/API.md +++ b/docs/API.md @@ -18,12 +18,12 @@ The port can be overridden via the `PORT` environment variable. The active port The `POST /api/execute`, `POST /api/send`, and `POST /api/config` endpoints accept an optional `X-Source` request header that tags the origin of the operation in WebSocket broadcast events. -| Value | Description | -| ------------ | ------------------------------------------------------- | -| `ui` | Request originated from the web UI (default if omitted) | -| `ai_agent` | Request originated from an AI agent | -| `api` | Request originated from direct API usage | -| `system` | Request originated from an automated system | +| Value | Description | +| ---------- | ------------------------------------------------------- | +| `ui` | Request originated from the web UI (default if omitted) | +| `ai_agent` | Request originated from an AI agent | +| `api` | Request originated from direct API usage | +| `system` | Request originated from an automated system | ```http X-Source: ai_agent @@ -179,11 +179,11 @@ curl -X POST http://localhost:5179/api/load \ **Error Responses** -| Status | Condition | -| ------ | ------------------------------------------------------------------------------------------------------------ | -| `400` | Validation failed, missing both `wasmPath` and `wasmBase64`, invalid path, or path does not end in `.wasm` | -| `400` | `httpPort` is specified and already in use (HTTP-WASM only) | -| `500` | WASM load failed or runner initialization error | +| Status | Condition | +| ------ | ----------------------------------------------------------------------------------------------------------- | +| `400` | Validation failed, missing both `wasmPath` and `wasmBase64`, invalid path, or path does not end in `.wasm` | +| `400` | `httpPort` is specified and already in use (HTTP-WASM only) | +| `500` | WASM load failed or runner initialization error | --- @@ -417,10 +417,10 @@ curl -X POST http://localhost:5179/api/execute \ **Error Responses** -| Status | Condition | -| ------ | ----------------------------------------------------------------------------------------------- | -| `400` | No WASM module loaded, or missing `path`/`url` for HTTP-WASM, or missing `url` for Proxy-WASM | -| `500` | Execution failed | +| Status | Condition | +| ------ | ---------------------------------------------------------------------------------------------- | +| `400` | No WASM module loaded, or missing `path`/`url` for HTTP-WASM, or missing `url` for Proxy-WASM | +| `500` | Execution failed | --- @@ -529,10 +529,10 @@ curl -X POST http://localhost:5179/api/call \ **Error Responses** -| Status | Condition | -| ------ | --------------------------------------------------------------------------------------- | -| `400` | Validation failed (invalid hook name, missing `properties`), or no WASM module loaded | -| `500` | Hook execution failed | +| Status | Condition | +| ------ | -------------------------------------------------------------------------------------- | +| `400` | Validation failed (invalid hook name, missing `properties`), or no WASM module loaded | +| `500` | Hook execution failed | --- @@ -652,10 +652,10 @@ curl -X POST http://localhost:5179/api/send \ **Error Responses** -| Status | Condition | -| ------ | ----------------------------------------------------------------------------- | -| `400` | Validation failed (missing `url` or `properties`), or no WASM module loaded | -| `500` | Execution failed | +| Status | Condition | +| ------ | ---------------------------------------------------------------------------- | +| `400` | Validation failed (missing `url` or `properties`), or no WASM module loaded | +| `500` | Execution failed | --- @@ -744,9 +744,9 @@ curl http://localhost:5179/api/config **Error Responses** -| Status | Condition | -| ------ | ------------------------------------------- | -| `404` | `fastedge-config.test.json` does not exist | +| Status | Condition | +| ------ | ------------------------------------------ | +| `404` | `fastedge-config.test.json` does not exist | --- @@ -805,10 +805,10 @@ curl -X POST http://localhost:5179/api/config \ **Error Responses** -| Status | Condition | -| ------ | ---------------------------------------------------------------------------------------- | -| `400` | Validation failed (missing `config.appType`, `config.request`, or `config.properties`) | -| `500` | File write failed | +| Status | Condition | +| ------ | --------------------------------------------------------------------------------------- | +| `400` | Validation failed (missing `config.appType`, `config.request`, or `config.properties`) | +| `500` | File write failed | --- @@ -863,10 +863,10 @@ curl -X POST http://localhost:5179/api/config/save-as \ **Error Responses** -| Status | Condition | -| ------ | ---------------------------------------- | -| `400` | Missing `config` or `filePath` | -| `500` | File write or directory creation failed | +| Status | Condition | +| ------ | --------------------------------------- | +| `400` | Missing `config` or `filePath` | +| `500` | File write or directory creation failed | --- @@ -886,23 +886,23 @@ Returns the JSON Schema document with `Content-Type: application/json`. #### Request Schemas -| Name | Description | -| -------------- | ------------------------------------------- | -| `api-load` | Request body schema for `POST /api/load` | -| `api-send` | Request body schema for `POST /api/send` | -| `api-call` | Request body schema for `POST /api/call` | -| `api-config` | Request body schema for `POST /api/config` | +| Name | Description | +| ------------ | ------------------------------------------ | +| `api-load` | Request body schema for `POST /api/load` | +| `api-send` | Request body schema for `POST /api/send` | +| `api-call` | Request body schema for `POST /api/call` | +| `api-config` | Request body schema for `POST /api/config` | #### Response / Type Schemas -| Name | Description | -| ----------------------- | -------------------------------------------------------------- | -| `fastedge-config.test` | Schema for `fastedge-config.test.json` config files | -| `hook-result` | Shape of a single `HookResult` object | -| `hook-call` | Shape of a `HookCall` input object | -| `full-flow-result` | Shape of the `FullFlowResult` returned by full-flow endpoints | -| `http-request` | Shape of an `HttpRequest` for HTTP-WASM execution | -| `http-response` | Shape of an `HttpResponse` returned by HTTP-WASM execution | +| Name | Description | +| ---------------------- | ------------------------------------------------------------- | +| `fastedge-config.test` | Schema for `fastedge-config.test.json` config files | +| `hook-result` | Shape of a single `HookResult` object | +| `hook-call` | Shape of a `HookCall` input object | +| `full-flow-result` | Shape of the `FullFlowResult` returned by full-flow endpoints | +| `http-request` | Shape of an `HttpRequest` for HTTP-WASM execution | +| `http-response` | Shape of an `HttpResponse` returned by HTTP-WASM execution | **Example** @@ -932,9 +932,9 @@ curl http://localhost:5179/api/schema/fastedge-config.test **Error Responses** -| Status | Condition | -| ------ | ---------------------- | -| `404` | Schema name not found | +| Status | Condition | +| ------ | --------------------- | +| `404` | Schema name not found | --- @@ -953,11 +953,11 @@ When a request body fails schema validation (Zod), `error` is the flattened Zod **Common status codes** -| Status | Meaning | -| ------ | ---------------------------------------------------------------------------------------------- | -| `400` | Invalid request body, missing required fields, or precondition not met (e.g. no WASM loaded) | -| `404` | Resource not found (config file, schema file) | -| `500` | Internal server error during execution or I/O | +| Status | Meaning | +| ------ | --------------------------------------------------------------------------------------------- | +| `400` | Invalid request body, missing required fields, or precondition not met (e.g. no WASM loaded) | +| `404` | Resource not found (config file, schema file) | +| `500` | Internal server error during execution or I/O | --- diff --git a/docs/DEBUGGER.md b/docs/DEBUGGER.md index b5a28dd..5bcab9e 100644 --- a/docs/DEBUGGER.md +++ b/docs/DEBUGGER.md @@ -4,18 +4,20 @@ Runs the FastEdge debugger HTTP server, which hosts the web UI, REST API, and We ## CLI Usage -The package exposes a `fastedge-debug` binary. Run it with `npx` without installing: +The package exposes a single binary, `fastedge-debug`. Run it with `npx` after installing `@gcoredev/fastedge-test` in your project: ```bash -npx @gcoredev/fastedge-test +npx fastedge-debug ``` -Or using the explicit binary name: +If the package isn't installed yet, the explicit form fetches and runs it in one shot: ```bash -npx fastedge-debug +npx -p @gcoredev/fastedge-test fastedge-debug ``` +> The shorthand `npx @gcoredev/fastedge-test` happens to work today because the package declares exactly one `bin` entry, and npx falls back to it when no name is given. Prefer the explicit `fastedge-debug` form — it stays correct if a second binary is ever added. + Once started, the server listens on `http://localhost:5179` by default and logs the bound address to stderr. The CLI automatically discovers the workspace root by walking up from the current directory, looking first for an existing `.fastedge-debug/` directory, then for a `package.json` or `Cargo.toml`. The resolved root is used as the base for port file and configuration file placement. Pass a path as the first argument to anchor discovery to a specific starting location: @@ -24,6 +26,24 @@ The CLI automatically discovers the workspace root by walking up from the curren npx fastedge-debug /path/to/my-app ``` +### `--project-dir ` (or `-C `) + +For setups where the CLI is invoked from a subdirectory of the project (for example, a Rust app with a `fastedge-test/` Node sandbox holding the debugger install), pass `--project-dir` to anchor workspace discovery at a different path: + +```bash +# From inside fastedge-test/, point the debugger at the parent project root +cd fastedge-test +npx fastedge-debug --project-dir .. +``` + +The flag accepts both `--project-dir ` and `--project-dir=`. `-C` is a short alias. The resolved path then drives `WORKSPACE_PATH` and all config / fixture / dotenv resolution that flows from it, exactly as if the user had invoked the CLI from that directory. The flag is stripped before any remaining positional arguments are forwarded to the server, so you can combine it with a fixture path or other options: + +```bash +npx fastedge-debug --project-dir .. ../fixtures/scenario-1.test.json +``` + +When omitted, behavior is unchanged from prior versions — the positional argument or `process.cwd()` is used as the starting point. + ## Programmatic Usage Import `startServer` from the `./server` export to start the server from your own script or test setup: @@ -98,13 +118,13 @@ curl http://localhost:5179/health ## Environment Variables -| Variable | Type | Default | Description | -| -------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------ | -| `PORT` | `number` | unset | Port the HTTP server listens on. Defaults to `5179` when not set. | -| `PROXY_RUNNER_DEBUG` | `"1"` | unset | Enable verbose debug logging for WebSocket and runner activity. | -| `VSCODE_INTEGRATION` | `"true"` | unset | Set to `"true"` when running inside the VSCode extension; enables workspace WASM detection. | -| `WORKSPACE_PATH` | `string` | unset | Absolute path to the workspace root; used as the `.env` file base and for port file placement. | -| `FASTEDGE_RUN_PATH` | `string` | unset | Override the path to the `fastedge-run` CLI binary used to execute WASM modules. | +| Variable | Type | Default | Description | +| -------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------- | +| `PORT` | `number` | unset | Port the HTTP server listens on. Defaults to `5179` when not set. | +| `PROXY_RUNNER_DEBUG` | `"1"` | unset | Enable verbose debug logging for WebSocket and runner activity. | +| `VSCODE_INTEGRATION` | `"true"` | unset | Set to `"true"` when running inside the VSCode extension; enables workspace WASM detection. | +| `WORKSPACE_PATH` | `string` | unset | Absolute path to the workspace root; used as the `.env` file base and for port file placement. | +| `FASTEDGE_RUN_PATH` | `string` | unset | Override the path to the `fastedge-run` CLI binary used to execute WASM modules. | ### Usage examples diff --git a/docs/TEST_CONFIG.md b/docs/TEST_CONFIG.md index 3fe295d..e71d111 100644 --- a/docs/TEST_CONFIG.md +++ b/docs/TEST_CONFIG.md @@ -27,12 +27,12 @@ The config schema is a union of two variants selected by `appType`: | `$schema` | `string` | No | — | URI pointing to the JSON Schema file for IDE autocompletion and validation. | | `description` | `string` | No | — | Human-readable label for this test scenario. | | `wasm` | `object` | No | — | WASM binary configuration. Required when running without a programmatic `wasmBuffer`. | -| `wasm.path` | `string` | Yes (if `wasm` present) | — | Path to the compiled `.wasm` binary, relative to the config file or absolute. | +| `wasm.path` | `string` | Yes (if `wasm` present) | — | Path to the compiled `.wasm` binary, relative to the config file or absolute. | | `wasm.description` | `string` | No | — | Human-readable label for the WASM binary. | | `appType` | `string` | Yes (schema) / CDN has runtime default | `"proxy-wasm"` | App variant. `"proxy-wasm"` for CDN mode; `"http-wasm"` for HTTP mode. HTTP-WASM has no default. | | `request` | `object` | **Yes** | — | Incoming HTTP request to simulate. | | `request.method` | `string` | Yes (schema) / runtime default | `"GET"` | HTTP method (e.g. `"GET"`, `"POST"`). | -| `request.url` | `string` | **Yes** (CDN only) | — | Full URL for the simulated upstream request (e.g. `"https://example.com/api"`). CDN mode only. | +| `request.url` | `string` | **Yes** (CDN only) | — | Full URL for the simulated upstream request (e.g. `"https://example.com/api"`). CDN mode only. | | `request.path` | `string` | **Yes** (HTTP-WASM only) | — | Request path (e.g. `"/api/submit"`). HTTP-WASM mode only. The WASM module acts as the origin server and receives only the path portion of the request. | | `request.headers` | `object` | Yes (schema) / runtime default | `{}` | Key/value map of request headers. All keys and values must be strings. | | `request.body` | `string` | Yes (schema) / runtime default | `""` | Request body as a plain string. Use an empty string for requests with no body. | diff --git a/docs/TEST_FRAMEWORK.md b/docs/TEST_FRAMEWORK.md index 300eb22..563c0c1 100644 --- a/docs/TEST_FRAMEWORK.md +++ b/docs/TEST_FRAMEWORK.md @@ -195,13 +195,13 @@ interface RunnerConfig { } ``` -| Field | Type | Description | -| -------------------------------- | ------------------------------ | ------------------------------------------------------------------------- | -| `dotenv.enabled` | `boolean` | Enable dotenv loading | -| `dotenv.path` | `string` | Directory to load dotenv files from; defaults to process CWD when omitted | -| `enforceProductionPropertyRules` | `boolean` | Override production property enforcement for the runner; default `true` | -| `runnerType` | `"http-wasm" \| "proxy-wasm"` | Override automatic WASM type detection | -| `httpPort` | `number` | Pin the HTTP server to a specific port (HTTP WASM only; throws if in use) | +| Field | Type | Description | +| -------------------------------- | ------------------------------- | ------------------------------------------------------------------------- | +| `dotenv.enabled` | `boolean` | Enable dotenv loading | +| `dotenv.path` | `string` | Directory to load dotenv files from; defaults to process CWD when omitted | +| `enforceProductionPropertyRules` | `boolean` | Override production property enforcement for the runner; default `true` | +| `runnerType` | `"http-wasm" \| "proxy-wasm"` | Override automatic WASM type detection | +| `httpPort` | `number` | Pin the HTTP server to a specific port (HTTP WASM only; throws if in use) | ## Functions diff --git a/esbuild/bundle-lib.js b/esbuild/bundle-lib.js index fc46728..16f0fe6 100644 --- a/esbuild/bundle-lib.js +++ b/esbuild/bundle-lib.js @@ -22,16 +22,28 @@ const entryPoint = path.join(projectRoot, "server", "runner", "index.ts"); const testFrameworkEntry = path.join(projectRoot, "server", "test-framework", "index.ts"); const distTestFrameworkDir = path.join(distLibDir, "test-framework"); -// All Node.js built-in modules to mark external +// Bare-name Node.js built-ins (the "node:" prefixed forms are handled by the +// nodeBuiltinsPlugin below — externalizing every "node:*" specifier without +// needing to enumerate them). const nodeBuiltins = [ - "node:fs", "node:path", "node:os", "node:util", "node:stream", - "node:events", "node:crypto", "node:buffer", "node:url", "node:http", - "node:https", "node:net", "node:tls", "node:child_process", "node:worker_threads", "fs", "path", "os", "util", "stream", "events", "crypto", "buffer", "url", "http", "https", "net", "tls", "child_process", "worker_threads", "module", "assert", "readline", "v8", "vm", ]; +// Mark any "node:*" specifier external. esbuild's `external` config only +// accepts literal strings, so we use a resolve plugin to cover the whole +// prefix without hand-curating every name. +const nodeBuiltinsPlugin = { + name: "node-builtins-external", + setup(build) { + build.onResolve({ filter: /^node:/ }, (args) => ({ + path: args.path, + external: true, + })); + }, +}; + async function buildLib() { console.log("📦 Building runner + test-framework library (ESM + CJS)..."); @@ -46,6 +58,7 @@ async function buildLib() { bundle: true, platform: "node", target: "node20", + plugins: [nodeBuiltinsPlugin], external: [ ...nodeBuiltins, // All npm dependencies are external — consumers install their own @@ -64,10 +77,21 @@ async function buildLib() { define: { "import.meta.url": "__importMetaUrl" }, }; + // Provide a real `require` to the ESM bundle. esbuild emits CJS-style + // `require(...)` calls when it inlines CommonJS deps (e.g. undici), and + // without this banner those fall through to a shim that throws because + // `require` is undefined in ESM module scope. + const esmRequireShim = { + banner: { + js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);", + }, + }; + try { // ── Runner entry (.) ────────────────────────────────────────────────────── await esbuild.build({ ...sharedConfig, + ...esmRequireShim, entryPoints: [entryPoint], format: "esm", outfile: path.join(distLibDir, "index.js"), @@ -86,6 +110,7 @@ async function buildLib() { // ── Test framework entry (./test) ───────────────────────────────────────── await esbuild.build({ ...sharedConfig, + ...esmRequireShim, entryPoints: [testFrameworkEntry], format: "esm", outfile: path.join(distTestFrameworkDir, "index.js"), diff --git a/fastedge-plugin-source/.generation-config.md b/fastedge-plugin-source/.generation-config.md index 51fe936..4ac9fb0 100644 --- a/fastedge-plugin-source/.generation-config.md +++ b/fastedge-plugin-source/.generation-config.md @@ -561,7 +561,18 @@ Running the debugger server. CLI usage, programmatic startup, port configuration ### Required Content -**CLI Usage** — `npx fastedge-debug` / `npx @gcoredev/fastedge-test` +**CLI Usage** — canonical invocation is `npx fastedge-debug` (the package +declares a single `bin`: `fastedge-debug`). `npx @gcoredev/fastedge-test` +works only via npm's single-bin fallback and is fragile if a second bin is +ever added; do not use it as the primary example. If the package isn't yet +installed in the project, prefer the explicit form +`npx -p @gcoredev/fastedge-test fastedge-debug` instead. + +Document the `--project-dir ` (alias `-C `) flag: anchors +workspace discovery at the resolved path, drives `WORKSPACE_PATH`, is +stripped from argv before any positional arguments reach the server. Show +the common use case (invoking from a subdirectory like `fastedge-test/` +when the project root is one directory up). **Programmatic Usage** — `startServer(port?)` with import path, signature, example diff --git a/server/__tests__/unit/utils/fastedge-cli.test.ts b/server/__tests__/unit/utils/fastedge-cli.test.ts new file mode 100644 index 0000000..9ba9e47 --- /dev/null +++ b/server/__tests__/unit/utils/fastedge-cli.test.ts @@ -0,0 +1,219 @@ +/** + * fastedge-cli resolution tests + * + * Exercises `getPackageRoot` / `getBundledCliPaths` against synthetic package + * layouts on disk. The walker is anchored on a package.json with name + * "@gcoredev/fastedge-test", and these tests cover the layouts the resolver + * must handle: + * - the installed npm layout (consumers import from dist/lib/ or + * dist/lib/test-framework/), + * - the in-repo source layout (server/utils/ → fastedge-run/), + * - workspace installs where a sibling package.json sits above ours, + * - intermediate package.json files written by the build (dist/lib/ + * gets a `{ "type": "module" }` package.json from esbuild/bundle-lib.js) + * or otherwise non-matching / unreadable. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, mkdir, writeFile, rm } from "fs/promises"; +import { join } from "path"; +import { platform, tmpdir } from "os"; +import { + getPackageRoot, + getBundledCliPaths, +} from "../../../utils/fastedge-cli.js"; + +const PKG_NAME = "@gcoredev/fastedge-test"; + +async function writePkgJson(dir: string, body: object): Promise { + await writeFile(join(dir, "package.json"), JSON.stringify(body), "utf8"); +} + +function expectedBinaryName(): string { + switch (platform()) { + case "win32": + return "fastedge-run.exe"; + case "darwin": + return "fastedge-run-darwin-arm64"; + case "linux": + return "fastedge-run-linux-x64"; + default: + throw new Error(`Unsupported test platform: ${platform()}`); + } +} + +describe("fastedge-cli resolution", () => { + let workdir: string; + + beforeEach(async () => { + workdir = await mkdtemp(join(tmpdir(), "fastedge-cli-test-")); + }); + + afterEach(async () => { + await rm(workdir, { recursive: true, force: true }); + }); + + describe("getPackageRoot", () => { + it("finds root from the runner entry (dist/lib/)", async () => { + const root = join(workdir, "pkg"); + await mkdir(join(root, "dist", "lib"), { recursive: true }); + await writePkgJson(root, { name: PKG_NAME }); + + expect(getPackageRoot(join(root, "dist", "lib"))).toBe(root); + }); + + it("finds root from the test-framework entry (dist/lib/test-framework/) — regression for the original two-level-deeper bug", async () => { + const root = join(workdir, "pkg"); + await mkdir(join(root, "dist", "lib", "test-framework"), { + recursive: true, + }); + await writePkgJson(root, { name: PKG_NAME }); + // bundle-lib.js writes `{ "type": "module" }` into dist/lib/package.json. + // The walker must skip this (no name) and keep going up. + await writePkgJson(join(root, "dist", "lib"), { type: "module" }); + + expect(getPackageRoot(join(root, "dist", "lib", "test-framework"))).toBe( + root, + ); + }); + + it("finds root from the source tree (server/utils/)", async () => { + const root = join(workdir, "pkg"); + await mkdir(join(root, "server", "utils"), { recursive: true }); + await writePkgJson(root, { name: PKG_NAME }); + + expect(getPackageRoot(join(root, "server", "utils"))).toBe(root); + }); + + it("walks past a sibling package.json with a different name", async () => { + // Simulates a workspace install where ours sits next to other packages + // under a workspace root that itself has a package.json. + const root = join(workdir, "ours"); + await mkdir(join(root, "dist", "lib", "test-framework"), { + recursive: true, + }); + await writePkgJson(root, { name: PKG_NAME }); + await writePkgJson(workdir, { name: "@other/workspace-root" }); + + expect(getPackageRoot(join(root, "dist", "lib", "test-framework"))).toBe( + root, + ); + }); + + it("walks past a non-JSON / unreadable package.json", async () => { + const root = join(workdir, "pkg"); + await mkdir(join(root, "dist", "lib", "test-framework"), { + recursive: true, + }); + await writePkgJson(root, { name: PKG_NAME }); + // Garbage at an intermediate level must not abort the walk. + await writeFile( + join(root, "dist", "lib", "package.json"), + "{ not valid json", + "utf8", + ); + + expect(getPackageRoot(join(root, "dist", "lib", "test-framework"))).toBe( + root, + ); + }); + + it("returns null when no matching package.json is found up to filesystem root", async () => { + const dir = join(workdir, "no-pkg", "deep", "tree"); + await mkdir(dir, { recursive: true }); + + expect(getPackageRoot(dir)).toBeNull(); + }); + + it("does not anchor on a package.json with the wrong name even if it is the only one in the tree", async () => { + // Defensive: a stray same-name dir from another vendor shouldn't trick us. + const root = join(workdir, "lookalike"); + await mkdir(join(root, "dist", "fastedge-cli"), { recursive: true }); + await writePkgJson(root, { name: "@vendor/fastedge-test-fork" }); + + expect(getPackageRoot(join(root, "dist"))).toBeNull(); + }); + + it("inspects the startDir itself on the first iteration (package.json directly at startDir is found)", async () => { + // Regression for the boundary case where the package root coincides + // with the search start — the old `while (dir !== dirname(dir))` form + // would skip the start dir if it were also the filesystem root. + const root = join(workdir, "pkg"); + await mkdir(root, { recursive: true }); + await writePkgJson(root, { name: PKG_NAME }); + + expect(getPackageRoot(root)).toBe(root); + }); + }); + + describe("getBundledCliPaths", () => { + it("anchors candidates on the resolved package root for both layouts, with startDir-relative fallbacks appended", async () => { + const root = join(workdir, "pkg"); + const startDir = join(root, "dist", "lib", "test-framework"); + await mkdir(startDir, { recursive: true }); + await writePkgJson(root, { name: PKG_NAME }); + + const paths = getBundledCliPaths(startDir); + const bin = expectedBinaryName(); + + expect(paths).toEqual([ + // Package-root anchored + join(root, "dist", "fastedge-cli", bin), + join(root, "fastedge-run", bin), + // startDir-relative fallback + join(startDir, "fastedge-cli", bin), + join(startDir, "..", "fastedge-cli", bin), + ]); + }); + + it("yields the same candidates regardless of which export entry the caller starts from", async () => { + // Both entries resolve to the same package root, but their startDir + // differs — so the fallback portion differs, even though the + // root-anchored portion is identical. Compare just the anchored slice. + const root = join(workdir, "pkg"); + await mkdir(join(root, "dist", "lib", "test-framework"), { + recursive: true, + }); + await writePkgJson(root, { name: PKG_NAME }); + + const fromRunner = getBundledCliPaths(join(root, "dist", "lib")); + const fromTestFramework = getBundledCliPaths( + join(root, "dist", "lib", "test-framework"), + ); + + // First two entries are the root-anchored candidates and must match. + expect(fromTestFramework.slice(0, 2)).toEqual(fromRunner.slice(0, 2)); + }); + + it("falls back to startDir-relative candidates when the package root cannot be located (VSCode bundle scenario)", async () => { + // Simulates the VSCode extension layout: dist/server.js is copied into + // the extension tree with dist/fastedge-cli/ as a sibling, but no + // @gcoredev/fastedge-test package.json is anywhere up the parent chain. + const bundleDir = join(workdir, "extension", "dist"); + await mkdir(join(bundleDir, "fastedge-cli"), { recursive: true }); + + const paths = getBundledCliPaths(bundleDir); + const bin = expectedBinaryName(); + + // No root-anchored candidates (root not found) — only startDir-relative. + expect(paths).toEqual([ + join(bundleDir, "fastedge-cli", bin), + join(bundleDir, "..", "fastedge-cli", bin), + ]); + }); + + it("returns startDir-relative candidates even when the directory layout has nothing in it (caller filters by existence)", async () => { + // The function does not check existence — that's findFastEdgeRunCli's + // job. So even a bare directory returns the two fallback paths; they + // simply won't pass the existsSync filter downstream. + const dir = join(workdir, "no-pkg"); + await mkdir(dir, { recursive: true }); + const bin = expectedBinaryName(); + + expect(getBundledCliPaths(dir)).toEqual([ + join(dir, "fastedge-cli", bin), + join(dir, "..", "fastedge-cli", bin), + ]); + }); + }); +}); diff --git a/server/utils/fastedge-cli.ts b/server/utils/fastedge-cli.ts index c6819c9..3d04847 100644 --- a/server/utils/fastedge-cli.ts +++ b/server/utils/fastedge-cli.ts @@ -3,12 +3,15 @@ * * Discovers the FastEdge-run CLI binary in the following order: * 1. FASTEDGE_RUN_PATH environment variable - * 2. Bundled binary in server/fastedge-cli/ (platform-specific) + * 2. Bundled binary inside the @gcoredev/fastedge-test package, anchored on + * the package root (see getPackageRoot): + * • dist/fastedge-cli/ — published npm layout + * • fastedge-run/ — in-repo source/dev layout * 3. PATH (using 'which' or 'where' command) */ import { execSync } from "child_process"; -import { existsSync, chmodSync } from "fs"; +import { existsSync, chmodSync, readFileSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import os from "os"; @@ -37,26 +40,80 @@ function getCliBinaryName(): string { } /** - * Get possible bundled CLI paths - * Checks both production (dist/fastedge-cli/) and source (fastedge-run/) locations + * Walk up from `startDir` until a package.json with name "@gcoredev/fastedge-test" + * is found. Anchoring on the package name (rather than hardcoded depths or an + * unbounded walk) keeps the search robust across bundle layouts and avoids + * climbing into a sibling package in workspace/monorepo installs. + * + * `startDir` defaults to the directory of this file (resolved at module load). + * It is overridable for tests so the resolver can be exercised against + * synthetic package layouts. */ -function getBundledCliPaths(): string[] { - const binaryName = getCliBinaryName(); - - return [ - // Installed npm package: dist/lib/index.js → dist/fastedge-cli/ - join(_currentDir, "..", "fastedge-cli", binaryName), +export function getPackageRoot(startDir: string = _currentDir): string | null { + // Check the current dir first, then break only after detecting that the + // parent equals the current dir (i.e. we're at the filesystem root). This + // ensures the root directory itself is inspected — important if the package + // ever lives at "/" (uncommon in practice, common enough in containers/CI + // to be worth handling defensively). + let dir = startDir; + while (true) { + const pkgPath = join(dir, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + if (pkg.name === "@gcoredev/fastedge-test") return dir; + } catch { + // Unreadable or non-JSON — keep walking + } + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} - // Production: bundled server at dist/server.js → dist/fastedge-cli/ - join(_currentDir, "fastedge-cli", binaryName), +/** + * Get possible bundled CLI paths. + * + * Two resolution modes, both contribute candidates: + * + * 1. **Package-root anchored** (primary) — when a `@gcoredev/fastedge-test` + * package.json can be located via `getPackageRoot`, candidates resolve + * against it: the published npm layout (`dist/fastedge-cli/`) and the + * in-repo source layout (`fastedge-run/`). + * + * 2. **startDir-relative fallback** — covers bundle layouts that ship + * without our package.json available nearby. Notably the VSCode extension + * copies `dist/server.js` and `dist/fastedge-cli/` into its own tree, so + * the walker can't anchor on our package root. The fallback lets the + * server bundle locate the sibling `fastedge-cli/` directory directly. + * + * `findFastEdgeRunCli` filters by existence, so listing extra candidates is + * safe — the first one that actually exists wins. `startDir` is overridable + * for tests. + */ +export function getBundledCliPaths(startDir: string = _currentDir): string[] { + const binaryName = getCliBinaryName(); + const candidates: string[] = []; + + // Primary: anchor on our package.json. + const root = getPackageRoot(startDir); + if (root) { + candidates.push( + join(root, "dist", "fastedge-cli", binaryName), + join(root, "fastedge-run", binaryName), + ); + } - // Development/Tests: running from source - // _currentDir might be server/utils/, so go up to project root - join(_currentDir, "..", "..", "fastedge-run", binaryName), + // Fallback: startDir-relative candidates for bundles without our + // package.json nearby (e.g. the VSCode extension's copy of dist/server.js + // sitting next to dist/fastedge-cli/). + candidates.push( + join(startDir, "fastedge-cli", binaryName), + join(startDir, "..", "fastedge-cli", binaryName), + ); - // Alternative: if _currentDir is already at project root - join(_currentDir, "fastedge-run", binaryName), - ]; + return candidates; } /** @@ -124,12 +181,14 @@ export async function findFastEdgeRunCli(): Promise { throw new Error( "fastedge-run CLI not found in any of these locations:\n" + " 1. FASTEDGE_RUN_PATH environment variable\n" + - " 2. Bundled binary in fastedge-cli/ (project root)\n" + - " 3. System PATH\n\n" + + " 2. Bundled inside the @gcoredev/fastedge-test package " + + "(dist/fastedge-cli/ when installed, fastedge-run/ in the source tree)\n" + + " 3. System PATH (which/where fastedge-run)\n\n" + "To fix this:\n" + - " - Set FASTEDGE_RUN_PATH environment variable, or\n" + + " - Set FASTEDGE_RUN_PATH to a fastedge-run binary you have locally, or\n" + " - Install fastedge-run in PATH: cargo install fastedge-run, or\n" + - " - Place the binary in fastedge-cli/ at project root (platform-specific filename)", + " - Reinstall @gcoredev/fastedge-test to restore the bundled binary " + + "(or, when developing this repo, place the platform binary in fastedge-run/)", ); }