commit c6188d4228412d1a0bfd7d2a770129f1bc9c640f Author: Meta Vens Date: Tue Jun 9 15:04:37 2026 +0100 Scaffold Loki Chrome CultMesh daemon diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88fe71a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +state/*.tmp +state/*.log +state/runtime/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..34d639d --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Loki + +Loki is a CultMesh-oriented daemon for observing local Chrome tabs through an +explicit Chrome DevTools Protocol endpoint. + +It does not see ordinary locked Chrome tabs by magic. Chrome must be launched +with a remote debugging port, and the daemon publishes that reachability as +state instead of pretending. + +## Authority Map + +- Owner: Loki owns Chrome DevTools observation and the derived operator surface. +- Inputs: `http://127.0.0.1:9222/json/version`, + `http://127.0.0.1:9222/json/list`, and daemon configuration. +- Outputs: typed `.cc` witness documents, an Eve provider advertisement, and a + `gamecult.eve.surface.v1` surface projection. +- Derived state: Eve surface health, tab counts, selected tab hints, and + operator text are derived from the Chrome snapshot. +- Forbidden writers: renderers, dashboards, ad hoc scripts, and browser windows + do not own Loki state. +- Shared paths: one `observeChrome()` path feeds witness, advertisement, and + Eve projection. +- Cut line: command execution is not implemented until it has an auditable + command envelope and explicit consent boundary. + +## Start Chrome For Observation + +Close Chrome or use a separate profile, then launch: + +```powershell +& "$env:ProgramFiles\Google\Chrome\Application\chrome.exe" ` + --remote-debugging-port=9222 ` + --user-data-dir="$env:TEMP\loki-chrome-profile" +``` + +Then run Loki: + +```powershell +cd E:\Projects\Loki +npm run start +``` + +For a one-shot smoke: + +```powershell +npm run smoke +``` + +## State Files + +Loki writes: + +- `state/loki.chrome_snapshot.cc` +- `state/loki.provider_advertisement.cc` +- `state/loki.eve_surface.cc` + +These first `.cc` documents are typed text witnesses. They preserve schema +identity, provider authority, and redaction posture while the daemon awaits a +direct CultCacheTS/CultNetTS backing store integration. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0b06efe --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,44 @@ +# Loki Architecture + +## Objective + +Expose local Chrome tab state as provider-owned, typed service state for +CultMesh/Eve consumers. + +## Current Mechanism + +Chrome exposes DevTools metadata on `127.0.0.1:{port}` when started with +`--remote-debugging-port`. Loki polls the version and tab-list endpoints, +normalizes the result, and writes three documents from the same snapshot. + +```mermaid +flowchart LR + Chrome["Chrome CDP endpoint"] --> Observe["observeChrome"] + Config["Loki config"] --> Observe + Observe --> Snapshot["chrome_snapshot.cc"] + Snapshot --> Provider["provider_advertisement.cc"] + Snapshot --> Surface["eve_surface.cc"] +``` + +## Invariants + +- CDP reachability is observed state, not assumed capability. +- Chrome owns page reality; Loki owns only the observation snapshot. +- `.cc` witnesses are the durable local state surface. +- Eve/CultUI output is derived from the same snapshot as the witness. +- No renderer or dashboard can override the daemon's observation state. + +## Intended Change Path + +1. Add CultCacheTS-backed binary `.cc` persistence behind the current writer. +2. Add CultNet/CultMesh publication of the same documents. +3. Add command handling only through `gamecult.eve.command.v1`. +4. Add consentful CDP actions, starting with read-only page metadata and + operator-approved screenshot capture. + +## Command Boundary + +Loki currently advertises no executable tab commands. This is deliberate. A +daemon that can evaluate JavaScript inside live user tabs is holding a sharp +tool; it needs an explicit command document, audit log, origin, target tab id, +and denial path before it should act. diff --git a/package.json b/package.json new file mode 100644 index 0000000..48a80ad --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "@gamecult/loki", + "version": "0.1.0", + "private": true, + "description": "CultMesh-oriented Chrome DevTools observer daemon.", + "type": "module", + "scripts": { + "start": "node src/loki-daemon.mjs", + "smoke": "node src/loki-daemon.mjs --once", + "test": "node test/fixture-cdp-smoke.mjs" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "gamecult", + "cultmesh", + "cultcache", + "chrome-devtools-protocol" + ], + "license": "UNLICENSED" +} diff --git a/src/loki-daemon.mjs b/src/loki-daemon.mjs new file mode 100644 index 0000000..7ba4602 --- /dev/null +++ b/src/loki-daemon.mjs @@ -0,0 +1,422 @@ +import { mkdir, rename, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const providerId = "loki.chrome"; +const serviceId = "loki.chrome_daemon"; +const defaultStateDir = join(repoRoot, "state"); + +function parseArgs(argv) { + const config = { + host: process.env.LOKI_CHROME_HOST ?? "127.0.0.1", + port: Number(process.env.LOKI_CHROME_PORT ?? 9222), + intervalMs: Number(process.env.LOKI_INTERVAL_MS ?? 5000), + stateDir: process.env.LOKI_STATE_DIR ?? defaultStateDir, + once: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--once") config.once = true; + else if (arg === "--host") config.host = argv[++index] ?? config.host; + else if (arg === "--port") config.port = Number(argv[++index] ?? config.port); + else if (arg === "--interval-ms") config.intervalMs = Number(argv[++index] ?? config.intervalMs); + else if (arg === "--state-dir") config.stateDir = argv[++index] ?? config.stateDir; + else if (arg === "--help") { + printHelp(); + process.exit(0); + } + } + + if (!Number.isFinite(config.port) || config.port <= 0) { + throw new Error(`Invalid --port: ${config.port}`); + } + if (!Number.isFinite(config.intervalMs) || config.intervalMs < 1000) { + throw new Error(`Invalid --interval-ms: ${config.intervalMs}`); + } + return config; +} + +function printHelp() { + console.log(`Loki Chrome CultMesh daemon + +Usage: + node src/loki-daemon.mjs [--once] [--host 127.0.0.1] [--port 9222] + [--interval-ms 5000] [--state-dir state] +`); +} + +async function fetchJson(url, timeoutMs = 2500) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`); + } + return await response.json(); + } finally { + clearTimeout(timeout); + } +} + +async function observeChrome(config) { + const baseUrl = `http://${config.host}:${config.port}`; + const observedAt = new Date().toISOString(); + + try { + const [version, targets] = await Promise.all([ + fetchJson(`${baseUrl}/json/version`), + fetchJson(`${baseUrl}/json/list`), + ]); + + const tabs = Array.isArray(targets) + ? targets + .filter((target) => target.type === "page") + .map(normalizeTab) + .sort((left, right) => left.title.localeCompare(right.title) || left.id.localeCompare(right.id)) + : []; + + return { + schema: "gamecult.loki.chrome_snapshot.v0", + providerId, + serviceId, + observedAt, + freshness: { + state: "fresh", + lastSeenAt: observedAt, + maxAgeMs: config.intervalMs * 3, + }, + cdp: { + endpoint: baseUrl, + reachable: true, + browser: stringOrNull(version.Browser), + protocolVersion: stringOrNull(version["Protocol-Version"]), + userAgent: stringOrNull(version["User-Agent"]), + webSocketDebuggerUrl: stringOrNull(version.webSocketDebuggerUrl), + }, + tabs, + summary: { + tabCount: tabs.length, + inspectableTabCount: tabs.filter((tab) => tab.webSocketDebuggerUrl).length, + }, + redaction: { + urlQuery: "preserved", + titles: "preserved", + pageContent: "not-read", + cookies: "not-read", + }, + }; + } catch (error) { + return { + schema: "gamecult.loki.chrome_snapshot.v0", + providerId, + serviceId, + observedAt, + freshness: { + state: "unreachable", + lastSeenAt: null, + maxAgeMs: config.intervalMs * 3, + }, + cdp: { + endpoint: baseUrl, + reachable: false, + error: error instanceof Error ? error.message : String(error), + }, + tabs: [], + summary: { + tabCount: 0, + inspectableTabCount: 0, + }, + redaction: { + urlQuery: "not-observed", + titles: "not-observed", + pageContent: "not-read", + cookies: "not-read", + }, + }; + } +} + +function normalizeTab(target) { + return { + id: String(target.id ?? ""), + title: stringOrNull(target.title), + url: stringOrNull(target.url), + type: stringOrNull(target.type), + faviconUrl: stringOrNull(target.faviconUrl), + devtoolsFrontendUrl: stringOrNull(target.devtoolsFrontendUrl), + webSocketDebuggerUrl: stringOrNull(target.webSocketDebuggerUrl), + attached: Boolean(target.attached), + canInspect: Boolean(target.webSocketDebuggerUrl), + }; +} + +function buildProviderAdvertisement(snapshot) { + const updatedAt = snapshot.observedAt; + return { + schema: "gamecult.eve.provider_advertisement.v1", + providerId, + serviceId, + verseId: "loki.local", + rootVerse: "asgard", + canonicalService: "asgard.loki.chrome", + locatedService: "asgard.localhost.loki.chrome", + cultMeshAddress: "asgard.localhost.loki.chrome/eve/gui", + title: "Loki Chrome", + kind: "service.operator", + updatedAt, + freshness: snapshot.freshness, + schemas: [ + { + schema: "gamecult.loki.chrome_snapshot.v0", + owner: providerId, + authority: "observed", + storage: "cultcache-cc", + cultMeshAddress: "asgard.localhost.loki.chrome/state/snapshot", + portable: true, + }, + { + schema: "gamecult.eve.surface.v1", + owner: providerId, + authority: "derived", + storage: "cultcache-cc", + cultMeshAddress: "asgard.localhost.loki.chrome/eve/gui", + portable: true, + }, + ], + witnesses: [ + { + id: "loki.chrome.snapshot", + kind: "cc-export", + path: "state/loki.chrome_snapshot.cc", + schemas: ["gamecult.loki.chrome_snapshot.v0"], + redaction: snapshot.redaction, + freshness: { + state: snapshot.freshness.state, + updatedAt, + }, + }, + { + id: "loki.chrome.eve_surface", + kind: "cc-export", + path: "state/loki.eve_surface.cc", + schemas: ["gamecult.eve.surface.v1"], + redaction: "derived-operator-surface", + freshness: { + state: snapshot.freshness.state, + updatedAt, + }, + }, + ], + surfaces: [ + { + surfaceId: "loki.chrome.operator", + schema: "gamecult.eve.surface.v1", + transport: "cultmesh-document", + address: "asgard.localhost.loki.chrome/eve/gui", + tuiAddress: "asgard.localhost.loki.chrome/eve/tui", + audience: "operator", + mode: "read-only", + styleProfile: "loki.operator", + commands: [], + }, + ], + commands: [], + routes: [ + { + kind: "local-cdp", + address: snapshot.cdp.endpoint, + carries: ["chrome-target-metadata"], + note: "Requires user-launched Chrome with --remote-debugging-port.", + }, + { + kind: "local-cultcache-witness", + address: "state/", + carries: [ + "state/loki.chrome_snapshot.cc", + "state/loki.provider_advertisement.cc", + "state/loki.eve_surface.cc", + ], + }, + ], + nestedVerses: [], + styleCapabilities: [], + contacts: [], + }; +} + +function buildEveSurface(snapshot) { + const statusTone = snapshot.cdp.reachable ? "ok" : "warning"; + const tabRows = snapshot.tabs.slice(0, 20).map((tab) => ({ + id: `tab-${stableId(tab.id)}`, + kind: "inspector.kv", + props: { + label: tab.title || "(untitled)", + value: tab.url || "(no url)", + tone: tab.canInspect ? "ok" : "muted", + meta: { + id: tab.id, + attached: tab.attached, + canInspect: tab.canInspect, + }, + }, + children: [], + })); + + return { + type: "surface-state", + schema: "gamecult.eve.surface.v1", + providerId, + providerKind: "service.operator", + title: "Loki Chrome", + version: Date.parse(snapshot.observedAt), + updatedAt: snapshot.observedAt, + surface: { + root: { + id: "loki.chrome.root", + kind: "surface", + props: { + layout: "operator", + freshness: snapshot.freshness, + }, + children: [ + { + id: "loki.chrome.summary", + kind: "panel", + props: { title: "Chrome DevTools", tone: statusTone }, + children: [ + metric("reachable", "Reachable", snapshot.cdp.reachable ? "yes" : "no", statusTone), + metric("tabs", "Tabs", String(snapshot.summary.tabCount), "neutral"), + metric("inspectable", "Inspectable", String(snapshot.summary.inspectableTabCount), "neutral"), + { + id: "loki.chrome.endpoint", + kind: "inspector.kv", + props: { + label: "Endpoint", + value: snapshot.cdp.endpoint, + tone: statusTone, + }, + children: [], + }, + snapshot.cdp.error + ? { + id: "loki.chrome.error", + kind: "text", + props: { text: snapshot.cdp.error, tone: "warning" }, + children: [], + } + : { + id: "loki.chrome.browser", + kind: "inspector.kv", + props: { + label: "Browser", + value: snapshot.cdp.browser ?? "unknown", + tone: "neutral", + }, + children: [], + }, + ], + }, + { + id: "loki.chrome.tabs", + kind: "panel", + props: { title: "Observed Tabs", empty: snapshot.tabs.length === 0 }, + children: tabRows.length > 0 + ? tabRows + : [ + { + id: "loki.chrome.no-tabs", + kind: "text", + props: { + text: "No CDP page targets observed.", + tone: "muted", + }, + children: [], + }, + ], + }, + ], + }, + styles: { + tokens: { + density: "operator", + accent: "#39a275", + warning: "#b7791f", + }, + }, + }, + commands: [], + }; +} + +function metric(id, label, value, tone) { + return { + id: `loki.chrome.metric.${id}`, + kind: "metric", + props: { label, value, tone }, + children: [], + }; +} + +function stableId(value) { + return String(value || "unknown").replace(/[^a-zA-Z0-9_.-]+/g, "-").slice(0, 80); +} + +function stringOrNull(value) { + return typeof value === "string" && value.length > 0 ? value : null; +} + +async function writeTypedDocument(filePath, document) { + await mkdir(dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.tmp`; + const body = [ + `# cultcache-text-witness v0`, + `# schema: ${document.schema}`, + `# provider: ${document.providerId ?? providerId}`, + `# updatedAt: ${document.updatedAt ?? document.observedAt ?? new Date().toISOString()}`, + JSON.stringify(document, null, 2), + "", + ].join("\n"); + await writeFile(tempPath, body, "utf8"); + await rename(tempPath, filePath); +} + +async function publishSnapshot(config, snapshot) { + const advertisement = buildProviderAdvertisement(snapshot); + const surface = buildEveSurface(snapshot); + + await Promise.all([ + writeTypedDocument(join(config.stateDir, "loki.chrome_snapshot.cc"), snapshot), + writeTypedDocument(join(config.stateDir, "loki.provider_advertisement.cc"), advertisement), + writeTypedDocument(join(config.stateDir, "loki.eve_surface.cc"), surface), + ]); + + return { advertisement, surface }; +} + +async function tick(config) { + const snapshot = await observeChrome(config); + await publishSnapshot(config, snapshot); + const state = snapshot.freshness.state; + const count = snapshot.summary.tabCount; + console.log(`${snapshot.observedAt} ${state} tabs=${count} endpoint=${snapshot.cdp.endpoint}`); +} + +async function main() { + const config = parseArgs(process.argv.slice(2)); + await tick(config); + if (config.once) return; + + setInterval(() => { + tick(config).catch((error) => { + console.error(error); + }); + }, config.intervalMs); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/state/loki.chrome_snapshot.cc b/state/loki.chrome_snapshot.cc new file mode 100644 index 0000000..d60a605 --- /dev/null +++ b/state/loki.chrome_snapshot.cc @@ -0,0 +1,31 @@ +# cultcache-text-witness v0 +# schema: gamecult.loki.chrome_snapshot.v0 +# provider: loki.chrome +# updatedAt: 2026-06-09T14:02:52.753Z +{ + "schema": "gamecult.loki.chrome_snapshot.v0", + "providerId": "loki.chrome", + "serviceId": "loki.chrome_daemon", + "observedAt": "2026-06-09T14:02:52.753Z", + "freshness": { + "state": "unreachable", + "lastSeenAt": null, + "maxAgeMs": 15000 + }, + "cdp": { + "endpoint": "http://127.0.0.1:9222", + "reachable": false, + "error": "fetch failed" + }, + "tabs": [], + "summary": { + "tabCount": 0, + "inspectableTabCount": 0 + }, + "redaction": { + "urlQuery": "not-observed", + "titles": "not-observed", + "pageContent": "not-read", + "cookies": "not-read" + } +} diff --git a/state/loki.eve_surface.cc b/state/loki.eve_surface.cc new file mode 100644 index 0000000..69c8773 --- /dev/null +++ b/state/loki.eve_surface.cc @@ -0,0 +1,115 @@ +# cultcache-text-witness v0 +# schema: gamecult.eve.surface.v1 +# provider: loki.chrome +# updatedAt: 2026-06-09T14:02:52.753Z +{ + "type": "surface-state", + "schema": "gamecult.eve.surface.v1", + "providerId": "loki.chrome", + "providerKind": "service.operator", + "title": "Loki Chrome", + "version": 1781013772753, + "updatedAt": "2026-06-09T14:02:52.753Z", + "surface": { + "root": { + "id": "loki.chrome.root", + "kind": "surface", + "props": { + "layout": "operator", + "freshness": { + "state": "unreachable", + "lastSeenAt": null, + "maxAgeMs": 15000 + } + }, + "children": [ + { + "id": "loki.chrome.summary", + "kind": "panel", + "props": { + "title": "Chrome DevTools", + "tone": "warning" + }, + "children": [ + { + "id": "loki.chrome.metric.reachable", + "kind": "metric", + "props": { + "label": "Reachable", + "value": "no", + "tone": "warning" + }, + "children": [] + }, + { + "id": "loki.chrome.metric.tabs", + "kind": "metric", + "props": { + "label": "Tabs", + "value": "0", + "tone": "neutral" + }, + "children": [] + }, + { + "id": "loki.chrome.metric.inspectable", + "kind": "metric", + "props": { + "label": "Inspectable", + "value": "0", + "tone": "neutral" + }, + "children": [] + }, + { + "id": "loki.chrome.endpoint", + "kind": "inspector.kv", + "props": { + "label": "Endpoint", + "value": "http://127.0.0.1:9222", + "tone": "warning" + }, + "children": [] + }, + { + "id": "loki.chrome.error", + "kind": "text", + "props": { + "text": "fetch failed", + "tone": "warning" + }, + "children": [] + } + ] + }, + { + "id": "loki.chrome.tabs", + "kind": "panel", + "props": { + "title": "Observed Tabs", + "empty": true + }, + "children": [ + { + "id": "loki.chrome.no-tabs", + "kind": "text", + "props": { + "text": "No CDP page targets observed.", + "tone": "muted" + }, + "children": [] + } + ] + } + ] + }, + "styles": { + "tokens": { + "density": "operator", + "accent": "#39a275", + "warning": "#b7791f" + } + } + }, + "commands": [] +} diff --git a/state/loki.provider_advertisement.cc b/state/loki.provider_advertisement.cc new file mode 100644 index 0000000..fa7e965 --- /dev/null +++ b/state/loki.provider_advertisement.cc @@ -0,0 +1,109 @@ +# cultcache-text-witness v0 +# schema: gamecult.eve.provider_advertisement.v1 +# provider: loki.chrome +# updatedAt: 2026-06-09T14:02:52.753Z +{ + "schema": "gamecult.eve.provider_advertisement.v1", + "providerId": "loki.chrome", + "serviceId": "loki.chrome_daemon", + "verseId": "loki.local", + "rootVerse": "asgard", + "canonicalService": "asgard.loki.chrome", + "locatedService": "asgard.localhost.loki.chrome", + "cultMeshAddress": "asgard.localhost.loki.chrome/eve/gui", + "title": "Loki Chrome", + "kind": "service.operator", + "updatedAt": "2026-06-09T14:02:52.753Z", + "freshness": { + "state": "unreachable", + "lastSeenAt": null, + "maxAgeMs": 15000 + }, + "schemas": [ + { + "schema": "gamecult.loki.chrome_snapshot.v0", + "owner": "loki.chrome", + "authority": "observed", + "storage": "cultcache-cc", + "cultMeshAddress": "asgard.localhost.loki.chrome/state/snapshot", + "portable": true + }, + { + "schema": "gamecult.eve.surface.v1", + "owner": "loki.chrome", + "authority": "derived", + "storage": "cultcache-cc", + "cultMeshAddress": "asgard.localhost.loki.chrome/eve/gui", + "portable": true + } + ], + "witnesses": [ + { + "id": "loki.chrome.snapshot", + "kind": "cc-export", + "path": "state/loki.chrome_snapshot.cc", + "schemas": [ + "gamecult.loki.chrome_snapshot.v0" + ], + "redaction": { + "urlQuery": "not-observed", + "titles": "not-observed", + "pageContent": "not-read", + "cookies": "not-read" + }, + "freshness": { + "state": "unreachable", + "updatedAt": "2026-06-09T14:02:52.753Z" + } + }, + { + "id": "loki.chrome.eve_surface", + "kind": "cc-export", + "path": "state/loki.eve_surface.cc", + "schemas": [ + "gamecult.eve.surface.v1" + ], + "redaction": "derived-operator-surface", + "freshness": { + "state": "unreachable", + "updatedAt": "2026-06-09T14:02:52.753Z" + } + } + ], + "surfaces": [ + { + "surfaceId": "loki.chrome.operator", + "schema": "gamecult.eve.surface.v1", + "transport": "cultmesh-document", + "address": "asgard.localhost.loki.chrome/eve/gui", + "tuiAddress": "asgard.localhost.loki.chrome/eve/tui", + "audience": "operator", + "mode": "read-only", + "styleProfile": "loki.operator", + "commands": [] + } + ], + "commands": [], + "routes": [ + { + "kind": "local-cdp", + "address": "http://127.0.0.1:9222", + "carries": [ + "chrome-target-metadata" + ], + "note": "Requires user-launched Chrome with --remote-debugging-port." + }, + { + "kind": "local-cultcache-witness", + "address": "state/", + "carries": [ + "state/loki.chrome_snapshot.cc", + "state/loki.provider_advertisement.cc", + "state/loki.eve_surface.cc" + ] + } + ], + "nestedVerses": [], + "styleCapabilities": [], + "contacts": [] +} diff --git a/test/fixture-cdp-smoke.mjs b/test/fixture-cdp-smoke.mjs new file mode 100644 index 0000000..2ea1365 --- /dev/null +++ b/test/fixture-cdp-smoke.mjs @@ -0,0 +1,81 @@ +import { createServer } from "node:http"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawn } from "node:child_process"; + +const fixtures = { + "/json/version": { + Browser: "Chrome/fixture", + "Protocol-Version": "1.3", + "User-Agent": "Fixture", + webSocketDebuggerUrl: "ws://127.0.0.1:0/devtools/browser/fixture", + }, + "/json/list": [ + { + id: "tab-1", + type: "page", + title: "Portal das Financas", + url: "https://sitfiscal.portaldasfinancas.gov.pt/geral/siteMap", + webSocketDebuggerUrl: "ws://127.0.0.1:0/devtools/page/tab-1", + attached: false, + }, + ], +}; + +const server = createServer((request, response) => { + const payload = fixtures[request.url]; + if (!payload) { + response.writeHead(404); + response.end("not found"); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(payload)); +}); + +const stateDir = await mkdtemp(join(tmpdir(), "loki-smoke-")); + +try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + await runDaemonOnce(port, stateDir); + + const snapshotText = await readFile(join(stateDir, "loki.chrome_snapshot.cc"), "utf8"); + const surfaceText = await readFile(join(stateDir, "loki.eve_surface.cc"), "utf8"); + + if (!snapshotText.includes('"state": "fresh"')) throw new Error("snapshot did not become fresh"); + if (!snapshotText.includes("Portal das Financas")) throw new Error("snapshot did not include fixture tab"); + if (!surfaceText.includes('"schema": "gamecult.eve.surface.v1"')) throw new Error("surface schema missing"); + + console.log("fixture CDP smoke passed"); +} finally { + server.close(); + await rm(stateDir, { recursive: true, force: true }); +} + +function runDaemonOnce(port, targetStateDir) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [ + "src/loki-daemon.mjs", + "--once", + "--port", + String(port), + "--state-dir", + targetStateDir, + ], { + cwd: new URL("..", import.meta.url), + stdio: "pipe", + }); + + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`daemon exited ${code}: ${stderr}`)); + }); + }); +}