diff --git a/.agents/skills/effort-graph/setup.md b/.agents/skills/effort-graph/setup.md index d38a95ac..a1b26eee 100644 --- a/.agents/skills/effort-graph/setup.md +++ b/.agents/skills/effort-graph/setup.md @@ -88,14 +88,18 @@ client polling loops. Semantic changes go through `flatbread effort write`. ## 4. Open the explorer (optional) With a complete `effortGraphContent()` preset in config, Flatbread serves the -content-relation explorer automatically: +content-relation explorer automatically (`@flatbread/explorer` ships with +`flatbread`): ```bash flatbread start --watch --open ``` -- Explorer UI: `http://localhost:5057/` +Mounting and `--open` share the same assets gate: if the packaged SPA assets +are missing, Flatbread skips the explorer mount and `--open` falls back to +`/graphql`. + +- Explorer UI (when mounted): `http://localhost:5057/` - Apollo GraphQL sandbox: `http://localhost:5057/graphql` -No separate app install is required; `@flatbread/explorer` ships with -`flatbread`. +No separate app install is required. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c41a59bf..63305475 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nex - Build all packages: `pnpm build` - **Workspace libraries (watch-only):** `pnpm dev` — runs package `dev` scripts (e.g. `tsup --watch`) for `packages/*`; it does **not** start the Next.js example. - **Next.js example:** prefer the flow under [Recommended onboarding](#recommended-onboarding-try-flatbread-in-the-nextjs-example); or `pnpm play` as a convenience alias. -- **Effort Graph explorer:** after `pnpm build`, run `pnpm play:efforts` (`flatbread start --watch --open`). When `flatbread.config.js` uses `effortGraphContent()`, Flatbread serves `@flatbread/explorer` at `http://localhost:5057/` (Apollo sandbox at `/graphql`). +- **Effort Graph explorer:** run `pnpm play:efforts` (builds `@flatbread/explorer` via `preplay:efforts`, then `flatbread start --watch --open`). When `flatbread.config.js` uses `effortGraphContent()`, Flatbread serves `@flatbread/explorer` at `http://localhost:5057/` (Apollo sandbox at `/graphql`). For HMR on the SPA shell, run `flatbread start --watch` and `pnpm --filter @flatbread/explorer dev` in parallel (Vite on **5173** proxies API routes to **5057**). - Check local CI parity before opening a PR: `pnpm verify` ## Working on a package diff --git a/package.json b/package.json index 50bbc8b6..54fb5e3a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "lint:fix:prettier": "pretty-quick --staged", "typecheck": "pnpm --filter @flatbread/proof --filter @flatbread/explorer typecheck", "play": "cd examples/nextjs && pnpm dev", + "preplay:efforts": "pnpm --filter @flatbread/explorer build", "play:efforts": "pnpm exec flatbread start --watch --open", "play:build": "pnpm build && cd examples/nextjs && pnpm build", "prepublish:ci": "pnpm install --frozen-lockfile && pnpm build:types", diff --git a/packages/effort-graph/skills/effort-graph/setup.md b/packages/effort-graph/skills/effort-graph/setup.md index d38a95ac..a1b26eee 100644 --- a/packages/effort-graph/skills/effort-graph/setup.md +++ b/packages/effort-graph/skills/effort-graph/setup.md @@ -88,14 +88,18 @@ client polling loops. Semantic changes go through `flatbread effort write`. ## 4. Open the explorer (optional) With a complete `effortGraphContent()` preset in config, Flatbread serves the -content-relation explorer automatically: +content-relation explorer automatically (`@flatbread/explorer` ships with +`flatbread`): ```bash flatbread start --watch --open ``` -- Explorer UI: `http://localhost:5057/` +Mounting and `--open` share the same assets gate: if the packaged SPA assets +are missing, Flatbread skips the explorer mount and `--open` falls back to +`/graphql`. + +- Explorer UI (when mounted): `http://localhost:5057/` - Apollo GraphQL sandbox: `http://localhost:5057/graphql` -No separate app install is required; `@flatbread/explorer` ships with -`flatbread`. +No separate app install is required. diff --git a/packages/explorer/README.md b/packages/explorer/README.md index c3bf0df4..838754b7 100644 --- a/packages/explorer/README.md +++ b/packages/explorer/README.md @@ -13,7 +13,9 @@ flatbread start --watch --open # → http://localhost:5057/graphql Apollo sandbox ``` -No separate Next app is required. +No separate Next app is required. Requires a built `dist/static` (see +[Develop in the monorepo](#develop-in-the-monorepo)); `pnpm play:efforts` runs +that build automatically. ## Static deploy @@ -31,6 +33,7 @@ Same-origin deploys (assets served by Flatbread) need no query param. | Export | Role | | ------------------------------ | ------------------------------------------ | | `getExplorerStaticDir()` | Absolute path to `dist/static` for Express | +| `explorerAssetsPresent()` | Whether prebuilt `index.html` exists | | `matchExplorerPreset(content)` | Detect Effort Graph (and later presets) | | `EXPLORER_BOOTSTRAP_PATH` | Bootstrap JSON path Flatbread injects | @@ -40,6 +43,14 @@ There is no public React component export in v1. ```bash pnpm --filter @flatbread/explorer test -pnpm --filter @flatbread/explorer build -pnpm play:efforts # flatbread start --watch --open from repo root +pnpm play:efforts # builds explorer, then flatbread start --watch --open +``` + +For UI-only iteration with HMR, run Flatbread and Vite in separate terminals +(Vite proxies `/graphql` and `/events` to Flatbread on port **5057**, or +`FLATBREAD_PORT` when set): + +```bash +pnpm exec flatbread start --watch # terminal 1 — GraphQL on :5057 +pnpm --filter @flatbread/explorer dev # terminal 2 — SPA on :5173 ``` diff --git a/packages/explorer/src/node/index.ts b/packages/explorer/src/node/index.ts index d75175a9..97c492e8 100644 --- a/packages/explorer/src/node/index.ts +++ b/packages/explorer/src/node/index.ts @@ -1,4 +1,9 @@ -export { getExplorerStaticDir, EXPLORER_BOOTSTRAP_PATH } from './staticDir.js'; +export { + explorerAssetsPresent, + getExplorerStaticDir, + setExplorerStaticDirOverride, + EXPLORER_BOOTSTRAP_PATH, +} from './staticDir.js'; export { matchExplorerPreset, type ExplorerPresetId, diff --git a/packages/explorer/src/node/staticDir.test.ts b/packages/explorer/src/node/staticDir.test.ts index f710d1b1..ee24d461 100644 --- a/packages/explorer/src/node/staticDir.test.ts +++ b/packages/explorer/src/node/staticDir.test.ts @@ -1,8 +1,18 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; -import { describe, it } from 'node:test'; -import { getExplorerStaticDir } from './staticDir.js'; +import { afterEach, describe, it } from 'node:test'; +import { + explorerAssetsPresent, + getExplorerStaticDir, + setExplorerStaticDirOverride, +} from './staticDir.js'; + +afterEach(() => { + setExplorerStaticDirOverride(undefined); +}); describe('getExplorerStaticDir', () => { it('resolves to a path ending in dist/static', () => { @@ -24,4 +34,41 @@ describe('getExplorerStaticDir', () => { assert.ok(fs.existsSync(index), `expected ${index} after vite build`); } }); + + it('honors setExplorerStaticDirOverride when set', async () => { + const emptyDir = await mkdtemp( + path.join(os.tmpdir(), 'flatbread-explorer-static-') + ); + try { + setExplorerStaticDirOverride(emptyDir); + assert.equal(getExplorerStaticDir(), emptyDir); + assert.equal(explorerAssetsPresent(), false); + } finally { + setExplorerStaticDirOverride(undefined); + await rm(emptyDir, { recursive: true, force: true }); + } + }); +}); + +describe('explorerAssetsPresent', () => { + it('reflects whether index.html exists under the static dir', async () => { + const indexPath = path.join(getExplorerStaticDir(), 'index.html'); + if (!fs.existsSync(indexPath)) { + assert.equal(explorerAssetsPresent(), false); + return; + } + + assert.equal(explorerAssetsPresent(), true); + + const emptyDir = await mkdtemp( + path.join(os.tmpdir(), 'flatbread-explorer-absent-') + ); + try { + setExplorerStaticDirOverride(emptyDir); + assert.equal(explorerAssetsPresent(), false); + } finally { + setExplorerStaticDirOverride(undefined); + await rm(emptyDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/explorer/src/node/staticDir.ts b/packages/explorer/src/node/staticDir.ts index 654551d6..6668be95 100644 --- a/packages/explorer/src/node/staticDir.ts +++ b/packages/explorer/src/node/staticDir.ts @@ -1,16 +1,40 @@ +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; /** HTTP path Flatbread serves for explorer bootstrap JSON. */ export const EXPLORER_BOOTSTRAP_PATH = '/__flatbread/explorer.json'; +let staticDirOverride: string | undefined; + +/** + * Test-only: force `getExplorerStaticDir()` to `dir`. + * Pass `undefined` to clear. Not for production callers. + */ +export function setExplorerStaticDirOverride(dir: string | undefined): void { + staticDirOverride = dir; +} + /** * Absolute path to the prebuilt SPA assets shipped in this package. * Flatbread mounts these with `express.static` when a preset matches. + * Honors `setExplorerStaticDirOverride` when set (tests only). */ export function getExplorerStaticDir(): string { + if (staticDirOverride !== undefined) { + return staticDirOverride; + } const here = path.dirname(fileURLToPath(import.meta.url)); // Works from both `src/node` (tests) and `dist/node` (published). const packageRoot = path.resolve(here, '../..'); return path.join(packageRoot, 'dist', 'static'); } + +/** + * True when prebuilt SPA `index.html` exists under `getExplorerStaticDir()`. + * Flatbread uses this with `matchExplorerPreset` before mounting or advertising + * explorer. + */ +export function explorerAssetsPresent(): boolean { + return fs.existsSync(path.join(getExplorerStaticDir(), 'index.html')); +} diff --git a/packages/explorer/src/web/core/endpoints.test.ts b/packages/explorer/src/web/core/endpoints.test.ts index 5fc140ce..23f1cd92 100644 --- a/packages/explorer/src/web/core/endpoints.test.ts +++ b/packages/explorer/src/web/core/endpoints.test.ts @@ -1,20 +1,47 @@ import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; +import { afterEach, describe, it } from 'node:test'; import { + type ExplorerBootstrap, normalizeGraphqlUrl, resolveEventsUrl, resolveGraphqlEndpoint, } from './endpoints.js'; +const DEFAULT_BOOTSTRAP: ExplorerBootstrap = { + preset: 'effort-graph', + graphqlPath: '/graphql', + eventsPath: '/events', +}; + +/** Stub browser globals for resolveEventsUrl (reads window.__FLATBREAD_EXPLORER__). */ +function withExplorerBootstrap( + bootstrap: ExplorerBootstrap, + run: () => void +): void { + const globalWithWindow = globalThis as typeof globalThis & { + window?: Window & { __FLATBREAD_EXPLORER__?: ExplorerBootstrap }; + }; + const previous = globalWithWindow.window; + globalWithWindow.window = { + location: { origin: 'http://localhost:5057', search: '' }, + __FLATBREAD_EXPLORER__: bootstrap, + } as Window & { __FLATBREAD_EXPLORER__?: ExplorerBootstrap }; + try { + run(); + } finally { + if (previous === undefined) { + delete globalWithWindow.window; + } else { + globalWithWindow.window = previous; + } + } +} + describe('resolveGraphqlEndpoint', () => { it('prefers ?endpoint= over bootstrap', () => { const endpoint = resolveGraphqlEndpoint( '?endpoint=https://api.example.com/graphql', - { - preset: 'effort-graph', - graphqlPath: '/graphql', - eventsPath: '/events', - }, + DEFAULT_BOOTSTRAP, 'http://localhost:5057' ); assert.equal(endpoint, 'https://api.example.com/graphql'); @@ -23,11 +50,7 @@ describe('resolveGraphqlEndpoint', () => { it('uses bootstrap same-origin paths', () => { const endpoint = resolveGraphqlEndpoint( '', - { - preset: 'effort-graph', - graphqlPath: '/graphql', - eventsPath: '/events', - }, + DEFAULT_BOOTSTRAP, 'http://localhost:5057' ); assert.equal(endpoint, 'http://localhost:5057/graphql'); @@ -37,6 +60,42 @@ describe('resolveGraphqlEndpoint', () => { const endpoint = resolveGraphqlEndpoint('', undefined, 'file://'); assert.equal(endpoint, 'http://localhost:5057/graphql'); }); + + it('resolves relative ?endpoint= against the default Node origin', () => { + const endpoint = resolveGraphqlEndpoint( + '?endpoint=/alt/graphql', + DEFAULT_BOOTSTRAP, + 'http://localhost:9999' + ); + assert.equal(endpoint, 'http://localhost:5057/alt/graphql'); + }); + + it('resolves host-without-scheme ?endpoint= by prepending http', () => { + const endpoint = resolveGraphqlEndpoint( + '?endpoint=api.example.com', + DEFAULT_BOOTSTRAP, + 'http://localhost:5057' + ); + assert.equal(endpoint, 'http://api.example.com/graphql'); + }); + + it('trims whitespace in ?endpoint=', () => { + const endpoint = resolveGraphqlEndpoint( + '?endpoint=%20%20https://api.example.com/graphql%20%20', + DEFAULT_BOOTSTRAP, + 'http://localhost:5057' + ); + assert.equal(endpoint, 'https://api.example.com/graphql'); + }); + + it('falls back when ?endpoint= is whitespace-only', () => { + const endpoint = resolveGraphqlEndpoint( + '?endpoint=%20%20%20', + DEFAULT_BOOTSTRAP, + 'http://localhost:5057' + ); + assert.equal(endpoint, 'http://localhost:5057/graphql'); + }); }); describe('normalizeGraphqlUrl', () => { @@ -46,6 +105,54 @@ describe('normalizeGraphqlUrl', () => { 'https://api.example.com/graphql' ); }); + + it('preserves an explicit /graphql path', () => { + assert.equal( + normalizeGraphqlUrl('https://api.example.com/custom/graphql'), + 'https://api.example.com/custom/graphql' + ); + }); + + it('resolves a relative path against the default Node origin', () => { + assert.equal( + normalizeGraphqlUrl('/alt/graphql'), + 'http://localhost:5057/alt/graphql' + ); + }); + + it('prepends http when given a host without a scheme', () => { + assert.equal( + normalizeGraphqlUrl('api.example.com'), + 'http://api.example.com/graphql' + ); + }); + + it('trims surrounding whitespace', () => { + assert.equal( + normalizeGraphqlUrl(' https://api.example.com/graphql '), + 'https://api.example.com/graphql' + ); + }); + + it('falls back to localhost when given whitespace only', () => { + assert.equal(normalizeGraphqlUrl(' '), 'http://localhost:5057/graphql'); + }); +}); + +describe('normalizeGraphqlUrl with window', () => { + afterEach(() => { + delete (globalThis as { window?: Window }).window; + }); + + it('resolves relative paths against window.location.origin', () => { + (globalThis as { window?: Window }).window = { + location: { origin: 'http://localhost:5173', search: '' }, + } as Window; + assert.equal( + normalizeGraphqlUrl('/dev/graphql'), + 'http://localhost:5173/dev/graphql' + ); + }); }); describe('resolveEventsUrl', () => { @@ -55,4 +162,43 @@ describe('resolveEventsUrl', () => { 'http://localhost:5057/events' ); }); + + it('uses default /events when window bootstrap is absent', () => { + assert.equal( + resolveEventsUrl('https://api.example.com/graphql'), + 'https://api.example.com/events' + ); + }); + + it('uses custom eventsPath from window bootstrap', () => { + withExplorerBootstrap( + { + preset: 'effort-graph', + graphqlPath: '/graphql', + eventsPath: '/custom-events', + }, + () => { + assert.equal( + resolveEventsUrl('http://localhost:5057/graphql'), + 'http://localhost:5057/custom-events' + ); + } + ); + }); + + it('derives custom eventsPath from the GraphQL endpoint origin only', () => { + withExplorerBootstrap( + { + preset: 'effort-graph', + graphqlPath: '/graphql', + eventsPath: '/sse/stream', + }, + () => { + assert.equal( + resolveEventsUrl('https://api.example.com/graphql'), + 'https://api.example.com/sse/stream' + ); + } + ); + }); }); diff --git a/packages/explorer/vite.config.ts b/packages/explorer/vite.config.ts index c811390c..943b5622 100644 --- a/packages/explorer/vite.config.ts +++ b/packages/explorer/vite.config.ts @@ -2,10 +2,13 @@ import path from 'node:path'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +const flatbreadPort = process.env.FLATBREAD_PORT ?? '5057'; +const flatbreadTarget = `http://localhost:${flatbreadPort}`; + export default defineConfig({ plugins: [react()], root: '.', - base: './', + base: '/', resolve: { alias: { '@': path.resolve(__dirname, 'src/web'), @@ -18,5 +21,15 @@ export default defineConfig({ }, server: { port: 5173, + proxy: { + '/graphql': { + target: flatbreadTarget, + changeOrigin: true, + }, + '/events': { + target: flatbreadTarget, + changeOrigin: true, + }, + }, }, }); diff --git a/packages/flatbread/src/cli/index.ts b/packages/flatbread/src/cli/index.ts index b1231d1f..d9a66f6d 100644 --- a/packages/flatbread/src/cli/index.ts +++ b/packages/flatbread/src/cli/index.ts @@ -10,7 +10,7 @@ import { registerEffortCommands } from './effort'; import { EXPLORER_ENDPOINT, GRAPHQL_ENDPOINT, - resolveCliOpenPath, + resolveOpenPath, } from './openPath'; import { loadFlatbreadConfig } from '../utils/getSchema'; @@ -75,7 +75,7 @@ prog let explorer = false; try { const loaded = await loadFlatbreadConfig(process.cwd()); - openPath = resolveCliOpenPath(loaded.config?.content); + openPath = resolveOpenPath(loaded.config?.content); explorer = openPath === EXPLORER_ENDPOINT; } catch { // Config may be missing during init; fall back to GraphQL sandbox. diff --git a/packages/flatbread/src/cli/openPath.test.ts b/packages/flatbread/src/cli/openPath.test.ts index d88065db..b82bdd0c 100644 --- a/packages/flatbread/src/cli/openPath.test.ts +++ b/packages/flatbread/src/cli/openPath.test.ts @@ -1,19 +1,70 @@ import test from 'ava'; import { effortGraphContent } from '@flatbread/effort-graph'; +import { + explorerAssetsPresent, + setExplorerStaticDirOverride, +} from '@flatbread/explorer'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import { join } from 'node:path'; import { EXPLORER_ENDPOINT, GRAPHQL_ENDPOINT, resolveCliOpenPath, + resolveOpenPath, } from './openPath.js'; -test('opens explorer root for a full Effort Graph preset', (t) => { +test('resolveCliOpenPath is an alias of resolveOpenPath', (t) => { + t.is(resolveCliOpenPath, resolveOpenPath); +}); + +test('opens explorer root when preset matches and assets are present', (t) => { + setExplorerStaticDirOverride(undefined); + if (!explorerAssetsPresent()) { + t.fail( + 'Explorer assets missing. Build @flatbread/explorer first (`pnpm --filter @flatbread/explorer build`).' + ); + return; + } + t.is(resolveOpenPath(effortGraphContent()), EXPLORER_ENDPOINT); + t.is(resolveOpenPath(effortGraphContent()), '/'); t.is(resolveCliOpenPath(effortGraphContent()), EXPLORER_ENDPOINT); + t.is(resolveCliOpenPath(effortGraphContent()), '/'); }); test('opens GraphQL sandbox when no explorer preset matches', (t) => { + t.is( + resolveOpenPath([{ collection: 'Post', path: 'posts' }]), + GRAPHQL_ENDPOINT + ); t.is( resolveCliOpenPath([{ collection: 'Post', path: 'posts' }]), GRAPHQL_ENDPOINT ); + t.is(resolveOpenPath(undefined), GRAPHQL_ENDPOINT); t.is(resolveCliOpenPath(undefined), GRAPHQL_ENDPOINT); }); + +test.serial( + 'opens GraphQL sandbox when preset matches but assets are missing', + async (t) => { + const emptyDir = await mkdtemp( + join(os.tmpdir(), 'flatbread-explorer-openpath-') + ); + setExplorerStaticDirOverride(emptyDir); + t.teardown(async () => { + setExplorerStaticDirOverride(undefined); + await rm(emptyDir, { recursive: true, force: true }); + }); + + t.is(resolveOpenPath(effortGraphContent()), GRAPHQL_ENDPOINT); + t.is(resolveOpenPath(effortGraphContent()), '/graphql'); + t.not(resolveOpenPath(effortGraphContent()), EXPLORER_ENDPOINT); + t.not(resolveOpenPath(effortGraphContent()), '/'); + + t.is(resolveCliOpenPath(effortGraphContent()), GRAPHQL_ENDPOINT); + t.is(resolveCliOpenPath(effortGraphContent()), '/graphql'); + t.not(resolveCliOpenPath(effortGraphContent()), EXPLORER_ENDPOINT); + t.not(resolveCliOpenPath(effortGraphContent()), '/'); + } +); diff --git a/packages/flatbread/src/cli/openPath.ts b/packages/flatbread/src/cli/openPath.ts index 38c5fee7..c855b6c2 100644 --- a/packages/flatbread/src/cli/openPath.ts +++ b/packages/flatbread/src/cli/openPath.ts @@ -1,16 +1,25 @@ -import { matchExplorerPreset } from '@flatbread/explorer'; +import { + explorerAssetsPresent, + matchExplorerPreset, +} from '@flatbread/explorer'; import type { ContentEntry } from '@flatbread/core'; export const GRAPHQL_ENDPOINT = '/graphql'; export const EXPLORER_ENDPOINT = '/'; /** - * Browser path for `flatbread start --open`. - * Explorer root when a preset matches; otherwise the Apollo sandbox. + * Browser path for `--open` / welcome: `/` only when an explorer preset matches + * **and** static assets are present (same gate as `mountExplorerIfMatched`); + * otherwise `/graphql`. */ -export function resolveCliOpenPath( +export function resolveOpenPath( content: readonly ContentEntry[] | undefined ): string { - if (content && matchExplorerPreset(content)) return EXPLORER_ENDPOINT; + if (content && matchExplorerPreset(content) && explorerAssetsPresent()) { + return EXPLORER_ENDPOINT; + } return GRAPHQL_ENDPOINT; } + +/** Alias of `resolveOpenPath` for existing call sites. */ +export const resolveCliOpenPath = resolveOpenPath; diff --git a/packages/flatbread/src/graphql/explorerMount.test.ts b/packages/flatbread/src/graphql/explorerMount.test.ts index 76573cae..32759118 100644 --- a/packages/flatbread/src/graphql/explorerMount.test.ts +++ b/packages/flatbread/src/graphql/explorerMount.test.ts @@ -1,66 +1,149 @@ -import test from 'ava'; +import test, { type ExecutionContext } from 'ava'; import express from 'express'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; import { join } from 'node:path'; import { effortGraphContent } from '@flatbread/effort-graph'; import { EXPLORER_BOOTSTRAP_PATH, + explorerAssetsPresent, getExplorerStaticDir, + setExplorerStaticDirOverride, } from '@flatbread/explorer'; -import { mountExplorerIfMatched, resolveOpenPath } from './explorerMount.js'; +import { mountExplorerIfMatched } from './explorerMount.js'; -test('resolveOpenPath prefers explorer for Effort Graph configs', (t) => { - t.is(resolveOpenPath(effortGraphContent()), '/'); - t.is(resolveOpenPath([{ collection: 'Post', path: 'posts' }]), '/graphql'); -}); +async function listen(app: express.Express) { + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('expected TCP address'); + } + return { + base: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }), + }; +} + +function requireExplorerAssets(t: ExecutionContext): boolean { + setExplorerStaticDirOverride(undefined); + if (!explorerAssetsPresent()) { + t.fail( + `Explorer assets missing at ${join( + getExplorerStaticDir(), + 'index.html' + )}. Build @flatbread/explorer first.` + ); + return false; + } + return true; +} test.serial( - 'mounts SPA at / and leaves /graphql for Apollo when assets exist', + 'mounts SPA at / and leaves /graphql and /events for downstream handlers', async (t) => { - const staticDir = getExplorerStaticDir(); - const indexPath = join(staticDir, 'index.html'); - // Build must have produced assets; skip soft-fail would hide regressions. - const { access } = await import('node:fs/promises'); - try { - await access(indexPath); - } catch { - t.fail( - `Explorer assets missing at ${indexPath}. Build @flatbread/explorer first.` - ); - return; - } + if (!requireExplorerAssets(t)) return; const app = express(); const mounted = mountExplorerIfMatched(app, effortGraphContent()); t.truthy(mounted); t.is(mounted!.openPath, '/'); - // Capture handlers by issuing a fake request through the stack. - const server = app.listen(0); - t.teardown( - () => - new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - }) - ); - await new Promise((resolve) => server.once('listening', resolve)); - const address = server.address(); - if (!address || typeof address === 'string') { - t.fail('expected TCP address'); - return; - } - const base = `http://127.0.0.1:${address.port}`; - - const home = await fetch(`${base}/`); + app.get('/graphql', (_req, res) => { + res.json({ route: 'graphql' }); + }); + app.get('/events', (_req, res) => { + res.type('text/event-stream').send('event: test\ndata: {}\n\n'); + }); + + const server = await listen(app); + t.teardown(server.close); + + const home = await fetch(`${server.base}/`); t.is(home.status, 200); const html = await home.text(); t.true(html.includes('__FLATBREAD_EXPLORER__')); t.true(html.includes('effort-graph')); - const boot = await fetch(`${base}${EXPLORER_BOOTSTRAP_PATH}`); + const boot = await fetch(`${server.base}${EXPLORER_BOOTSTRAP_PATH}`); t.is(boot.status, 200); const json = (await boot.json()) as { preset: string; graphqlPath: string }; t.is(json.preset, 'effort-graph'); t.is(json.graphqlPath, '/graphql'); + + const graphql = await fetch(`${server.base}/graphql`); + t.is(graphql.status, 200); + const graphqlBody = await graphql.text(); + t.false(graphqlBody.includes('__FLATBREAD_EXPLORER__')); + t.true(graphqlBody.includes('"route":"graphql"')); + + const events = await fetch(`${server.base}/events`); + t.is(events.status, 200); + const eventsBody = await events.text(); + t.false(eventsBody.includes('__FLATBREAD_EXPLORER__')); + t.true(eventsBody.includes('event: test')); + } +); + +test.serial( + 'serves injected HTML for extensionless SPA client routes', + async (t) => { + if (!requireExplorerAssets(t)) return; + + const app = express(); + const mounted = mountExplorerIfMatched(app, effortGraphContent()); + t.truthy(mounted); + + const server = await listen(app); + t.teardown(server.close); + + const clientRoute = await fetch(`${server.base}/effort-graph/view`); + t.is(clientRoute.status, 200); + const html = await clientRoute.text(); + t.true(html.includes('__FLATBREAD_EXPLORER__')); + t.true(html.includes('effort-graph')); + } +); + +test.serial( + 'warns and returns null without SPA routes when assets are missing', + async (t) => { + const emptyDir = await mkdtemp( + join(os.tmpdir(), 'flatbread-explorer-mount-') + ); + setExplorerStaticDirOverride(emptyDir); + t.teardown(async () => { + setExplorerStaticDirOverride(undefined); + await rm(emptyDir, { recursive: true, force: true }); + }); + + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(' ')); + }; + t.teardown(() => { + console.warn = originalWarn; + }); + + const app = express(); + const mounted = mountExplorerIfMatched(app, effortGraphContent()); + t.is(mounted, null); + t.true( + warnings.some((message) => + message.includes('Flatbread explorer assets missing') + ) + ); + + const server = await listen(app); + t.teardown(server.close); + + const home = await fetch(`${server.base}/`); + t.is(home.status, 404); } ); @@ -71,20 +154,9 @@ test.serial('does not mount explorer for ordinary content', async (t) => { ]); t.is(mounted, null); - const server = app.listen(0); - t.teardown( - () => - new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - }) - ); - await new Promise((resolve) => server.once('listening', resolve)); - const address = server.address(); - if (!address || typeof address === 'string') { - t.fail('expected TCP address'); - return; - } - const res = await fetch(`http://127.0.0.1:${address.port}/`); - // No route registered → Express default 404 + const server = await listen(app); + t.teardown(server.close); + + const res = await fetch(`${server.base}/`); t.is(res.status, 404); }); diff --git a/packages/flatbread/src/graphql/explorerMount.ts b/packages/flatbread/src/graphql/explorerMount.ts index 2f68a1ac..ae78e19a 100644 --- a/packages/flatbread/src/graphql/explorerMount.ts +++ b/packages/flatbread/src/graphql/explorerMount.ts @@ -1,5 +1,6 @@ import { EXPLORER_BOOTSTRAP_PATH, + explorerAssetsPresent, getExplorerStaticDir, matchExplorerPreset, type ExplorerPresetMatch, @@ -10,6 +11,7 @@ import express, { type Request, type Response, type NextFunction, + type RequestHandler, } from 'express'; import fs from 'node:fs'; import path from 'node:path'; @@ -23,64 +25,112 @@ export interface ExplorerMountResult { openPath: '/'; } +export interface ExplorerMountHandle { + /** Whether SPA middleware currently serves `/` (preset match ∧ assets). */ + isActive(): boolean; + /** + * Re-evaluate preset + assets against new content. + * Does not touch the Express stack; only the gate + cached injected HTML. + * Warns (once per transition into missing-assets) when preset matches but + * assets are absent. + */ + update(content: readonly ContentEntry[]): void; +} + +interface ExplorerBootstrap { + preset: ExplorerPresetMatch['preset']; + graphqlPath: string; + eventsPath: string; +} + /** - * When a registered explorer preset matches, serve the SPA at `/`. - * Callers should mount Apollo afterward; this middleware `next()`s for - * `/graphql` and `/events` so those API routes still work. Returns null when - * no preset matches. + * Registers bootstrap, static, `/`/`index.html`, and SPA fallback once. + * Inactive gate → all of those `next()` so Apollo/SSE own the paths. + * Toggle activity with {@link ExplorerMountHandle.update} on config reload. */ -export function mountExplorerIfMatched( +export function mountExplorer( app: Express, content: readonly ContentEntry[] -): ExplorerMountResult | null { - const match = matchExplorerPreset(content); - if (!match) return null; - - const staticDir = getExplorerStaticDir(); - const indexHtmlPath = path.join(staticDir, 'index.html'); - if (!fs.existsSync(indexHtmlPath)) { - console.warn( - `Flatbread explorer assets missing at ${staticDir}. Run \`pnpm --filter @flatbread/explorer build\`.` - ); - return null; - } - - const bootstrap = { - preset: match.preset, +): ExplorerMountHandle { + let active = false; + let indexHtml = ''; + let bootstrap: ExplorerBootstrap = { + preset: 'effort-graph', graphqlPath: GRAPHQL_PATH, eventsPath: EVENTS_PATH, }; + let staticMiddleware: RequestHandler | null = null; + /** True while the last evaluation was preset-match + missing assets. */ + let inMissingAssets = false; - app.get(EXPLORER_BOOTSTRAP_PATH, (_req, res) => { - res.json(bootstrap); - }); + const evaluate = (nextContent: readonly ContentEntry[]) => { + const match = matchExplorerPreset(nextContent); + if (!match) { + active = false; + inMissingAssets = false; + staticMiddleware = null; + return; + } + + const staticDir = getExplorerStaticDir(); + if (!explorerAssetsPresent()) { + if (!inMissingAssets) { + console.warn( + `Flatbread explorer assets missing at ${staticDir}. Run \`pnpm --filter @flatbread/explorer build\`.` + ); + inMissingAssets = true; + } + active = false; + staticMiddleware = null; + return; + } + + inMissingAssets = false; + bootstrap = { + preset: match.preset, + graphqlPath: GRAPHQL_PATH, + eventsPath: EVENTS_PATH, + }; - // Inject bootstrap into index.html so the SPA knows same-origin endpoints - // without an extra round-trip before first paint. - let indexHtml = fs.readFileSync(indexHtmlPath, 'utf8'); - const bootScript = ``; - if (indexHtml.includes('')) { - indexHtml = indexHtml.replace('', `${bootScript}`); - } else { - indexHtml = `${bootScript}${indexHtml}`; - } - - app.use( - express.static(staticDir, { + const indexHtmlPath = path.join(staticDir, 'index.html'); + let html = fs.readFileSync(indexHtmlPath, 'utf8'); + const bootScript = ``; + if (html.includes('')) { + html = html.replace('', `${bootScript}`); + } else { + html = `${bootScript}${html}`; + } + indexHtml = html; + staticMiddleware = express.static(staticDir, { index: false, fallthrough: true, - }) - ); + }); + active = true; + }; - app.get(['/', '/index.html'], (_req, res) => { + evaluate(content); + + app.get(EXPLORER_BOOTSTRAP_PATH, (_req, res, next) => { + if (!active) return next(); + res.json(bootstrap); + }); + + app.use((req, res, next) => { + if (!active || !staticMiddleware) return next(); + return staticMiddleware(req, res, next); + }); + + app.get(['/', '/index.html'], (_req, res, next) => { + if (!active) return next(); res.type('html').send(indexHtml); }); // SPA fallback for client routes — never steal API paths. `/events` and // `/graphql` are registered after this mount and must receive `next()`. app.use((req: Request, res: Response, next: NextFunction) => { + if (!active) return next(); if (req.method !== 'GET' && req.method !== 'HEAD') return next(); const pathname = req.path; if ( @@ -95,13 +145,25 @@ export function mountExplorerIfMatched( res.type('html').send(indexHtml); }); - return { match, openPath: '/' }; + return { + isActive: () => active, + update: evaluate, + }; } -/** Open path for `--open`: explorer root when mounted, else Apollo sandbox. */ -export function resolveOpenPath( - content: readonly ContentEntry[] | undefined -): string { - if (content && matchExplorerPreset(content)) return '/'; - return GRAPHQL_PATH; +/** + * When a registered explorer preset matches and static assets are present, + * serve the SPA at `/`. Prefer {@link mountExplorer} when the mount must + * react to config reload (mutable gate). Returns null when inactive after the + * initial evaluation (no preset, or assets missing — warns; does not throw). + */ +export function mountExplorerIfMatched( + app: Express, + content: readonly ContentEntry[] +): ExplorerMountResult | null { + const handle = mountExplorer(app, content); + if (!handle.isActive()) return null; + const match = matchExplorerPreset(content); + if (!match) return null; + return { match, openPath: '/' }; } diff --git a/packages/flatbread/src/graphql/liveServer.ts b/packages/flatbread/src/graphql/liveServer.ts index 6370c213..1b4e3c46 100644 --- a/packages/flatbread/src/graphql/liveServer.ts +++ b/packages/flatbread/src/graphql/liveServer.ts @@ -19,7 +19,7 @@ import express, { type RequestHandler } from 'express'; import http from 'http'; import { loadFlatbreadConfig } from '../utils/getSchema'; import { createEffortGraphComposition } from './effortGraphComposition'; -import { mountExplorerIfMatched } from './explorerMount'; +import { mountExplorer } from './explorerMount'; export interface GraphqlServerOptions { port?: number; @@ -31,7 +31,11 @@ export interface RunningGraphqlServer { readonly port: number; readonly reloader: LiveSchemaReloader; readonly effortGraph?: EffortGraphLiveBridge; - /** True when the content-relation explorer SPA is mounted at `/`. */ + /** + * Whether the explorer SPA currently answers `/`. + * May change under `--watch` when config reload adds or removes a matching + * explorer preset (same mutable-gate pattern as Apollo's generation swap). + */ readonly explorer: boolean; close(): Promise; } @@ -111,9 +115,19 @@ export async function startGraphqlServer( if (old) old.stopWhenDrained(); }, }); - // Explorer SPA first so `/` is the visualizer when a preset matches. It must - // `next()` for `/events` and `/graphql` (see explorerMount). - const explorerMount = mountExplorerIfMatched(app, config.content); + // Explorer SPA first so `/` is the visualizer when a preset matches. Middleware + // is registered once; the gate toggles when replaceConfig commits (watch + // applyConfig and any direct reload). Inactive → `next()` for `/events` / + // `/graphql`. + const explorerHandle = mountExplorer(app, config.content); + const replaceConfig = reloader.replaceConfig.bind(reloader); + reloader.replaceConfig = async (nextConfig) => { + const result = await replaceConfig(nextConfig); + if (result.status === 'committed') { + explorerHandle.update(nextConfig.content); + } + return result; + }; app.get('/events', (req, res) => { res.status(200).set({ @@ -237,21 +251,43 @@ export async function startGraphqlServer( cwd, (error, events) => { if (error) { + // Concurrent AVA fixtures under cwd can race inotify; never let + // watcher noise reject the owning test/server promise. console.error('Flatbread watcher error:', error); return; } - coordinator!.push( - events.map((event) => ({ path: event.path, type: event.type })) - ); + try { + coordinator!.push( + events.map((event) => ({ + path: event.path, + type: event.type, + })) + ); + } catch (pushError) { + console.error('Flatbread watcher push failed:', pushError); + } }, - { ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'] } + { + ignore: [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + // Ephemeral test fixtures and effort-graph journals under cwd. + // Keep `.tmp-live-server-test-*` visible so watch-mode AVA can + // exercise real filesystem edits. + '**/.tmp-effort-*/**', + '**/.tmp-explorer-*/**', + '**/.journal/**', + ], + } ); break; } catch (error: unknown) { const isEnoent = error instanceof Error && ((error as NodeJS.ErrnoException).code === 'ENOENT' || - error.message.includes('No such file or directory')); + error.message.includes('No such file or directory') || + error.message.includes('inotify_add_watch')); if (!isEnoent || attempt === 2) throw error; await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)) @@ -263,12 +299,21 @@ export async function startGraphqlServer( port, reloader, effortGraph, - explorer: explorerMount !== null, + get explorer() { + return explorerHandle.isActive(); + }, async close() { if (closed) return; closed = true; await coordinator?.dispose(); - await subscription?.unsubscribe(); + // @parcel/watcher can throw EINVAL ("Unable to remove watcher") on Node + // 22 when the native handle is already gone during teardown. + try { + await subscription?.unsubscribe(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('Unable to remove watcher')) throw error; + } await current?.stop(); await new Promise((closeResolve) => { if (httpServer.listening) httpServer.close(() => closeResolve()); diff --git a/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts b/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts index fff0a970..9b8f3d92 100644 --- a/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts +++ b/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts @@ -1,11 +1,16 @@ import test from 'ava'; import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'; +import os from 'node:os'; import { join, relative } from 'node:path'; import filesystem from '@flatbread/source-filesystem'; import markdownTransformer from '@flatbread/transformer-markdown'; import { initializeConfig } from '@flatbread/core'; import { effortGraphContent } from '@flatbread/effort-graph'; +import { + explorerAssetsPresent, + setExplorerStaticDirOverride, +} from '@flatbread/explorer'; import type { ConfigResult, LoadedFlatbreadConfig } from '@flatbread/core'; import type { EffortGraphMutation } from '@flatbread/effort-graph'; import { startGraphqlServer } from './liveServer.js'; @@ -55,9 +60,121 @@ async function query(port: number, source: string) { }; } +/** Same SSE reader pattern as liveServerEvents.test.ts. */ +interface SseEvent { + event: string; + data: string; +} +function createSseReader( + body: ReadableStream, + timeoutMs = 5_000 +): { + next(predicate: (event: SseEvent) => boolean): Promise; + close(): Promise; +} { + const reader = body.getReader(); + const decoder = new TextDecoder(); + const events: SseEvent[] = []; + const waiters: Array<{ + predicate: (event: SseEvent) => boolean; + resolve: (event: SseEvent) => void; + reject: (reason: Error) => void; + timer: NodeJS.Timeout; + }> = []; + let buffer = ''; + let finished = false; + + const dispatch = (event: SseEvent) => { + for (let i = 0; i < waiters.length; i++) { + if (waiters[i].predicate(event)) { + const waiter = waiters.splice(i, 1)[0]; + clearTimeout(waiter.timer); + waiter.resolve(event); + return; + } + } + events.push(event); + }; + + const flushBuffer = () => { + let index: number; + while ((index = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, index); + buffer = buffer.slice(index + 2); + if (!block.length || block.startsWith(':')) continue; + const parsed: SseEvent = { event: 'message', data: '' }; + const dataLines: string[] = []; + for (const line of block.split('\n')) { + if (line.startsWith('event:')) parsed.event = line.slice(6).trim(); + else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); + } + parsed.data = dataLines.join('\n'); + dispatch(parsed); + } + }; + + const pump = async () => { + try { + while (true) { + const { value, done } = await reader.read(); + if (done) return; + buffer += decoder.decode(value, { stream: true }); + flushBuffer(); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + for (const waiter of waiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.reject(err); + } + } finally { + finished = true; + for (const waiter of waiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.reject(new Error('SSE stream closed before match')); + } + } + }; + void pump(); + + return { + next(predicate) { + const buffered = events.findIndex(predicate); + if (buffered !== -1) + return Promise.resolve(events.splice(buffered, 1)[0]); + if (finished) + return Promise.reject(new Error('SSE stream already closed')); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const index = waiters.findIndex((w) => w.timer === timer); + if (index !== -1) waiters.splice(index, 1); + reject(new Error('Timed out waiting for SSE event')); + }, timeoutMs); + if (typeof timer.unref === 'function') timer.unref(); + waiters.push({ predicate, resolve, reject, timer }); + }); + }, + async close() { + try { + await reader.cancel(); + } catch { + // Ignore: server may have already ended the response. + } + }, + }; +} + test.serial( 'active preset exposes the bridge and a mutation is strictly readable end-to-end', async (t) => { + setExplorerStaticDirOverride(undefined); + if (!explorerAssetsPresent()) { + t.fail( + 'Explorer assets missing. Build @flatbread/explorer first (`pnpm --filter @flatbread/explorer build`).' + ); + return; + } + const fixture = await makeDir(); t.teardown(() => rm(fixture.dir, { recursive: true, force: true })); const server = await startGraphqlServer({ @@ -82,6 +199,83 @@ test.serial( } ); +test.serial( + 'explorer mount leaves /events SSE working when assets exist', + async (t) => { + setExplorerStaticDirOverride(undefined); + if (!explorerAssetsPresent()) { + t.fail( + 'Explorer assets missing. Build @flatbread/explorer first (`pnpm --filter @flatbread/explorer build`).' + ); + return; + } + + const fixture = await makeDir(); + t.teardown(() => rm(fixture.dir, { recursive: true, force: true })); + const server = await startGraphqlServer({ + config: config(fixture.relativeRoot, true), + port: 0, + }); + t.teardown(() => server.close()); + + t.true(server.explorer); + + const response = await fetch(`http://localhost:${server.port}/events`, { + headers: { accept: 'text/event-stream' }, + }); + t.is(response.status, 200); + t.regex(response.headers.get('content-type') ?? '', /text\/event-stream/i); + + const stream = createSseReader(response.body!); + const ready = await stream.next((event) => event.event === 'ready'); + t.deepEqual(JSON.parse(ready.data), { + generation: server.reloader.generation, + }); + await stream.close(); + } +); + +test.serial( + 'missing explorer assets soft-fails with explorer false but GraphQL still works', + async (t) => { + const emptyDir = await mkdtemp( + join(os.tmpdir(), 'flatbread-explorer-live-') + ); + setExplorerStaticDirOverride(emptyDir); + t.teardown(async () => { + setExplorerStaticDirOverride(undefined); + await rm(emptyDir, { recursive: true, force: true }); + }); + + const fixture = await makeDir(); + t.teardown(() => rm(fixture.dir, { recursive: true, force: true })); + const server = await startGraphqlServer({ + config: config(fixture.relativeRoot, true), + port: 0, + }); + t.teardown(() => server.close()); + + t.false(server.explorer); + t.truthy(server.effortGraph); + + const home = await fetch(`http://localhost:${server.port}/`); + t.not(home.headers.get('content-type') ?? '', 'text/html'); + t.false((await home.text()).includes('__FLATBREAD_EXPLORER__')); + + const created = await server.effortGraph!.writer.mutate({ + type: 'CreateEffort', + title: 'GraphQL without explorer assets', + body: '', + }); + await server.effortGraph!.waitForCommittedGeneration(created.generation); + const response = await query(server.port, '{ allEfforts { title } }'); + t.deepEqual(response.errors, undefined); + t.deepEqual(response.data?.allEfforts, [ + { title: 'GraphQL without explorer assets' }, + ]); + } +); + test.serial( 'inactive config exposes no effortGraph and behaves as before', async (t) => { diff --git a/packages/flatbread/src/graphql/liveServerExplorerWatch.test.ts b/packages/flatbread/src/graphql/liveServerExplorerWatch.test.ts new file mode 100644 index 00000000..977f9a1f --- /dev/null +++ b/packages/flatbread/src/graphql/liveServerExplorerWatch.test.ts @@ -0,0 +1,196 @@ +import test from 'ava'; +import express from 'express'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import filesystem from '@flatbread/source-filesystem'; +import markdownTransformer from '@flatbread/transformer-markdown'; +import { initializeConfig } from '@flatbread/core'; +import { effortGraphContent } from '@flatbread/effort-graph'; +import { + explorerAssetsPresent, + setExplorerStaticDirOverride, +} from '@flatbread/explorer'; +import type { ConfigResult, LoadedFlatbreadConfig } from '@flatbread/core'; +import { mountExplorer } from './explorerMount.js'; +import { startGraphqlServer } from './liveServer.js'; + +async function makeDir() { + const dir = await mkdtemp(join(process.cwd(), '.tmp-explorer-watch-')); + const root = join(dir, 'graph'); + for (const path of [ + 'efforts', + 'issues', + 'findings', + 'decisions', + 'constraints', + 'risks', + 'citations', + 'blobs', + ]) + await mkdir(join(root, path), { recursive: true }); + await mkdir(join(root, 'plain'), { recursive: true }); + return { dir, root, relativeRoot: relative(process.cwd(), root) }; +} + +function config( + root: string, + active: boolean +): ConfigResult { + return { + config: initializeConfig({ + source: filesystem(), + transformer: markdownTransformer(), + content: active + ? effortGraphContent(root) + : [{ collection: 'Plain', path: `${root}/plain` }], + }), + }; +} + +async function listen(app: express.Express) { + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('expected TCP address'); + } + return { + base: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }), + }; +} + +async function query(port: number, source: string) { + const response = await fetch(`http://localhost:${port}/graphql`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: source }), + }); + return (await response.json()) as { + data?: Record; + errors?: Array<{ message: string }>; + }; +} + +function requireExplorerAssets(): void { + setExplorerStaticDirOverride(undefined); + if (!explorerAssetsPresent()) { + throw new Error( + 'Explorer assets missing. Build @flatbread/explorer first (`pnpm --filter @flatbread/explorer build`).' + ); + } +} + +test.serial( + 'mountExplorer gate toggles SPA off and on without remounting Express', + async (t) => { + requireExplorerAssets(); + + const app = express(); + const handle = mountExplorer(app, effortGraphContent('.flatbread-efforts')); + t.true(handle.isActive()); + + app.post('/graphql', (_req, res) => { + res.json({ data: { ok: true } }); + }); + + const server = await listen(app); + t.teardown(server.close); + + const homeActive = await fetch(`${server.base}/`); + t.is(homeActive.status, 200); + t.true((await homeActive.text()).includes('__FLATBREAD_EXPLORER__')); + + handle.update([{ collection: 'Post', path: 'posts' }]); + t.false(handle.isActive()); + + const homeInactive = await fetch(`${server.base}/`); + t.is(homeInactive.status, 404); + t.false((await homeInactive.text()).includes('__FLATBREAD_EXPLORER__')); + + const graphql = await fetch(`${server.base}/graphql`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: '{ __typename }' }), + }); + t.is(graphql.status, 200); + t.true((await graphql.text()).includes('"ok":true')); + + handle.update(effortGraphContent('.flatbread-efforts')); + t.true(handle.isActive()); + + const homeReenabled = await fetch(`${server.base}/`); + t.is(homeReenabled.status, 200); + t.true((await homeReenabled.text()).includes('__FLATBREAD_EXPLORER__')); + } +); + +test.serial( + 'config reload via replaceConfig clears sticky explorer when preset is removed', + async (t) => { + requireExplorerAssets(); + + const fixture = await makeDir(); + t.teardown(() => rm(fixture.dir, { recursive: true, force: true })); + + const server = await startGraphqlServer({ + config: config(fixture.relativeRoot, true), + port: 0, + }); + t.teardown(() => server.close()); + + t.true(server.explorer); + const homeBefore = await fetch(`http://localhost:${server.port}/`); + t.is(homeBefore.status, 200); + t.true((await homeBefore.text()).includes('__FLATBREAD_EXPLORER__')); + + const inactive = config(fixture.relativeRoot, false).config!; + const result = await server.reloader.replaceConfig(inactive); + t.is(result.status, 'committed'); + t.false(server.explorer); + + const homeAfter = await fetch(`http://localhost:${server.port}/`); + t.false((await homeAfter.text()).includes('__FLATBREAD_EXPLORER__')); + + const gql = await query(server.port, '{ __typename }'); + t.deepEqual(gql.errors, undefined); + t.is(gql.data?.__typename, 'Query'); + } +); + +test.serial( + 'config reload via replaceConfig enables explorer when preset is added', + async (t) => { + requireExplorerAssets(); + + const fixture = await makeDir(); + t.teardown(() => rm(fixture.dir, { recursive: true, force: true })); + + const server = await startGraphqlServer({ + config: config(fixture.relativeRoot, false), + port: 0, + }); + t.teardown(() => server.close()); + + t.false(server.explorer); + const homeBefore = await fetch(`http://localhost:${server.port}/`); + t.false((await homeBefore.text()).includes('__FLATBREAD_EXPLORER__')); + + const active = config(fixture.relativeRoot, true).config!; + const result = await server.reloader.replaceConfig(active); + t.is(result.status, 'committed'); + t.true(server.explorer); + + const homeAfter = await fetch(`http://localhost:${server.port}/`); + t.is(homeAfter.status, 200); + t.true((await homeAfter.text()).includes('__FLATBREAD_EXPLORER__')); + + const gql = await query(server.port, '{ __typename }'); + t.deepEqual(gql.errors, undefined); + t.is(gql.data?.__typename, 'Query'); + } +);