Add Loki Chrome extension bridge
This commit is contained in:
parent
c6188d4228
commit
ae87719d61
13 changed files with 863 additions and 70 deletions
46
README.md
46
README.md
|
|
@ -1,16 +1,17 @@
|
|||
# Loki
|
||||
|
||||
Loki is a CultMesh-oriented daemon for observing local Chrome tabs through an
|
||||
explicit Chrome DevTools Protocol endpoint.
|
||||
Loki is a CultMesh-oriented daemon for observing local Chrome tabs through a
|
||||
drop-in Chrome extension or 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.
|
||||
It does not see ordinary locked Chrome tabs by magic. The easy path is loading
|
||||
the unpacked extension in `extension/`; the developer path is launching Chrome
|
||||
with a remote debugging port. The daemon publishes 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`,
|
||||
- Inputs: Chrome extension reports, `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.
|
||||
|
|
@ -18,12 +19,32 @@ state instead of pretending.
|
|||
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.
|
||||
- Shared paths: extension and CDP observations are normalized into one snapshot
|
||||
before 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
|
||||
## Drop Into Chrome
|
||||
|
||||
Run Loki:
|
||||
|
||||
```powershell
|
||||
cd E:\Projects\Loki
|
||||
npm run start
|
||||
```
|
||||
|
||||
Then install the extension:
|
||||
|
||||
1. Open `chrome://extensions`.
|
||||
2. Enable `Developer mode`.
|
||||
3. Choose `Load unpacked`.
|
||||
4. Select `E:\Projects\Loki\extension`.
|
||||
|
||||
The extension posts tab metadata to
|
||||
`http://127.0.0.1:17666/ingest/chrome-extension`. It does not read page DOM,
|
||||
cookies, or page content.
|
||||
|
||||
## CDP Observation
|
||||
|
||||
Close Chrome or use a separate profile, then launch:
|
||||
|
||||
|
|
@ -33,13 +54,6 @@ Close Chrome or use a separate profile, then launch:
|
|||
--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
|
||||
|
|
|
|||
|
|
@ -3,19 +3,25 @@
|
|||
## Objective
|
||||
|
||||
Expose local Chrome tab state as provider-owned, typed service state for
|
||||
CultMesh/Eve consumers.
|
||||
CultMesh/Eve consumers while keeping Chrome installation easy for the operator.
|
||||
|
||||
## 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.
|
||||
The Chrome extension observes tab metadata with explicit Chrome permissions and
|
||||
posts reports to Loki's localhost ingest endpoint. Chrome can also expose
|
||||
DevTools metadata on `127.0.0.1:{port}` when started with
|
||||
`--remote-debugging-port`. Loki normalizes both sources into one snapshot and
|
||||
writes three documents from that snapshot.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Chrome["Chrome CDP endpoint"] --> Observe["observeChrome"]
|
||||
Config["Loki config"] --> Observe
|
||||
Observe --> Snapshot["chrome_snapshot.cc"]
|
||||
Extension["Chrome extension"] --> Ingest["extension ingest"]
|
||||
Chrome["Chrome CDP endpoint"] --> Observe["observe CDP"]
|
||||
Config["Loki config"] --> Ingest
|
||||
Config --> Observe
|
||||
Ingest --> Combine["combined snapshot"]
|
||||
Observe --> Combine
|
||||
Combine --> Snapshot["chrome_snapshot.cc"]
|
||||
Snapshot --> Provider["provider_advertisement.cc"]
|
||||
Snapshot --> Surface["eve_surface.cc"]
|
||||
```
|
||||
|
|
@ -23,6 +29,7 @@ flowchart LR
|
|||
## Invariants
|
||||
|
||||
- CDP reachability is observed state, not assumed capability.
|
||||
- Extension 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.
|
||||
|
|
|
|||
23
extension/manifest.json
Normal file
23
extension/manifest.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Loki Chrome Bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Publishes Chrome tab metadata to the local Loki CultMesh daemon.",
|
||||
"permissions": [
|
||||
"alarms",
|
||||
"storage",
|
||||
"tabs"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1:17666/*",
|
||||
"http://localhost:17666/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "service-worker.js",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Loki Chrome Bridge",
|
||||
"default_popup": "popup.html"
|
||||
}
|
||||
}
|
||||
91
extension/popup.css
Normal file
91
extension/popup.css
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 300px;
|
||||
font: 13px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #17201b;
|
||||
background: #f7faf8;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.state {
|
||||
border: 1px solid #a9b8ae;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
color: #445149;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.state.ok {
|
||||
border-color: #58a36f;
|
||||
color: #1d6b35;
|
||||
background: #eef8f0;
|
||||
}
|
||||
|
||||
.state.bad {
|
||||
border-color: #c2803a;
|
||||
color: #7a4a13;
|
||||
background: #fff7ea;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: #5a665f;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 0 12px;
|
||||
color: #8d3d20;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
border: 1px solid #2b6b47;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
color: #ffffff;
|
||||
background: #2f7a50;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
37
extension/popup.html
Normal file
37
extension/popup.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Loki Chrome Bridge</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>Loki</h1>
|
||||
<span id="state" class="state">checking</span>
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Tabs</dt>
|
||||
<dd id="tabs">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last sync</dt>
|
||||
<dd id="last-sync">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Endpoint</dt>
|
||||
<dd id="endpoint">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p id="error" class="error" hidden></p>
|
||||
|
||||
<button id="sync" type="button">Sync now</button>
|
||||
</main>
|
||||
<script src="popup.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
42
extension/popup.js
Normal file
42
extension/popup.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
const stateEl = document.querySelector("#state");
|
||||
const tabsEl = document.querySelector("#tabs");
|
||||
const lastSyncEl = document.querySelector("#last-sync");
|
||||
const endpointEl = document.querySelector("#endpoint");
|
||||
const errorEl = document.querySelector("#error");
|
||||
const syncButton = document.querySelector("#sync");
|
||||
|
||||
syncButton.addEventListener("click", async () => {
|
||||
syncButton.disabled = true;
|
||||
await chrome.runtime.sendMessage({ type: "loki.sync-now" });
|
||||
await render();
|
||||
syncButton.disabled = false;
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
async function render() {
|
||||
const result = await chrome.runtime.sendMessage({ type: "loki.get-status" });
|
||||
const status = result.status;
|
||||
endpointEl.textContent = result.endpoint;
|
||||
|
||||
if (!status) {
|
||||
stateEl.textContent = "waiting";
|
||||
stateEl.className = "state";
|
||||
tabsEl.textContent = "-";
|
||||
lastSyncEl.textContent = "-";
|
||||
errorEl.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
stateEl.textContent = status.ok ? "connected" : "offline";
|
||||
stateEl.className = `state ${status.ok ? "ok" : "bad"}`;
|
||||
tabsEl.textContent = String(status.tabCount ?? 0);
|
||||
lastSyncEl.textContent = status.lastSyncAt ? new Date(status.lastSyncAt).toLocaleTimeString() : "-";
|
||||
|
||||
if (status.error) {
|
||||
errorEl.textContent = status.error;
|
||||
errorEl.hidden = false;
|
||||
} else {
|
||||
errorEl.hidden = true;
|
||||
}
|
||||
}
|
||||
146
extension/service-worker.js
Normal file
146
extension/service-worker.js
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
const DEFAULT_ENDPOINT = "http://127.0.0.1:17666/ingest/chrome-extension";
|
||||
const STATUS_KEY = "lokiStatus";
|
||||
const CONFIG_KEY = "lokiConfig";
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.alarms.create("loki-sync", { periodInMinutes: 1 });
|
||||
syncTabs("installed");
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
chrome.alarms.create("loki-sync", { periodInMinutes: 1 });
|
||||
syncTabs("startup");
|
||||
});
|
||||
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === "loki-sync") syncTabs("alarm");
|
||||
});
|
||||
|
||||
chrome.tabs.onCreated.addListener(() => syncTabs("tab-created"));
|
||||
chrome.tabs.onUpdated.addListener(() => syncTabs("tab-updated"));
|
||||
chrome.tabs.onRemoved.addListener(() => syncTabs("tab-removed"));
|
||||
chrome.tabs.onActivated.addListener(() => syncTabs("tab-activated"));
|
||||
chrome.tabs.onMoved.addListener(() => syncTabs("tab-moved"));
|
||||
chrome.tabs.onAttached.addListener(() => syncTabs("tab-attached"));
|
||||
chrome.tabs.onDetached.addListener(() => syncTabs("tab-detached"));
|
||||
chrome.tabs.onHighlighted.addListener(() => syncTabs("tab-highlighted"));
|
||||
chrome.windows.onFocusChanged.addListener(() => syncTabs("window-focus"));
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message?.type === "loki.sync-now") {
|
||||
syncTabs("manual").then(sendResponse);
|
||||
return true;
|
||||
}
|
||||
if (message?.type === "loki.get-status") {
|
||||
getStatus().then(sendResponse);
|
||||
return true;
|
||||
}
|
||||
if (message?.type === "loki.set-endpoint") {
|
||||
setEndpoint(message.endpoint).then(sendResponse);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
async function syncTabs(reason) {
|
||||
const statusStartedAt = new Date().toISOString();
|
||||
try {
|
||||
const endpoint = await getEndpoint();
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const report = {
|
||||
schema: "gamecult.loki.chrome_extension_report.v0",
|
||||
extensionVersion: chrome.runtime.getManifest().version,
|
||||
browser: navigator.userAgent,
|
||||
reportedAt: statusStartedAt,
|
||||
reason,
|
||||
tabs: tabs.map(projectTab),
|
||||
summary: {
|
||||
tabCount: tabs.length,
|
||||
activeTabCount: tabs.filter((tab) => tab.active).length,
|
||||
},
|
||||
redaction: {
|
||||
pageContent: "not-read",
|
||||
cookies: "not-read",
|
||||
tabMetadata: "title-url-favicon-window-state",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(report),
|
||||
});
|
||||
|
||||
const responseBody = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(responseBody.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const status = {
|
||||
ok: true,
|
||||
endpoint,
|
||||
reason,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
tabCount: tabs.length,
|
||||
activeSource: responseBody.activeSource ?? "unknown",
|
||||
error: null,
|
||||
};
|
||||
await chrome.storage.local.set({ [STATUS_KEY]: status });
|
||||
return status;
|
||||
} catch (error) {
|
||||
const endpoint = await getEndpoint();
|
||||
const status = {
|
||||
ok: false,
|
||||
endpoint,
|
||||
reason,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
tabCount: 0,
|
||||
activeSource: "none",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
await chrome.storage.local.set({ [STATUS_KEY]: status });
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function projectTab(tab) {
|
||||
return {
|
||||
id: tab.id,
|
||||
windowId: tab.windowId,
|
||||
index: tab.index,
|
||||
active: tab.active,
|
||||
highlighted: tab.highlighted,
|
||||
pinned: tab.pinned,
|
||||
audible: tab.audible ?? false,
|
||||
muted: Boolean(tab.mutedInfo?.muted),
|
||||
incognito: tab.incognito,
|
||||
title: tab.title ?? null,
|
||||
url: tab.url ?? null,
|
||||
favIconUrl: tab.favIconUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getStatus() {
|
||||
const values = await chrome.storage.local.get([STATUS_KEY, CONFIG_KEY]);
|
||||
return {
|
||||
endpoint: values[CONFIG_KEY]?.endpoint ?? DEFAULT_ENDPOINT,
|
||||
status: values[STATUS_KEY] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getEndpoint() {
|
||||
const values = await chrome.storage.local.get(CONFIG_KEY);
|
||||
return values[CONFIG_KEY]?.endpoint ?? DEFAULT_ENDPOINT;
|
||||
}
|
||||
|
||||
async function setEndpoint(endpoint) {
|
||||
const cleanEndpoint = String(endpoint || "").trim();
|
||||
if (!/^https?:\/\/(127\.0\.0\.1|localhost):\d+\/ingest\/chrome-extension$/.test(cleanEndpoint)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Endpoint must be http://127.0.0.1:<port>/ingest/chrome-extension or localhost.",
|
||||
};
|
||||
}
|
||||
await chrome.storage.local.set({ [CONFIG_KEY]: { endpoint: cleanEndpoint } });
|
||||
return syncTabs("endpoint-updated");
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
"scripts": {
|
||||
"start": "node src/loki-daemon.mjs",
|
||||
"smoke": "node src/loki-daemon.mjs --once",
|
||||
"test": "node test/fixture-cdp-smoke.mjs"
|
||||
"test": "node test/fixture-cdp-smoke.mjs && node test/extension-ingest-smoke.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { createServer } from "node:http";
|
||||
import { mkdir, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -11,6 +12,8 @@ function parseArgs(argv) {
|
|||
const config = {
|
||||
host: process.env.LOKI_CHROME_HOST ?? "127.0.0.1",
|
||||
port: Number(process.env.LOKI_CHROME_PORT ?? 9222),
|
||||
extensionHost: process.env.LOKI_EXTENSION_HOST ?? "127.0.0.1",
|
||||
extensionPort: Number(process.env.LOKI_EXTENSION_PORT ?? 17666),
|
||||
intervalMs: Number(process.env.LOKI_INTERVAL_MS ?? 5000),
|
||||
stateDir: process.env.LOKI_STATE_DIR ?? defaultStateDir,
|
||||
once: false,
|
||||
|
|
@ -21,6 +24,8 @@ function parseArgs(argv) {
|
|||
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 === "--extension-host") config.extensionHost = argv[++index] ?? config.extensionHost;
|
||||
else if (arg === "--extension-port") config.extensionPort = Number(argv[++index] ?? config.extensionPort);
|
||||
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") {
|
||||
|
|
@ -35,6 +40,9 @@ function parseArgs(argv) {
|
|||
if (!Number.isFinite(config.intervalMs) || config.intervalMs < 1000) {
|
||||
throw new Error(`Invalid --interval-ms: ${config.intervalMs}`);
|
||||
}
|
||||
if (!Number.isFinite(config.extensionPort) || config.extensionPort <= 0) {
|
||||
throw new Error(`Invalid --extension-port: ${config.extensionPort}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +51,7 @@ function printHelp() {
|
|||
|
||||
Usage:
|
||||
node src/loki-daemon.mjs [--once] [--host 127.0.0.1] [--port 9222]
|
||||
[--extension-host 127.0.0.1] [--extension-port 17666]
|
||||
[--interval-ms 5000] [--state-dir state]
|
||||
`);
|
||||
}
|
||||
|
|
@ -79,15 +88,14 @@ async function observeChrome(config) {
|
|||
: [];
|
||||
|
||||
return {
|
||||
schema: "gamecult.loki.chrome_snapshot.v0",
|
||||
providerId,
|
||||
serviceId,
|
||||
source: "cdp",
|
||||
observedAt,
|
||||
freshness: {
|
||||
state: "fresh",
|
||||
lastSeenAt: observedAt,
|
||||
maxAgeMs: config.intervalMs * 3,
|
||||
},
|
||||
authority: "external-authority-projection",
|
||||
cdp: {
|
||||
endpoint: baseUrl,
|
||||
reachable: true,
|
||||
|
|
@ -97,22 +105,10 @@ async function observeChrome(config) {
|
|||
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,
|
||||
source: "cdp",
|
||||
observedAt,
|
||||
freshness: {
|
||||
state: "unreachable",
|
||||
|
|
@ -125,18 +121,170 @@ async function observeChrome(config) {
|
|||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
tabs: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExtensionObservation(payload, config) {
|
||||
const observedAt = new Date().toISOString();
|
||||
if (!payload || payload.schema !== "gamecult.loki.chrome_extension_report.v0") {
|
||||
throw new Error("Unsupported extension report schema");
|
||||
}
|
||||
|
||||
const tabs = Array.isArray(payload.tabs)
|
||||
? payload.tabs.map(normalizeExtensionTab).sort((left, right) => Number(right.active) - Number(left.active) || left.index - right.index)
|
||||
: [];
|
||||
|
||||
return {
|
||||
source: "chrome-extension",
|
||||
observedAt,
|
||||
extension: {
|
||||
reachable: true,
|
||||
extensionVersion: stringOrNull(payload.extensionVersion),
|
||||
browser: stringOrNull(payload.browser),
|
||||
receivedAt: observedAt,
|
||||
lastReportAt: stringOrNull(payload.reportedAt),
|
||||
ingestEndpoint: `http://${config.extensionHost}:${config.extensionPort}/ingest/chrome-extension`,
|
||||
},
|
||||
freshness: {
|
||||
state: "fresh",
|
||||
lastSeenAt: observedAt,
|
||||
maxAgeMs: config.intervalMs * 3,
|
||||
},
|
||||
tabs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExtensionTab(tab) {
|
||||
return {
|
||||
id: String(tab.id ?? ""),
|
||||
windowId: numberOrNull(tab.windowId),
|
||||
index: Number.isFinite(tab.index) ? tab.index : 0,
|
||||
active: Boolean(tab.active),
|
||||
highlighted: Boolean(tab.highlighted),
|
||||
pinned: Boolean(tab.pinned),
|
||||
audible: Boolean(tab.audible),
|
||||
muted: Boolean(tab.muted),
|
||||
incognito: Boolean(tab.incognito),
|
||||
title: stringOrNull(tab.title),
|
||||
url: stringOrNull(tab.url),
|
||||
type: "page",
|
||||
faviconUrl: stringOrNull(tab.favIconUrl),
|
||||
devtoolsFrontendUrl: null,
|
||||
webSocketDebuggerUrl: null,
|
||||
attached: false,
|
||||
canInspect: false,
|
||||
source: "chrome-extension",
|
||||
};
|
||||
}
|
||||
|
||||
function buildSnapshot(config, cdpObservation, extensionObservation) {
|
||||
const observedAt = new Date().toISOString();
|
||||
const activeObservation = chooseActiveObservation(cdpObservation, extensionObservation);
|
||||
const tabs = activeObservation?.tabs ?? [];
|
||||
const cdp = cdpObservation?.cdp ?? {
|
||||
endpoint: `http://${config.host}:${config.port}`,
|
||||
reachable: false,
|
||||
error: "not-observed",
|
||||
};
|
||||
const extension = extensionObservation?.extension ?? {
|
||||
reachable: false,
|
||||
ingestEndpoint: `http://${config.extensionHost}:${config.extensionPort}/ingest/chrome-extension`,
|
||||
error: "no extension report received",
|
||||
};
|
||||
|
||||
const sourceStates = {
|
||||
cdp: cdpObservation?.freshness?.state ?? "missing",
|
||||
chromeExtension: extensionObservation?.freshness?.state ?? "missing",
|
||||
};
|
||||
const freshnessState = activeObservation ? "fresh" : "unreachable";
|
||||
|
||||
return {
|
||||
schema: "gamecult.loki.chrome_snapshot.v0",
|
||||
providerId,
|
||||
serviceId,
|
||||
observedAt,
|
||||
activeSource: activeObservation?.source ?? "none",
|
||||
freshness: {
|
||||
state: freshnessState,
|
||||
lastSeenAt: activeObservation?.observedAt ?? null,
|
||||
maxAgeMs: config.intervalMs * 3,
|
||||
},
|
||||
sources: {
|
||||
cdp: {
|
||||
state: sourceStates.cdp,
|
||||
observedAt: cdpObservation?.observedAt ?? null,
|
||||
},
|
||||
chromeExtension: {
|
||||
state: sourceStates.chromeExtension,
|
||||
observedAt: extensionObservation?.observedAt ?? null,
|
||||
},
|
||||
},
|
||||
cdp,
|
||||
extension,
|
||||
tabs,
|
||||
summary: {
|
||||
tabCount: tabs.length,
|
||||
inspectableTabCount: tabs.filter((tab) => tab.webSocketDebuggerUrl).length,
|
||||
activeTabCount: tabs.filter((tab) => tab.active).length,
|
||||
},
|
||||
redaction: {
|
||||
urlQuery: activeObservation ? "preserved" : "not-observed",
|
||||
titles: activeObservation ? "preserved" : "not-observed",
|
||||
pageContent: "not-read",
|
||||
cookies: "not-read",
|
||||
source: activeObservation?.source ?? "none",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function chooseActiveObservation(cdpObservation, extensionObservation) {
|
||||
if (extensionObservation?.freshness?.state === "fresh") return extensionObservation;
|
||||
if (cdpObservation?.freshness?.state === "fresh") return cdpObservation;
|
||||
return null;
|
||||
}
|
||||
|
||||
function unreachableSnapshot(config, error) {
|
||||
const observedAt = new Date().toISOString();
|
||||
return {
|
||||
schema: "gamecult.loki.chrome_snapshot.v0",
|
||||
providerId,
|
||||
serviceId,
|
||||
observedAt,
|
||||
activeSource: "none",
|
||||
freshness: {
|
||||
state: "unreachable",
|
||||
lastSeenAt: null,
|
||||
maxAgeMs: config.intervalMs * 3,
|
||||
},
|
||||
sources: {
|
||||
cdp: { state: "unreachable", observedAt },
|
||||
chromeExtension: { state: "missing", observedAt: null },
|
||||
},
|
||||
cdp: {
|
||||
endpoint: `http://${config.host}:${config.port}`,
|
||||
reachable: false,
|
||||
error,
|
||||
},
|
||||
extension: {
|
||||
reachable: false,
|
||||
ingestEndpoint: `http://${config.extensionHost}:${config.extensionPort}/ingest/chrome-extension`,
|
||||
error: "no extension report received",
|
||||
},
|
||||
tabs: [],
|
||||
summary: {
|
||||
tabCount: 0,
|
||||
inspectableTabCount: 0,
|
||||
activeTabCount: 0,
|
||||
},
|
||||
redaction: {
|
||||
urlQuery: "not-observed",
|
||||
titles: "not-observed",
|
||||
pageContent: "not-read",
|
||||
cookies: "not-read",
|
||||
source: "none",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTab(target) {
|
||||
|
|
@ -177,6 +325,14 @@ function buildProviderAdvertisement(snapshot) {
|
|||
cultMeshAddress: "asgard.localhost.loki.chrome/state/snapshot",
|
||||
portable: true,
|
||||
},
|
||||
{
|
||||
schema: "gamecult.loki.chrome_extension_report.v0",
|
||||
owner: providerId,
|
||||
authority: "external-authority-projection",
|
||||
storage: "ingest-only",
|
||||
cultMeshAddress: "asgard.localhost.loki.chrome/ingest/chrome-extension",
|
||||
portable: true,
|
||||
},
|
||||
{
|
||||
schema: "gamecult.eve.surface.v1",
|
||||
owner: providerId,
|
||||
|
|
@ -231,6 +387,12 @@ function buildProviderAdvertisement(snapshot) {
|
|||
carries: ["chrome-target-metadata"],
|
||||
note: "Requires user-launched Chrome with --remote-debugging-port.",
|
||||
},
|
||||
{
|
||||
kind: "chrome-extension-ingest",
|
||||
address: snapshot.extension.ingestEndpoint,
|
||||
carries: ["chrome-extension-tab-metadata"],
|
||||
note: "Preferred drop-in Chrome path; extension observes tab metadata and Loki owns persistence.",
|
||||
},
|
||||
{
|
||||
kind: "local-cultcache-witness",
|
||||
address: "state/",
|
||||
|
|
@ -248,7 +410,7 @@ function buildProviderAdvertisement(snapshot) {
|
|||
}
|
||||
|
||||
function buildEveSurface(snapshot) {
|
||||
const statusTone = snapshot.cdp.reachable ? "ok" : "warning";
|
||||
const statusTone = snapshot.freshness.state === "fresh" ? "ok" : "warning";
|
||||
const tabRows = snapshot.tabs.slice(0, 20).map((tab) => ({
|
||||
id: `tab-${stableId(tab.id)}`,
|
||||
kind: "inspector.kv",
|
||||
|
|
@ -285,22 +447,33 @@ function buildEveSurface(snapshot) {
|
|||
{
|
||||
id: "loki.chrome.summary",
|
||||
kind: "panel",
|
||||
props: { title: "Chrome DevTools", tone: statusTone },
|
||||
props: { title: "Chrome Observation", tone: statusTone },
|
||||
children: [
|
||||
metric("reachable", "Reachable", snapshot.cdp.reachable ? "yes" : "no", statusTone),
|
||||
metric("reachable", "Reachable", snapshot.freshness.state === "fresh" ? "yes" : "no", statusTone),
|
||||
metric("source", "Source", snapshot.activeSource, statusTone),
|
||||
metric("tabs", "Tabs", String(snapshot.summary.tabCount), "neutral"),
|
||||
metric("inspectable", "Inspectable", String(snapshot.summary.inspectableTabCount), "neutral"),
|
||||
{
|
||||
id: "loki.chrome.endpoint",
|
||||
id: "loki.chrome.extension-endpoint",
|
||||
kind: "inspector.kv",
|
||||
props: {
|
||||
label: "Endpoint",
|
||||
value: snapshot.cdp.endpoint,
|
||||
tone: statusTone,
|
||||
label: "Extension ingest",
|
||||
value: snapshot.extension.ingestEndpoint,
|
||||
tone: snapshot.extension.reachable ? "ok" : "muted",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
snapshot.cdp.error
|
||||
{
|
||||
id: "loki.chrome.cdp-endpoint",
|
||||
kind: "inspector.kv",
|
||||
props: {
|
||||
label: "CDP endpoint",
|
||||
value: snapshot.cdp.endpoint,
|
||||
tone: snapshot.cdp.reachable ? "ok" : "muted",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
snapshot.cdp.error && !snapshot.extension.reachable
|
||||
? {
|
||||
id: "loki.chrome.error",
|
||||
kind: "text",
|
||||
|
|
@ -308,11 +481,11 @@ function buildEveSurface(snapshot) {
|
|||
children: [],
|
||||
}
|
||||
: {
|
||||
id: "loki.chrome.browser",
|
||||
id: "loki.chrome.source-state",
|
||||
kind: "inspector.kv",
|
||||
props: {
|
||||
label: "Browser",
|
||||
value: snapshot.cdp.browser ?? "unknown",
|
||||
label: "Source state",
|
||||
value: `extension=${snapshot.sources.chromeExtension.state}; cdp=${snapshot.sources.cdp.state}`,
|
||||
tone: "neutral",
|
||||
},
|
||||
children: [],
|
||||
|
|
@ -368,6 +541,10 @@ function stringOrNull(value) {
|
|||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
async function writeTypedDocument(filePath, document) {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.${process.pid}.tmp`;
|
||||
|
|
@ -397,11 +574,107 @@ async function publishSnapshot(config, snapshot) {
|
|||
}
|
||||
|
||||
async function tick(config) {
|
||||
const snapshot = await observeChrome(config);
|
||||
const cdpObservation = await observeChrome(config);
|
||||
const snapshot = buildSnapshot(config, cdpObservation, runtime.extensionObservation);
|
||||
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}`);
|
||||
runtime.lastSnapshot = snapshot;
|
||||
console.log(`${snapshot.observedAt} ${state} source=${snapshot.activeSource} tabs=${count} cdp=${snapshot.sources.cdp.state} extension=${snapshot.sources.chromeExtension.state}`);
|
||||
}
|
||||
|
||||
const runtime = {
|
||||
extensionObservation: null,
|
||||
lastSnapshot: null,
|
||||
};
|
||||
|
||||
function startExtensionIngestServer(config) {
|
||||
const server = createServer(async (request, response) => {
|
||||
setCorsHeaders(response);
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (request.method === "GET" && request.url === "/health") {
|
||||
writeJson(response, 200, {
|
||||
ok: true,
|
||||
providerId,
|
||||
serviceId,
|
||||
ingest: `http://${config.extensionHost}:${config.extensionPort}/ingest/chrome-extension`,
|
||||
lastSnapshotAt: runtime.lastSnapshot?.observedAt ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url === "/state") {
|
||||
writeJson(response, 200, runtime.lastSnapshot ?? unreachableSnapshot(config, "no snapshot published yet"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/ingest/chrome-extension") {
|
||||
const payload = await readJsonBody(request);
|
||||
runtime.extensionObservation = normalizeExtensionObservation(payload, config);
|
||||
const cdpObservation = await observeChrome(config);
|
||||
const snapshot = buildSnapshot(config, cdpObservation, runtime.extensionObservation);
|
||||
await publishSnapshot(config, snapshot);
|
||||
runtime.lastSnapshot = snapshot;
|
||||
writeJson(response, 202, {
|
||||
accepted: true,
|
||||
schema: snapshot.schema,
|
||||
activeSource: snapshot.activeSource,
|
||||
tabs: snapshot.summary.tabCount,
|
||||
observedAt: snapshot.observedAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(response, 404, { error: "not found" });
|
||||
} catch (error) {
|
||||
writeJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(config.extensionPort, config.extensionHost, () => {
|
||||
console.log(`Loki extension ingest listening on http://${config.extensionHost}:${config.extensionPort}`);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
function setCorsHeaders(response) {
|
||||
response.setHeader("access-control-allow-origin", "*");
|
||||
response.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
|
||||
response.setHeader("access-control-allow-headers", "content-type");
|
||||
}
|
||||
|
||||
function writeJson(response, statusCode, body) {
|
||||
response.writeHead(statusCode, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readJsonBody(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 1024 * 1024) {
|
||||
request.destroy();
|
||||
reject(new Error("request body too large"));
|
||||
}
|
||||
});
|
||||
request.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(body || "{}"));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
|
@ -409,6 +682,7 @@ async function main() {
|
|||
await tick(config);
|
||||
if (config.once) return;
|
||||
|
||||
startExtensionIngestServer(config);
|
||||
setInterval(() => {
|
||||
tick(config).catch((error) => {
|
||||
console.error(error);
|
||||
|
|
|
|||
|
|
@ -1,31 +1,49 @@
|
|||
# cultcache-text-witness v0
|
||||
# schema: gamecult.loki.chrome_snapshot.v0
|
||||
# provider: loki.chrome
|
||||
# updatedAt: 2026-06-09T14:02:52.753Z
|
||||
# updatedAt: 2026-06-09T15:11:06.124Z
|
||||
{
|
||||
"schema": "gamecult.loki.chrome_snapshot.v0",
|
||||
"providerId": "loki.chrome",
|
||||
"serviceId": "loki.chrome_daemon",
|
||||
"observedAt": "2026-06-09T14:02:52.753Z",
|
||||
"observedAt": "2026-06-09T15:11:06.124Z",
|
||||
"activeSource": "none",
|
||||
"freshness": {
|
||||
"state": "unreachable",
|
||||
"lastSeenAt": null,
|
||||
"maxAgeMs": 15000
|
||||
},
|
||||
"sources": {
|
||||
"cdp": {
|
||||
"state": "unreachable",
|
||||
"observedAt": "2026-06-09T15:11:06.096Z"
|
||||
},
|
||||
"chromeExtension": {
|
||||
"state": "missing",
|
||||
"observedAt": null
|
||||
}
|
||||
},
|
||||
"cdp": {
|
||||
"endpoint": "http://127.0.0.1:9222",
|
||||
"reachable": false,
|
||||
"error": "fetch failed"
|
||||
},
|
||||
"extension": {
|
||||
"reachable": false,
|
||||
"ingestEndpoint": "http://127.0.0.1:17666/ingest/chrome-extension",
|
||||
"error": "no extension report received"
|
||||
},
|
||||
"tabs": [],
|
||||
"summary": {
|
||||
"tabCount": 0,
|
||||
"inspectableTabCount": 0
|
||||
"inspectableTabCount": 0,
|
||||
"activeTabCount": 0
|
||||
},
|
||||
"redaction": {
|
||||
"urlQuery": "not-observed",
|
||||
"titles": "not-observed",
|
||||
"pageContent": "not-read",
|
||||
"cookies": "not-read"
|
||||
"cookies": "not-read",
|
||||
"source": "none"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
# cultcache-text-witness v0
|
||||
# schema: gamecult.eve.surface.v1
|
||||
# provider: loki.chrome
|
||||
# updatedAt: 2026-06-09T14:02:52.753Z
|
||||
# updatedAt: 2026-06-09T15:11:06.124Z
|
||||
{
|
||||
"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",
|
||||
"version": 1781017866124,
|
||||
"updatedAt": "2026-06-09T15:11:06.124Z",
|
||||
"surface": {
|
||||
"root": {
|
||||
"id": "loki.chrome.root",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
"id": "loki.chrome.summary",
|
||||
"kind": "panel",
|
||||
"props": {
|
||||
"title": "Chrome DevTools",
|
||||
"title": "Chrome Observation",
|
||||
"tone": "warning"
|
||||
},
|
||||
"children": [
|
||||
|
|
@ -41,6 +41,16 @@
|
|||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "loki.chrome.metric.source",
|
||||
"kind": "metric",
|
||||
"props": {
|
||||
"label": "Source",
|
||||
"value": "none",
|
||||
"tone": "warning"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "loki.chrome.metric.tabs",
|
||||
"kind": "metric",
|
||||
|
|
@ -62,12 +72,22 @@
|
|||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "loki.chrome.endpoint",
|
||||
"id": "loki.chrome.extension-endpoint",
|
||||
"kind": "inspector.kv",
|
||||
"props": {
|
||||
"label": "Endpoint",
|
||||
"label": "Extension ingest",
|
||||
"value": "http://127.0.0.1:17666/ingest/chrome-extension",
|
||||
"tone": "muted"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "loki.chrome.cdp-endpoint",
|
||||
"kind": "inspector.kv",
|
||||
"props": {
|
||||
"label": "CDP endpoint",
|
||||
"value": "http://127.0.0.1:9222",
|
||||
"tone": "warning"
|
||||
"tone": "muted"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# cultcache-text-witness v0
|
||||
# schema: gamecult.eve.provider_advertisement.v1
|
||||
# provider: loki.chrome
|
||||
# updatedAt: 2026-06-09T14:02:52.753Z
|
||||
# updatedAt: 2026-06-09T15:11:06.124Z
|
||||
{
|
||||
"schema": "gamecult.eve.provider_advertisement.v1",
|
||||
"providerId": "loki.chrome",
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"cultMeshAddress": "asgard.localhost.loki.chrome/eve/gui",
|
||||
"title": "Loki Chrome",
|
||||
"kind": "service.operator",
|
||||
"updatedAt": "2026-06-09T14:02:52.753Z",
|
||||
"updatedAt": "2026-06-09T15:11:06.124Z",
|
||||
"freshness": {
|
||||
"state": "unreachable",
|
||||
"lastSeenAt": null,
|
||||
|
|
@ -28,6 +28,14 @@
|
|||
"cultMeshAddress": "asgard.localhost.loki.chrome/state/snapshot",
|
||||
"portable": true
|
||||
},
|
||||
{
|
||||
"schema": "gamecult.loki.chrome_extension_report.v0",
|
||||
"owner": "loki.chrome",
|
||||
"authority": "external-authority-projection",
|
||||
"storage": "ingest-only",
|
||||
"cultMeshAddress": "asgard.localhost.loki.chrome/ingest/chrome-extension",
|
||||
"portable": true
|
||||
},
|
||||
{
|
||||
"schema": "gamecult.eve.surface.v1",
|
||||
"owner": "loki.chrome",
|
||||
|
|
@ -49,11 +57,12 @@
|
|||
"urlQuery": "not-observed",
|
||||
"titles": "not-observed",
|
||||
"pageContent": "not-read",
|
||||
"cookies": "not-read"
|
||||
"cookies": "not-read",
|
||||
"source": "none"
|
||||
},
|
||||
"freshness": {
|
||||
"state": "unreachable",
|
||||
"updatedAt": "2026-06-09T14:02:52.753Z"
|
||||
"updatedAt": "2026-06-09T15:11:06.124Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -66,7 +75,7 @@
|
|||
"redaction": "derived-operator-surface",
|
||||
"freshness": {
|
||||
"state": "unreachable",
|
||||
"updatedAt": "2026-06-09T14:02:52.753Z"
|
||||
"updatedAt": "2026-06-09T15:11:06.124Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
@ -93,6 +102,14 @@
|
|||
],
|
||||
"note": "Requires user-launched Chrome with --remote-debugging-port."
|
||||
},
|
||||
{
|
||||
"kind": "chrome-extension-ingest",
|
||||
"address": "http://127.0.0.1:17666/ingest/chrome-extension",
|
||||
"carries": [
|
||||
"chrome-extension-tab-metadata"
|
||||
],
|
||||
"note": "Preferred drop-in Chrome path; extension observes tab metadata and Loki owns persistence."
|
||||
},
|
||||
{
|
||||
"kind": "local-cultcache-witness",
|
||||
"address": "state/",
|
||||
|
|
|
|||
104
test/extension-ingest-smoke.mjs
Normal file
104
test/extension-ingest-smoke.mjs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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 stateDir = await mkdtemp(join(tmpdir(), "loki-extension-smoke-"));
|
||||
const extensionPort = await reservePort();
|
||||
const cdpPort = await reservePort();
|
||||
const daemon = spawn(process.execPath, [
|
||||
"src/loki-daemon.mjs",
|
||||
"--port",
|
||||
String(cdpPort),
|
||||
"--extension-port",
|
||||
String(extensionPort),
|
||||
"--interval-ms",
|
||||
"60000",
|
||||
"--state-dir",
|
||||
stateDir,
|
||||
], {
|
||||
cwd: new URL("..", import.meta.url),
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
daemon.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForHealth(extensionPort);
|
||||
const response = await fetch(`http://127.0.0.1:${extensionPort}/ingest/chrome-extension`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema: "gamecult.loki.chrome_extension_report.v0",
|
||||
extensionVersion: "fixture",
|
||||
browser: "Chrome fixture",
|
||||
reportedAt: new Date().toISOString(),
|
||||
reason: "smoke",
|
||||
tabs: [
|
||||
{
|
||||
id: 100,
|
||||
windowId: 1,
|
||||
index: 0,
|
||||
active: true,
|
||||
highlighted: true,
|
||||
pinned: false,
|
||||
audible: false,
|
||||
muted: false,
|
||||
incognito: false,
|
||||
title: "Portal das Financas",
|
||||
url: "https://sitfiscal.portaldasfinancas.gov.pt/geral/siteMap",
|
||||
favIconUrl: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ingest failed: HTTP ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const snapshotText = await readFile(join(stateDir, "loki.chrome_snapshot.cc"), "utf8");
|
||||
if (!snapshotText.includes('"activeSource": "chrome-extension"')) {
|
||||
throw new Error("snapshot did not choose chrome-extension as active source");
|
||||
}
|
||||
if (!snapshotText.includes("Portal das Financas")) {
|
||||
throw new Error("snapshot did not include extension tab");
|
||||
}
|
||||
|
||||
console.log("extension ingest smoke passed");
|
||||
} finally {
|
||||
daemon.kill();
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function waitForHealth(port) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
throw new Error(`daemon did not become healthy: ${stderr}`);
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function reservePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address();
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in a new issue