docs: complete LAN presentation sync
This commit is contained in:
+42
-4
@@ -38,6 +38,35 @@ pnpm --dir web start
|
||||
A single Hono process serves the built React application and API routes from
|
||||
`http://127.0.0.1:8787`.
|
||||
|
||||
### LAN Presentation Rehearsal
|
||||
|
||||
Build once, then bind the production Hono server to the laptop's LAN
|
||||
interfaces:
|
||||
|
||||
```powershell
|
||||
pnpm --dir web build
|
||||
$env:WEB_HOST = "0.0.0.0"
|
||||
pnpm --dir web start
|
||||
```
|
||||
|
||||
Open `http://<laptop-lan-ip>:8787/presenter` on the phone and
|
||||
`http://<laptop-lan-ip>:8787/present` on the audience display. Either route can
|
||||
**Start session** or **Join session**. Starting shows a six-character code, QR
|
||||
target, and opposite-route join link; the other device can scan the QR, open
|
||||
the link, or enter the code. Navigation is bidirectional, and the presenter
|
||||
route can end the session for every paired device.
|
||||
|
||||
Rooms are short-lived and in memory. After a temporary disconnect or reload,
|
||||
the client reconnects and applies the latest server snapshot; that snapshot
|
||||
wins over navigation performed locally while offline. A server restart or room
|
||||
expiry requires a new session.
|
||||
|
||||
This mode is for a **trusted LAN only**. The join code is not authentication,
|
||||
the built server does not provide TLS, and this runbook makes no
|
||||
public-internet security claim. Keep `wf-rpc-server` on loopback at port `8765`:
|
||||
browser workflow operations continue through the Hono boundary on port `8787`,
|
||||
so the workflow RPC server does not need a LAN binding.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
@@ -48,6 +77,12 @@ A single Hono process serves the built React application and API routes from
|
||||
| `pnpm --dir web typecheck` | Run TypeScript type checking |
|
||||
| `pnpm --dir web build` | Build the React console for production |
|
||||
| `pnpm --dir web start` | Start the production Hono server |
|
||||
| `pnpm --dir web --filter @lda/console test:presentation-sync:e2e:install` | Install the Chromium binary required by the browser smoke test |
|
||||
| `pnpm --dir web --filter @lda/console test:presentation-sync:e2e` | Run isolated two-context presentation synchronization smoke tests against a built server |
|
||||
|
||||
Run the E2E install command once after `pnpm install` on each clean development
|
||||
or CI machine. Playwright keeps the matching Chromium binary in its browser
|
||||
cache for subsequent smoke runs.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -58,6 +93,7 @@ web/
|
||||
server/ Hono local server (API + static serving)
|
||||
packages/
|
||||
rpc/ Effect-based JSON-RPC client, schemas, and errors
|
||||
presentation-sync/ Bounded presentation room wire contract
|
||||
```
|
||||
|
||||
The browser communicates with Hono at `/api/connect` and `/api/rpc`. Hono
|
||||
@@ -174,12 +210,14 @@ The console exposes `/present`, a 720p no-scroll defense compositor for the
|
||||
stage regions, discussion branches, one editorial canvas, persistent scene-aware
|
||||
assistant surfaces, and keyboard navigation.
|
||||
|
||||
The companion `/presenter` route is a read-only speech and Q&A reader. It uses
|
||||
The companion `/presenter` route is a speech and Q&A reader. It uses
|
||||
the same `#scene/<scene>/<beat>` and `#discuss/<branch>` hashes, shows target and
|
||||
cumulative timing, keeps optional detail/evidence/Q&A collapsed, and links to
|
||||
the corresponding audience slide in a new tab. It performs no workflow RPC,
|
||||
replay, live-target, or cross-window synchronization. Use ArrowLeft and
|
||||
ArrowRight to move between notes; covered checkboxes remain local to the page.
|
||||
replay, or live-target operations. Its shared pairing controller synchronizes
|
||||
canonical navigation hashes with `/present` through Hono without duplicating
|
||||
storyboard semantics. Use ArrowLeft and ArrowRight to move between notes;
|
||||
covered checkboxes remain local to the page.
|
||||
Must-say notes support authored inline Markdown emphasis for rapid scanning, and
|
||||
the stable Previous/Next bar remains available at narrow viewport widths.
|
||||
|
||||
@@ -331,7 +369,7 @@ discussion transitions.
|
||||
This plan deliberately defers:
|
||||
- AI Elements / Vercel AI chat primitives
|
||||
- Live LLM driver integration
|
||||
- Remote phone control
|
||||
- Public-internet presentation hosting, TLS, and authentication
|
||||
- Final visual polish and motion choreography
|
||||
|
||||
### Constrained Demo Agent
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { createServer } from "node:net";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test";
|
||||
|
||||
const INITIAL_HASH = "#scene/thesis/title";
|
||||
const ARCHITECTURE_FOCUS_HASH =
|
||||
"#scene/architecture/runtime/focus/runtime-providers/configured-providers";
|
||||
const DISCUSSION_HASH = "#discuss/where-is-ai-agent";
|
||||
const serverEntry = fileURLToPath(new URL("../../server/dist/index.js", import.meta.url));
|
||||
|
||||
let server: ChildProcessWithoutNullStreams;
|
||||
let baseUrl: string;
|
||||
|
||||
const reservePort = async (): Promise<number> => {
|
||||
const listener = createServer();
|
||||
listener.listen(0, "127.0.0.1");
|
||||
await once(listener, "listening");
|
||||
const address = listener.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
listener.close();
|
||||
throw new Error("Could not reserve an E2E server port");
|
||||
}
|
||||
const { port } = address;
|
||||
listener.close();
|
||||
await once(listener, "close");
|
||||
return port;
|
||||
};
|
||||
|
||||
const waitForServer = async (child: ChildProcessWithoutNullStreams): Promise<void> => {
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`E2E server exited with ${child.exitCode}:\n${output}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(baseUrl);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// The isolated process has not bound its port yet.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`E2E server did not become ready:\n${output}`);
|
||||
};
|
||||
|
||||
const stopServer = async (): Promise<void> => {
|
||||
if (server.exitCode !== null) return;
|
||||
const exited = once(server, "exit");
|
||||
server.kill("SIGTERM");
|
||||
const exitedGracefully = await Promise.race([
|
||||
exited.then(() => true),
|
||||
new Promise<false>((resolve) => setTimeout(() => resolve(false), 7_000)),
|
||||
]);
|
||||
if (exitedGracefully || server.exitCode !== null) return;
|
||||
|
||||
// Never reach beyond the child created by this test when graceful server
|
||||
// shutdown fails; force only that recorded process and wait for its exit.
|
||||
const forcedExit = once(server, "exit");
|
||||
server.kill("SIGKILL");
|
||||
await forcedExit;
|
||||
};
|
||||
|
||||
const hashOf = (page: Page): string => new URL(page.url()).hash;
|
||||
|
||||
const expectHashes = async (expected: string, ...pages: readonly Page[]): Promise<void> => {
|
||||
await expect.poll(() => pages.map(hashOf)).toEqual(pages.map(() => expected));
|
||||
};
|
||||
|
||||
const startSession = async (
|
||||
page: Page,
|
||||
creatorPath: "/present" | "/presenter",
|
||||
): Promise<string> => {
|
||||
await page.getByRole("button", { name: "Pair presentation" }).click();
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
const code = page.locator(".presentation-pairing__code");
|
||||
await expect(code).toBeVisible();
|
||||
const value = (await code.textContent())?.trim() ?? "";
|
||||
const joinPath = creatorPath === "/presenter" ? "/present" : "/presenter";
|
||||
const joinUrl = `${baseUrl}${joinPath}?pair=${value}`;
|
||||
await expect(page.getByRole("img", { name: "Pairing QR code" })).toHaveAttribute(
|
||||
"data-qr-value",
|
||||
joinUrl,
|
||||
);
|
||||
await expect(page.getByRole("link", { name: "Copyable join URL" })).toHaveAttribute(
|
||||
"href",
|
||||
joinUrl,
|
||||
);
|
||||
return value;
|
||||
};
|
||||
|
||||
const expectConnected = async (...pages: readonly Page[]): Promise<void> => {
|
||||
await Promise.all(
|
||||
pages.map((page) =>
|
||||
expect(page.getByRole("status", { name: "Connected" })).toBeVisible(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const openPair = async (
|
||||
browser: Browser,
|
||||
creatorPath: "/present" | "/presenter",
|
||||
joinMethod: "link" | "code" = "link",
|
||||
): Promise<{
|
||||
creatorContext: BrowserContext;
|
||||
creator: Page;
|
||||
joinerContext: BrowserContext;
|
||||
joiner: Page;
|
||||
}> => {
|
||||
const creatorIsPresenter = creatorPath === "/presenter";
|
||||
const creatorContext = await browser.newContext({
|
||||
viewport: creatorIsPresenter ? { width: 390, height: 844 } : { width: 1280, height: 720 },
|
||||
});
|
||||
const joinerContext = await browser.newContext({
|
||||
viewport: creatorIsPresenter ? { width: 1280, height: 720 } : { width: 390, height: 844 },
|
||||
});
|
||||
const creator = await creatorContext.newPage();
|
||||
await creator.goto(`${baseUrl}${creatorPath}${INITIAL_HASH}`);
|
||||
const code = await startSession(creator, creatorPath);
|
||||
expect(code).toMatch(/^[A-Z0-9]{6}$/);
|
||||
|
||||
const joinerPath = creatorIsPresenter ? "/present" : "/presenter";
|
||||
const joiner = await joinerContext.newPage();
|
||||
if (joinMethod === "link") {
|
||||
await joiner.goto(`${baseUrl}${joinerPath}?pair=${code}${INITIAL_HASH}`);
|
||||
} else {
|
||||
await joiner.goto(`${baseUrl}${joinerPath}${INITIAL_HASH}`);
|
||||
await joiner.getByRole("button", { name: "Pair presentation" }).click();
|
||||
await joiner.getByLabel("Pairing code").fill(code);
|
||||
await joiner.getByRole("button", { name: "Join session" }).click();
|
||||
}
|
||||
await expectConnected(creator, joiner);
|
||||
await expectHashes(INITIAL_HASH, creator, joiner);
|
||||
return { creatorContext, creator, joinerContext, joiner };
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const port = await reservePort();
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
server = spawn(process.execPath, [serverEntry], {
|
||||
env: {
|
||||
...process.env,
|
||||
WEB_HOST: process.env.PRESENTATION_E2E_BIND_HOST ?? "127.0.0.1",
|
||||
WEB_PORT: String(port),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
await waitForServer(server);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stopServer();
|
||||
});
|
||||
|
||||
test("synchronizes presenter and audience navigation, reload, fidelity, and termination", async ({ browser }) => {
|
||||
const { creatorContext: phoneContext, creator: phone, joinerContext: audienceContext, joiner: audience } =
|
||||
await openPair(browser, "/presenter");
|
||||
|
||||
try {
|
||||
await phone.getByRole("link", { name: "Next →", exact: true }).click();
|
||||
await expectHashes("#scene/thesis/substrate", phone, audience);
|
||||
|
||||
await audience.keyboard.press("ArrowLeft");
|
||||
await expectHashes(INITIAL_HASH, phone, audience);
|
||||
|
||||
await audience.evaluate((hash) => {
|
||||
window.location.hash = hash;
|
||||
}, DISCUSSION_HASH);
|
||||
await expectHashes(DISCUSSION_HASH, phone, audience);
|
||||
await expect(phone.getByText("Q&A", { exact: true }).first()).toBeVisible();
|
||||
|
||||
await audience.evaluate((hash) => {
|
||||
window.location.hash = hash;
|
||||
}, ARCHITECTURE_FOCUS_HASH);
|
||||
await expectHashes(ARCHITECTURE_FOCUS_HASH, phone, audience);
|
||||
|
||||
await phone.reload();
|
||||
await expectConnected(phone, audience);
|
||||
await expectHashes(ARCHITECTURE_FOCUS_HASH, phone, audience);
|
||||
|
||||
await phone.getByRole("button", { name: "End presentation" }).click();
|
||||
await phone.getByRole("button", { name: "End presentation now" }).click();
|
||||
await Promise.all([
|
||||
expect(phone.getByText("The presenter ended this session.")).toBeVisible(),
|
||||
expect(audience.getByText("The presenter ended this session.")).toBeVisible(),
|
||||
]);
|
||||
} finally {
|
||||
await phoneContext.close();
|
||||
await audienceContext.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("supports symmetric creation from the audience route", async ({ browser }) => {
|
||||
const { creatorContext: audienceContext, creator: audience, joinerContext: phoneContext, joiner: phone } =
|
||||
await openPair(browser, "/present", "code");
|
||||
|
||||
try {
|
||||
await audience.keyboard.press("ArrowRight");
|
||||
await expectHashes("#scene/thesis/substrate", audience, phone);
|
||||
|
||||
await phone.getByRole("link", { name: "← Previous", exact: true }).click();
|
||||
await expectHashes(INITIAL_HASH, audience, phone);
|
||||
|
||||
await phone.getByRole("button", { name: "End presentation" }).click();
|
||||
await phone.getByRole("button", { name: "End presentation now" }).click();
|
||||
await Promise.all([
|
||||
expect(phone.getByText("The presenter ended this session.")).toBeVisible(),
|
||||
expect(audience.getByText("The presenter ended this session.")).toBeVisible(),
|
||||
]);
|
||||
} finally {
|
||||
await audienceContext.close();
|
||||
await phoneContext.close();
|
||||
}
|
||||
});
|
||||
@@ -8,18 +8,20 @@
|
||||
"prebuild": "pnpm --filter @lda/presentation-sync build",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"test:presentation-sync:e2e:install": "playwright install chromium",
|
||||
"test:presentation-sync:e2e": "playwright test e2e/presentation-sync.spec.ts --workers=1",
|
||||
"pretypecheck": "pnpm --filter @lda/presentation-sync build",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lda/presentation-sync": "workspace:*",
|
||||
"@assistant-ui/react": "^0.14.26",
|
||||
"@dagrejs/dagre": "3.0.0",
|
||||
"@fontsource-variable/newsreader": "5.2.10",
|
||||
"@fontsource-variable/source-sans-3": "5.2.9",
|
||||
"@fontsource/barlow-condensed": "5.2.8",
|
||||
"@fontsource/ibm-plex-mono": "5.2.7",
|
||||
"@lda/presentation-sync": "workspace:*",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"@xyflow/react": "12.11.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -37,6 +39,7 @@
|
||||
"valibot": "1.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/vite": "4.3.2",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src", "e2e"],
|
||||
"references": [{ "path": "../../packages/presentation-sync" }]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
|
||||
const backendPort = process.env.WEB_PORT ?? "8787";
|
||||
|
||||
@@ -46,6 +46,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
exclude: [...configDefaults.exclude, "e2e/**"],
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+38
@@ -84,6 +84,9 @@ importers:
|
||||
specifier: 1.4.2
|
||||
version: 1.4.2([email protected])
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.61.1
|
||||
version: 1.61.1
|
||||
'@tailwindcss/vite':
|
||||
specifier: 4.3.2
|
||||
version: 4.3.2([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
||||
@@ -586,6 +589,11 @@ packages:
|
||||
'@oxc-project/[email protected]':
|
||||
resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==}
|
||||
|
||||
'@playwright/[email protected]':
|
||||
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
|
||||
|
||||
@@ -1896,6 +1904,11 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -2237,6 +2250,16 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -3095,6 +3118,10 @@ snapshots:
|
||||
|
||||
'@oxc-project/[email protected]': {}
|
||||
|
||||
'@playwright/[email protected]':
|
||||
dependencies:
|
||||
playwright: 1.61.1
|
||||
|
||||
'@radix-ui/[email protected]': {}
|
||||
|
||||
'@radix-ui/[email protected]': {}
|
||||
@@ -4385,6 +4412,9 @@ snapshots:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7([email protected])
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
@@ -4839,6 +4869,14 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
playwright-core: 1.61.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
nanoid: 3.3.15
|
||||
|
||||
Reference in New Issue
Block a user