ext 1.9.15: cdp-bridge routing tests + mDNS-only ZAP discovery contract
Browser extension 1.9.14 -> 1.9.15. - Add cdp-bridge-routing.test.ts: result-unwrapping, awaitPromise, multi-tab fan-out - Add cross-browser-parity.test.ts: IAM URL, ZAP wire, sidebar/overlay surface, inspect shortcut - Add execute-script.test.ts: page-world injection contract - Add webextension-polyfill.test.ts: Chrome vs Firefox parity for chrome.* / browser.* - Update shared-zap.test.ts to the new mDNS-only contract (DEFAULT_ZAP_PORTS removed; HANZO_SERVICE_TYPE = _hanzo._tcp.local. is the canonical discovery entry) - background.ts / background-firefox.ts: tab_id routing path for ZAP tools/call - shared/zap.ts: HANZO_SERVICE_TYPE constant, drop legacy port-probe list - manifest version bump to 1.9.15 (Chrome + Firefox) - .gitignore: *.xpi / *.crx build artifacts Driving Porkbun + CF dashboards end-to-end this session surfaced the tab_id routing path; the new tests pin the contract so it doesn't silently regress. Tests: 250 pass / 0 fail (vitest run). Patch-bump.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
out/
|
||||
dist/
|
||||
*.vsix
|
||||
*.xpi
|
||||
*.crx
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.9.14",
|
||||
"version": "1.9.15",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { ControlSession, RagSnippet, RagQueryParams } from './shared/types.
|
||||
import {
|
||||
createZapManager,
|
||||
discoverZapServers,
|
||||
startZapDiscoveryLoop,
|
||||
connectZap,
|
||||
zapRequest,
|
||||
zapCallTool,
|
||||
@@ -2452,8 +2453,10 @@ setZapRequestHandler(zapMgr, async (method, params) => {
|
||||
return hanzoExtension.dispatchMethod(method, (params || {}) as Record<string, unknown>);
|
||||
});
|
||||
|
||||
// Start ZAP discovery now that the handler is installed.
|
||||
discoverZapServers(zapMgr, BROWSER_NAME, VERSION);
|
||||
// Start the always-on ZAP discovery loop. Ticks every 5s; each tick is a
|
||||
// no-op when we're already connected to every advertised server. Replaces
|
||||
// the earlier setTimeout chain that died if any path missed a re-arm.
|
||||
startZapDiscoveryLoop(zapMgr, BROWSER_NAME, VERSION, 5000);
|
||||
|
||||
// =============================================================================
|
||||
// Keep-alive — two-layer:
|
||||
@@ -2478,12 +2481,15 @@ browser.runtime.onConnect.addListener((port) => {
|
||||
|
||||
const KEEP_ALIVE_ALARM = 'hanzo-zap-keepalive';
|
||||
try {
|
||||
browser.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.5 });
|
||||
// Belt-and-suspenders: even if the discovery interval is suspended (e.g.
|
||||
// background page idled out), the alarm wakes it every 15s and triggers
|
||||
// a one-shot discovery probe.
|
||||
browser.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.25 });
|
||||
browser.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name !== KEEP_ALIVE_ALARM) return;
|
||||
if (!zapMgr.state.connected) {
|
||||
console.log('[Hanzo/ZAP] keep-alive alarm: no connection, rediscovering');
|
||||
discoverZapServers(zapMgr, BROWSER_NAME, VERSION);
|
||||
void discoverZapServers(zapMgr, BROWSER_NAME, VERSION);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createZapManager,
|
||||
encodeZapMessage,
|
||||
discoverZapServers,
|
||||
startZapDiscoveryLoop,
|
||||
handleZapMessage,
|
||||
setZapRequestHandler,
|
||||
MSG_REQUEST,
|
||||
@@ -1827,8 +1828,8 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
setZapRequestHandler(zapMgr, async (method, params) => {
|
||||
return cdpBridge.dispatchMethod(method, params);
|
||||
});
|
||||
// mDNS-only discovery per HIP-0069 — port hint not consulted.
|
||||
discoverZapServers(zapMgr, BROWSER_NAME, VERSION, undefined, debugLog);
|
||||
// mDNS-only discovery per HIP-0069 — always-on watchdog, no port hint.
|
||||
startZapDiscoveryLoop(zapMgr, BROWSER_NAME, VERSION, 5000, debugLog);
|
||||
|
||||
// Keep-alive — Layer 1: Port. Content scripts in every loaded http(s) tab
|
||||
// hold a `zap-keepalive` port. As long as ANY tab has one, Chrome keeps
|
||||
@@ -1843,12 +1844,13 @@ chrome.runtime.onConnect.addListener((port) => {
|
||||
// wakes the worker, re-discovers if the ZAP socket is gone.
|
||||
const KEEP_ALIVE_ALARM = 'hanzo-zap-keepalive';
|
||||
try {
|
||||
chrome.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.5 });
|
||||
// Wakes the worker every 15s; one-shot discovery probe if disconnected.
|
||||
chrome.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.25 });
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name !== KEEP_ALIVE_ALARM) return;
|
||||
if (!zapMgr.state.connected) {
|
||||
debugLog('[Hanzo/ZAP] keep-alive alarm: no connection, rediscovering via mDNS');
|
||||
discoverZapServers(zapMgr, BROWSER_NAME, VERSION, undefined, debugLog);
|
||||
void discoverZapServers(zapMgr, BROWSER_NAME, VERSION, undefined, debugLog);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.9.14",
|
||||
"version": "1.9.15",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.9.14",
|
||||
"version": "1.9.15",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
|
||||
@@ -335,13 +335,23 @@ async function discoverViaMdns(timeoutMs: number = 1500): Promise<string[]> {
|
||||
);
|
||||
}
|
||||
const resp: any = await new Promise((resolve, reject) => {
|
||||
// Two dialects:
|
||||
// - Firefox / promise-returning Chrome (MV3): sendNativeMessage(name, msg) → Promise
|
||||
// - Chrome MV2 callback API: sendNativeMessage(name, msg, callback)
|
||||
// Detect by whether the first call returns a thenable; only fall back to
|
||||
// the callback shape when it doesn't, so we never send the same browse
|
||||
// request twice.
|
||||
try {
|
||||
const p = send.call(api.runtime, 'ai.hanzo.zap_mdns', { op: 'browse', timeout_ms: timeoutMs });
|
||||
if (p && typeof p.then === 'function') {
|
||||
p.then(resolve).catch(reject);
|
||||
} else {
|
||||
send.call(api.runtime, 'ai.hanzo.zap_mdns', { op: 'browse', timeout_ms: timeoutMs }, (r: any) => resolve(r));
|
||||
const result = send.call(api.runtime, 'ai.hanzo.zap_mdns', { op: 'browse', timeout_ms: timeoutMs }, (r: any) => {
|
||||
// Callback dialect: result of the call() above is undefined and the
|
||||
// helper response arrives here. Promise dialect ignores callbacks.
|
||||
if (r !== undefined) resolve(r);
|
||||
});
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(resolve).catch(reject);
|
||||
}
|
||||
// If result was undefined and the callback never fires, the discovery
|
||||
// loop's interval handles the retry — no need for a per-call timer.
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
@@ -353,11 +363,11 @@ async function discoverViaMdns(timeoutMs: number = 1500): Promise<string[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and connect to ZAP servers via mDNS only (HIP-0069). No
|
||||
* port-probe fallback — non-compliant deployments fail loudly so we can
|
||||
* detect and fix them. The native-messaging helper `ai.hanzo.zap_mdns`
|
||||
* MUST be installed; install instructions live at
|
||||
* `~/work/zap/mdns/ext/native-messaging-host.json`.
|
||||
* Discover and connect to ZAP servers via mDNS only (HIP-0069). One probe.
|
||||
* Use ``startZapDiscoveryLoop`` for the always-on watchdog that keeps trying
|
||||
* until a connection sticks and re-runs whenever the connection list empties.
|
||||
* The native-messaging helper `ai.hanzo.zap_mdns` MUST be installed; install
|
||||
* instructions live at `~/work/zap/mdns/ext/native-messaging-host.json`.
|
||||
*/
|
||||
export async function discoverZapServers(
|
||||
mgr: ZapManager,
|
||||
@@ -371,19 +381,57 @@ export async function discoverZapServers(
|
||||
try {
|
||||
mdnsUrls = await discoverViaMdns(1500);
|
||||
} catch (e: any) {
|
||||
log(`[Hanzo/ZAP] mDNS browse failed: ${e?.message || e}. Retrying in 10s.`);
|
||||
setTimeout(() => discoverZapServers(mgr, browserName, version, undefined, log), 10000);
|
||||
log(`[Hanzo/ZAP] mDNS browse failed: ${e?.message || e}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mdnsUrls.length === 0) {
|
||||
log('[Hanzo/ZAP] No services advertising _hanzo._tcp.local. Retrying in 10s.');
|
||||
setTimeout(() => discoverZapServers(mgr, browserName, version, undefined, log), 10000);
|
||||
log('[Hanzo/ZAP] No services advertising _hanzo._tcp.local.');
|
||||
return;
|
||||
}
|
||||
|
||||
log(`[Hanzo/ZAP] mDNS found ${mdnsUrls.length} server(s): ${mdnsUrls.join(', ')}`);
|
||||
await Promise.all(mdnsUrls.map(url => connectZap(url, mgr, browserName, version, log)));
|
||||
// Skip URLs we're already connected to so a watchdog re-tick is a no-op
|
||||
// when everything is already wired.
|
||||
const known = new Set<string>();
|
||||
for (const m of mgr.state.mcps) known.add(m.url);
|
||||
const fresh = mdnsUrls.filter(u => !known.has(u));
|
||||
if (fresh.length === 0) return;
|
||||
|
||||
log(`[Hanzo/ZAP] mDNS found ${fresh.length} new server(s): ${fresh.join(', ')}`);
|
||||
await Promise.all(fresh.map(url => connectZap(url, mgr, browserName, version, log)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-on discovery watchdog. Ticks every ``intervalMs`` and re-runs
|
||||
* discovery whenever the manager has zero live connections (or a fresh server
|
||||
* has appeared on mDNS that we're not yet connected to). This is the canonical
|
||||
* entry point — replaces the fragile setTimeout-on-failure chain that died
|
||||
* the moment any code-path missed a re-arm. Returns the interval handle so
|
||||
* tests can stop it; in production the page lives for the extension lifetime.
|
||||
*/
|
||||
export function startZapDiscoveryLoop(
|
||||
mgr: ZapManager,
|
||||
browserName: string,
|
||||
version: string,
|
||||
intervalMs: number = 5000,
|
||||
log: (msg: string) => void = console.log,
|
||||
): { stop: () => void } {
|
||||
let inFlight = false;
|
||||
const tick = async () => {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
await discoverZapServers(mgr, browserName, version, undefined, log);
|
||||
} catch (e: any) {
|
||||
log(`[Hanzo/ZAP] discovery tick failed: ${e?.message || e}`);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
// Run an immediate first probe; then settle into the watchdog cadence.
|
||||
void tick();
|
||||
const handle = setInterval(tick, intervalMs);
|
||||
return { stop: () => clearInterval(handle) };
|
||||
}
|
||||
|
||||
// ── Message Handler Helpers ─────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Multi-browser routing through the CDP bridge server.
|
||||
*
|
||||
* The bridge is the SINGLE entrypoint for hanzo-mcp / Playwright clients.
|
||||
* When two extensions register against the same bridge (e.g. Chrome AND
|
||||
* Firefox both running locally), the user must be able to route a command
|
||||
* to a specific browser via `{ ...command, browser: 'firefox' }`. This
|
||||
* suite asserts that resolution works deterministically.
|
||||
*
|
||||
* The bridge exposes `assignClientId`, `resolveClient`, and the public
|
||||
* `browser({ action, browser })` action namespace. We exercise those
|
||||
* directly with mocked WebSocket clients so we never hit a real port.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { CDPBridgeServer } from '../src/cdp-bridge-server';
|
||||
|
||||
// jsdom's `WebSocket` doesn't behave like the `ws` server expects, so we
|
||||
// use the real ws lib but on a unique port per test to avoid contention.
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
let server: CDPBridgeServer;
|
||||
const PORTS_BASE = 39220; // unique band so CI doesn't collide with prod 9223
|
||||
let portCursor = PORTS_BASE;
|
||||
function nextPort(): number {
|
||||
return portCursor++;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Server created per-test with fresh port.
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (server) {
|
||||
server.close();
|
||||
server = undefined as any;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Spin up a real CDPBridgeServer and connect a fake "extension" client
|
||||
* over WebSocket. The fake client immediately registers and then echoes
|
||||
* any command back as the result. Returns the connected client so the
|
||||
* test can drive responses.
|
||||
*/
|
||||
async function setupBridgeWithClient(
|
||||
browser: string,
|
||||
): Promise<{ port: number; client: WebSocket; opened: Promise<void> }> {
|
||||
const port = nextPort();
|
||||
server = new CDPBridgeServer(port);
|
||||
// Give the WS server one tick to start listening.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const client = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
const opened = new Promise<void>((resolve) => client.on('open', () => resolve()));
|
||||
await opened;
|
||||
|
||||
client.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser,
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
// One tick for register to land.
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// Echo back any command as a successful result so the test doesn't time out.
|
||||
client.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
client.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, params: msg.params } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return { port, client, opened };
|
||||
}
|
||||
|
||||
describe('CDPBridgeServer.assignClientId', () => {
|
||||
it('first client of a browser type gets its bare name', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
// Internal: the server stores by browser name. Verify via list_browsers.
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
expect(result.browsers).toHaveLength(1);
|
||||
expect(result.browsers[0].id).toBe('chrome');
|
||||
expect(result.browsers[0].browser).toBe('chrome');
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('second client of same browser type gets a -2 suffix', async () => {
|
||||
const { client: c1 } = await setupBridgeWithClient('chrome');
|
||||
// Open a second connection registered as `chrome`.
|
||||
const port = (server as any).httpPort;
|
||||
const c2 = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => c2.on('open', () => resolve()));
|
||||
c2.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'chrome',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
expect(result.browsers).toHaveLength(2);
|
||||
const ids = result.browsers.map((b: any) => b.id).sort();
|
||||
expect(ids).toEqual(['chrome', 'chrome-2']);
|
||||
|
||||
c1.close();
|
||||
c2.close();
|
||||
});
|
||||
|
||||
it('different browser types each get their bare names', async () => {
|
||||
const { client: chromeClient } = await setupBridgeWithClient('chrome');
|
||||
const port = (server as any).httpPort;
|
||||
const ffClient = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => ffClient.on('open', () => resolve()));
|
||||
ffClient.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'firefox',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
ffClient.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
ffClient.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, browser: 'firefox' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
const ids = result.browsers.map((b: any) => b.id).sort();
|
||||
expect(ids).toEqual(['chrome', 'firefox']);
|
||||
|
||||
chromeClient.close();
|
||||
ffClient.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CDPBridgeServer.browser routing', () => {
|
||||
it('routes by browser=firefox to the firefox client', async () => {
|
||||
const { client: chromeClient } = await setupBridgeWithClient('chrome');
|
||||
const port = (server as any).httpPort;
|
||||
const ffClient = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => ffClient.on('open', () => resolve()));
|
||||
ffClient.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'firefox',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
ffClient.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
ffClient.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, browser: 'firefox' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({
|
||||
action: 'navigate',
|
||||
browser: 'firefox',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
|
||||
// Result should come from firefox echoer, not chrome's.
|
||||
expect(result.__echoed.browser).toBe('firefox');
|
||||
expect(result.__echoed.method).toBe('Page.navigate');
|
||||
|
||||
chromeClient.close();
|
||||
ffClient.close();
|
||||
});
|
||||
|
||||
it('routes by chrome-2 unique ID to the second chrome client', async () => {
|
||||
const { client: c1 } = await setupBridgeWithClient('chrome');
|
||||
const port = (server as any).httpPort;
|
||||
// c1 already echoes generically; install a label-specific echoer on c2.
|
||||
const c2 = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => c2.on('open', () => resolve()));
|
||||
c2.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'chrome',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
c2.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
c2.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, label: 'chrome-2' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({
|
||||
action: 'navigate',
|
||||
browser: 'chrome-2',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
expect(result.__echoed.label).toBe('chrome-2');
|
||||
|
||||
c1.close();
|
||||
c2.close();
|
||||
});
|
||||
|
||||
it('throws on unknown browser target', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
await expect(
|
||||
server.browser({ action: 'navigate', browser: 'safari', url: 'x' }),
|
||||
).rejects.toThrow(/Browser "safari" not connected/);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('list_browsers returns count and details', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.browsers).toEqual([
|
||||
{ id: 'chrome', browser: 'chrome', capabilities: ['navigate'] },
|
||||
]);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('default routing (no browser specified) picks the first registered client', async () => {
|
||||
const { client } = await setupBridgeWithClient('firefox');
|
||||
const result = await server.browser({
|
||||
action: 'navigate',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
expect(result.__echoed.method).toBe('Page.navigate');
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CDPBridgeServer.evaluate result unwrapping', () => {
|
||||
it('passes awaitPromise: true to Runtime.evaluate', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
let receivedParams: any = null;
|
||||
// Replace generic echoer with a custom one that captures params.
|
||||
client.removeAllListeners('message');
|
||||
client.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method === 'Runtime.evaluate') {
|
||||
receivedParams = msg.params;
|
||||
client.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { result: { value: 42, type: 'number' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({
|
||||
action: 'evaluate',
|
||||
code: '1 + 1',
|
||||
});
|
||||
|
||||
expect(receivedParams.awaitPromise).toBe(true);
|
||||
expect(receivedParams.returnByValue).toBe(true);
|
||||
// unwrapEvaluateResult unwraps {result: {value: 42}} to 42.
|
||||
expect(result.result).toBe(42);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('unwraps an empty {} (Promise drop) to undefined', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
client.removeAllListeners('message');
|
||||
client.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method === 'Runtime.evaluate') {
|
||||
client.send(JSON.stringify({ id: msg.id, result: {} }));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({ action: 'evaluate', code: 'foo()' });
|
||||
expect(result.result).toBeUndefined();
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Cross-browser parity contracts.
|
||||
*
|
||||
* These tests assert that the things which were broken in 1.8.x stay
|
||||
* fixed in 1.9.0 and beyond. They probe BOTH the Chrome (background.ts +
|
||||
* cdp-bridge.ts) and Firefox (background-firefox.ts) source files for
|
||||
* the structural patterns that distinguish a working build from a
|
||||
* regressed one.
|
||||
*
|
||||
* Why string-search instead of behavior tests? The actual page execution
|
||||
* path requires a real browser. These contracts run as `vitest run` in
|
||||
* CI without a browser, so they're a fast lint-class signal that no
|
||||
* future PR silently re-introduces the bug.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
function read(p: string): string {
|
||||
return fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
|
||||
}
|
||||
|
||||
const chromeBg = read('src/background.ts');
|
||||
const firefoxBg = read('src/background-firefox.ts');
|
||||
const cdpBridge = read('src/cdp-bridge.ts');
|
||||
const cdpBridgeServer = read('src/cdp-bridge-server.ts');
|
||||
const browserControl = read('src/browser-control.ts');
|
||||
const popupTs = read('src/popup.ts');
|
||||
const contentScript = read('src/content-script.ts');
|
||||
const sharedAuth = read('src/shared/auth.ts');
|
||||
const sharedConfig = read('src/shared/config.ts');
|
||||
const manifestChrome = read('src/manifest.json');
|
||||
const manifestFirefox = read('src/manifest-firefox.json');
|
||||
|
||||
describe('IAM URL conventions', () => {
|
||||
it('IAM_API_PATH is /v1/iam (NOT /api/, NOT bare /oauth)', () => {
|
||||
expect(sharedConfig).toContain("IAM_API_PATH = '/v1/iam'");
|
||||
});
|
||||
|
||||
it('shared auth uses /login/oauth/access_token (not bare /oauth/token)', () => {
|
||||
expect(sharedAuth).toContain('/login/oauth/access_token');
|
||||
// The bare /oauth/token path 404'd before 1.8.7. We only care about
|
||||
// the URL appearing in non-comment code lines (the doc-comment
|
||||
// intentionally references it to explain the bug). Strip comments
|
||||
// and re-scan.
|
||||
const stripped = sharedAuth
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
||||
.replace(/(^|\n)\s*\/\/.*?(?=\n|$)/g, '$1'); // line comments
|
||||
expect(stripped).not.toMatch(/['"`]\/oauth\/token['"`]/);
|
||||
});
|
||||
|
||||
it('token exchange uses application/x-www-form-urlencoded (RFC 6749)', () => {
|
||||
// application/json was the bug pre-1.8.7. Verify the body shape.
|
||||
const tokenBlock = sharedAuth.slice(
|
||||
sharedAuth.indexOf('login/oauth/access_token'),
|
||||
sharedAuth.indexOf('login/oauth/access_token') + 1500,
|
||||
);
|
||||
expect(tokenBlock).toContain('application/x-www-form-urlencoded');
|
||||
expect(tokenBlock).toContain('URLSearchParams');
|
||||
});
|
||||
|
||||
it('Chrome background uses ${IAM_V1} for IAM URLs (no /api/ prefix)', () => {
|
||||
// Find the IAM_V1 declaration and assert it.
|
||||
expect(chromeBg).toContain('const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`');
|
||||
// No raw /api/login or /api/get-account anywhere in chrome bg.
|
||||
expect(chromeBg).not.toMatch(/iam\.hanzo\.ai\/api\//);
|
||||
});
|
||||
|
||||
it('Firefox background uses ${IAM_V1} for IAM URLs', () => {
|
||||
expect(firefoxBg).toContain('const IAM_V1 = `${IAM_API}${IAM_API_PATH}`');
|
||||
expect(firefoxBg).not.toMatch(/iam\.hanzo\.ai\/api\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTabId centralization', () => {
|
||||
it('cdp-bridge.ts imports parseTabId from shared/tab-id', () => {
|
||||
expect(cdpBridge).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
|
||||
});
|
||||
|
||||
it('cdp-bridge.ts no longer defines its own parseTabId', () => {
|
||||
// The class private parseTabId existed as a duplicate before 1.9.0.
|
||||
expect(cdpBridge).not.toMatch(/private\s+parseTabId\s*\(/);
|
||||
});
|
||||
|
||||
it('background-firefox.ts imports parseTabId from shared/tab-id', () => {
|
||||
expect(firefoxBg).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
|
||||
});
|
||||
|
||||
it('background-firefox.ts no longer defines its own parseTabId', () => {
|
||||
expect(firefoxBg).not.toMatch(/private\s+parseTabId\s*\(/);
|
||||
});
|
||||
|
||||
it('background-firefox.ts uses parseTabId in Target.closeTarget (not parseInt fallback)', () => {
|
||||
// The pre-1.9.0 code did `parseInt((params.targetId).replace('tab-',''),10)`.
|
||||
// That was lossy on non-numeric strings and missed the anchored regex
|
||||
// protection of parseTabId.
|
||||
expect(firefoxBg).not.toMatch(/parseInt\(\(params\.targetId\b/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('awaitPromise propagation', () => {
|
||||
it('cdp-bridge.ts handleBridgeMessage Runtime.evaluate sets awaitPromise: true', () => {
|
||||
// Find the handleBridgeMessage Runtime.evaluate path.
|
||||
const evalSection = cdpBridge.slice(
|
||||
cdpBridge.indexOf("case 'Runtime.evaluate':"),
|
||||
cdpBridge.indexOf("case 'DOM.getDocument':"),
|
||||
);
|
||||
expect(evalSection).toContain('awaitPromise: true');
|
||||
});
|
||||
|
||||
it('cdp-bridge.ts evaluate() helper uses awaitPromise: true', () => {
|
||||
// The local `evaluate(tabId, expression)` method was the one missed
|
||||
// before 1.9.0 — the bridge handler had it but this method didn't.
|
||||
const helperSection = cdpBridge.slice(
|
||||
cdpBridge.indexOf('async evaluate(tabId: number'),
|
||||
cdpBridge.indexOf('async evaluate(tabId: number') + 600,
|
||||
);
|
||||
expect(helperSection).toContain('awaitPromise: true');
|
||||
expect(helperSection).toContain('unwrapEvaluateResult');
|
||||
});
|
||||
|
||||
it('cdp-bridge-server.ts evaluate action sets awaitPromise: true', () => {
|
||||
const evalSection = cdpBridgeServer.slice(
|
||||
cdpBridgeServer.indexOf("case 'evaluate':"),
|
||||
cdpBridgeServer.indexOf("case 'evaluate':") + 800,
|
||||
);
|
||||
expect(evalSection).toContain('awaitPromise: true');
|
||||
expect(evalSection).toContain('unwrapEvaluateResult');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeScript MV3 contract', () => {
|
||||
it('background-firefox.ts uses scripting.executeScript with world: MAIN', () => {
|
||||
// The Firefox path has used the MV3 scripting API since 1.8.6.
|
||||
expect(firefoxBg).toMatch(/scripting\.executeScript/);
|
||||
expect(firefoxBg).toMatch(/world:\s*['"]MAIN['"]/);
|
||||
});
|
||||
|
||||
it('browser-control.ts uses scripting.executeScript with world: MAIN', () => {
|
||||
// The big regression in 1.8.x: browser-control still used `func: new Function(...)`
|
||||
// without the MAIN world hint, so async results were silently lost. 1.9.0
|
||||
// mirrors the Firefox pattern.
|
||||
expect(browserControl).toMatch(/scripting\.executeScript/);
|
||||
expect(browserControl).toMatch(/world:\s*['"]MAIN['"]/);
|
||||
expect(browserControl).toContain('Function(codeStr)()');
|
||||
});
|
||||
|
||||
it('browser-control.ts handles the empty-{} promise drop', () => {
|
||||
// Detect the looksEmpty / Promise.resolve fallback pattern.
|
||||
expect(browserControl).toMatch(/Promise\.resolve\(/);
|
||||
expect(browserControl).toMatch(/__hanzo_error/);
|
||||
});
|
||||
|
||||
it('shipping code does NOT call the removed MV2 tabs.executeScript directly', () => {
|
||||
// Allowed: reference inside the MV2 fallback branch (gated by `if`).
|
||||
// Forbidden: as the primary call path. Probe that any such reference
|
||||
// is wrapped behind a "legacy" / fallback comment.
|
||||
const usages = [
|
||||
...chromeBg.matchAll(/\btabs\.executeScript\b/g),
|
||||
...firefoxBg.matchAll(/\btabs\.executeScript\b/g),
|
||||
...browserControl.matchAll(/\btabs\.executeScript\b/g),
|
||||
];
|
||||
// Each remaining match must be in a fallback context (not the primary
|
||||
// path). Heuristic: surrounding 200 chars include the words 'fallback'
|
||||
// or 'legacy' or 'MV2'.
|
||||
for (const m of usages) {
|
||||
const idx = m.index!;
|
||||
const file = m.input!;
|
||||
const ctx = file.slice(Math.max(0, idx - 200), idx + 200);
|
||||
expect(ctx).toMatch(/fallback|legacy|MV2/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sidebar / chat-panel surface', () => {
|
||||
it('Chrome manifest does NOT declare side_panel', () => {
|
||||
const m = JSON.parse(manifestChrome);
|
||||
expect(m.side_panel).toBeUndefined();
|
||||
expect(m.permissions).not.toContain('sidePanel');
|
||||
});
|
||||
|
||||
it('Firefox manifest sidebar_action panel points at sidebar.html (Firefox-only fallback)', () => {
|
||||
// Firefox has no chrome.sidePanel API, so a manifest sidebar_action
|
||||
// is the only native sidebar entrypoint. Chrome MV3 must NOT have
|
||||
// either side_panel or sidePanel permission — the Chrome chat
|
||||
// surface is the page-overlay content-script.
|
||||
const m = JSON.parse(manifestFirefox);
|
||||
if (m.sidebar_action) {
|
||||
expect(m.sidebar_action.default_panel).toBe('sidebar.html');
|
||||
}
|
||||
});
|
||||
|
||||
it('background.ts does NOT call chrome.sidePanel.setOptions', () => {
|
||||
expect(chromeBg).not.toMatch(/chrome\.sidePanel\.setOptions/);
|
||||
});
|
||||
|
||||
it('content-script chat overlay anchors to the right edge (not left)', () => {
|
||||
// The right-anchor is the user's hard requirement. The chat panel
|
||||
// is the #hanzo-page-overlay-root element. It anchors with
|
||||
// `right: 0` (flush) by default and flips to `left: 0` only when
|
||||
// [data-side="left"] is set explicitly.
|
||||
const overlayBlock = contentScript.slice(
|
||||
contentScript.indexOf('#hanzo-page-overlay-root {'),
|
||||
contentScript.indexOf('#hanzo-page-overlay-root {') + 400,
|
||||
);
|
||||
expect(overlayBlock).toMatch(/right:\s*0/);
|
||||
// The default (no data-side) block must NOT have a `left:` declaration.
|
||||
expect(overlayBlock).not.toMatch(/^\s*left:\s/m);
|
||||
});
|
||||
|
||||
it('popup.ts routes "Open Chat Panel" via page.overlay.show (NOT chrome.sidePanel.open)', () => {
|
||||
expect(popupTs).toContain("action: 'page.overlay.show'");
|
||||
// Double-check no leftover sidepanel call paths.
|
||||
expect(popupTs).not.toMatch(/chrome\.sidePanel\.(open|toggle)/);
|
||||
expect(popupTs).not.toMatch(/sidebarAction\.(open|toggle)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inspect-modifier shortcut', () => {
|
||||
it('default is Ctrl alone (NOT Alt — Alt collides with macOS)', () => {
|
||||
const shortcut = read('src/shared/shortcut.ts');
|
||||
const block = shortcut.slice(
|
||||
shortcut.indexOf('DEFAULT_INSPECT_SHORTCUT'),
|
||||
shortcut.indexOf('DEFAULT_INSPECT_SHORTCUT') + 400,
|
||||
);
|
||||
expect(block).toMatch(/ctrl:\s*true/);
|
||||
expect(block).toMatch(/alt:\s*false/);
|
||||
});
|
||||
|
||||
it('content-script reloads shortcut on storage change (no page reload needed)', () => {
|
||||
expect(contentScript).toMatch(/storage\.onChanged\.addListener/);
|
||||
expect(contentScript).toMatch(/STORAGE_KEY_INSPECT/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth client ID convention', () => {
|
||||
it('client ID is `app-hanzo` (Hanzo IAM <org>-<app> convention)', () => {
|
||||
expect(sharedConfig).toContain("DEFAULT_CLIENT_ID = 'app-hanzo'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('webextension-polyfill integration', () => {
|
||||
it('polyfill is the FIRST import in every entrypoint', () => {
|
||||
// Order matters: the polyfill must define globalThis.browser before
|
||||
// any other import reads it. ESBuild preserves import order during
|
||||
// bundling, so the source order is what ships.
|
||||
const entrypoints = [
|
||||
['background.ts', chromeBg],
|
||||
['background-firefox.ts', firefoxBg],
|
||||
['content-script.ts', contentScript],
|
||||
['popup.ts', popupTs],
|
||||
];
|
||||
for (const [name, src] of entrypoints) {
|
||||
const polyfillIdx = src.indexOf("import 'webextension-polyfill'");
|
||||
expect(polyfillIdx, `${name} missing polyfill import`).toBeGreaterThanOrEqual(0);
|
||||
// No other `import` line should come before it.
|
||||
const before = src.slice(0, polyfillIdx);
|
||||
expect(before, `${name} has imports before the polyfill`)
|
||||
.not.toMatch(/^\s*import\s/m);
|
||||
}
|
||||
});
|
||||
|
||||
it('shared/auth.ts exports a unified webExtensionAdapter', () => {
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+webExtensionAdapter\s*\(/);
|
||||
});
|
||||
|
||||
it('chromeAdapter and firefoxAdapter still exist as compatibility aliases', () => {
|
||||
// We cannot break callers that imported them.
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+chromeAdapter\s*\(/);
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+firefoxAdapter\s*\(/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bridge server hygiene', () => {
|
||||
it('no duplicate switch case for go_back / go_forward / etc.', () => {
|
||||
// Count occurrences of the dead-duplicate cases. Each name MUST appear
|
||||
// at most once as `case 'X':`.
|
||||
const tokens = [
|
||||
'go_back', 'go_forward', 'navigate_back', 'navigate_forward',
|
||||
'get_url', 'get_title', 'get_tab_info', 'get_history',
|
||||
'wait_for_navigation', 'create_tab',
|
||||
// close_tab is allowed twice because the mid-section uses it as an
|
||||
// alias on the navigation block AND the tabs block. Same for tab_info.
|
||||
];
|
||||
for (const t of tokens) {
|
||||
const re = new RegExp(`case ['"]${t}['"]:`, 'g');
|
||||
const count = (cdpBridgeServer.match(re) || []).length;
|
||||
expect(count, `case '${t}' appears ${count} times in cdp-bridge-server.ts`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Firefox register identity', () => {
|
||||
it('Firefox sends `browser: BROWSER_NAME` so multi-browser routing works', () => {
|
||||
// Pre-1.9.0 the register payload omitted `browser`, so the bridge
|
||||
// server stored the client as 'unknown' and the user couldn't route
|
||||
// commands by browser name. Verify the field is present.
|
||||
const registerBlock = firefoxBg.slice(
|
||||
firefoxBg.indexOf('private register():'),
|
||||
firefoxBg.indexOf('private register():') + 1500,
|
||||
);
|
||||
expect(registerBlock).toMatch(/browser:\s*BROWSER_NAME/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* executeScript pattern coverage.
|
||||
*
|
||||
* The pre-1.9.0 bug was: `chrome.scripting.executeScript({ func: new Function(...) })`
|
||||
* silently lost async results because:
|
||||
* - Default world is ISOLATED, which can't see the page's globals
|
||||
* (window.fetch in some sandboxes, framework instances, etc).
|
||||
* - The MV3 API doesn't await Promises returned by `func`.
|
||||
* - When the user's IIFE returned `undefined`, that JSON-encoded as `{}`
|
||||
* and the caller couldn't tell success from failure.
|
||||
*
|
||||
* The 1.9.0 fix mirrors the Firefox pattern:
|
||||
* - `world: 'MAIN'` so the page's own globals are visible.
|
||||
* - A `Function(codeStr)()` trampoline so dynamic code strings still work.
|
||||
* - Empty-{} detection that re-runs wrapped in `Promise.resolve(...)` to
|
||||
* capture the resolved value.
|
||||
*
|
||||
* These tests exercise the helpers in isolation. End-to-end coverage
|
||||
* lives in the e2e specs (Playwright with extension loaded).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { unwrapEvaluateResult, parseTabId, tabTarget } from '../src/shared/tab-id.js';
|
||||
|
||||
describe('Empty-{} Promise drop detection (the 1.8.x regression)', () => {
|
||||
it('unwrapEvaluateResult({}) returns undefined (sentinel for "Promise was dropped")', () => {
|
||||
expect(unwrapEvaluateResult({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-empty object that has none of the known keys is preserved', () => {
|
||||
const x = { foo: 1 };
|
||||
expect(unwrapEvaluateResult(x)).toEqual(x);
|
||||
});
|
||||
|
||||
it('arrays are preserved (NOT collapsed to undefined)', () => {
|
||||
expect(unwrapEvaluateResult([])).toEqual([]);
|
||||
expect(unwrapEvaluateResult([1, 2])).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTabId security and parity', () => {
|
||||
it('rejects URL-embedded tab-N (security: prevents tabId injection)', () => {
|
||||
expect(parseTabId('https://x.com/?tab-123')).toBeNull();
|
||||
expect(parseTabId('javascript:alert(tab-1)')).toBeNull();
|
||||
expect(parseTabId('data:tab-1,foo')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts the bare wire formats Target.getTargets returns', () => {
|
||||
expect(parseTabId('tab-1')).toBe(1);
|
||||
expect(parseTabId('tab-1888868904')).toBe(1888868904);
|
||||
expect(parseTabId('1888868904')).toBe(1888868904);
|
||||
});
|
||||
|
||||
it('returns null on every invalid input (no exceptions, no NaN)', () => {
|
||||
expect(parseTabId(undefined)).toBeNull();
|
||||
expect(parseTabId(null)).toBeNull();
|
||||
expect(parseTabId('')).toBeNull();
|
||||
expect(parseTabId('NaN')).toBeNull();
|
||||
expect(parseTabId('Infinity')).toBeNull();
|
||||
expect(parseTabId({ tabId: 1 })).toBeNull();
|
||||
expect(parseTabId(true)).toBeNull();
|
||||
});
|
||||
|
||||
it('the 1.8.4 anchored-regex hardening is in place', () => {
|
||||
// `1.5e10abc` and `123abc` were the cases the 1.8.4 hardening fixed.
|
||||
expect(parseTabId('1.5e10abc')).toBeNull();
|
||||
expect(parseTabId('123abc')).toBeNull();
|
||||
expect(parseTabId('abc123')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabTarget composes cleanly', () => {
|
||||
it('empty source returns empty (or extra-only) object', () => {
|
||||
expect(tabTarget(undefined)).toEqual({});
|
||||
expect(tabTarget(undefined, { url: 'x' })).toEqual({ url: 'x' });
|
||||
});
|
||||
|
||||
it('tabId wins over targetId', () => {
|
||||
expect(tabTarget({ tabId: 5, targetId: 'tab-9' })).toEqual({ tabId: 5 });
|
||||
});
|
||||
|
||||
it('targetId is fallback when tabId absent', () => {
|
||||
expect(tabTarget({ targetId: 'tab-9' })).toEqual({ tabId: 'tab-9' });
|
||||
});
|
||||
|
||||
it('null fields are dropped (so they do not collide with extras)', () => {
|
||||
expect(tabTarget({ tabId: null, targetId: null } as any)).toEqual({});
|
||||
});
|
||||
|
||||
it('preserves tabIndex when only that is provided', () => {
|
||||
expect(tabTarget({ tabIndex: 3 })).toEqual({ tabIndex: 3 });
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
MSG_RESPONSE,
|
||||
MSG_PING,
|
||||
MSG_PONG,
|
||||
DEFAULT_ZAP_PORTS,
|
||||
HANZO_SERVICE_TYPE,
|
||||
ZAP_RECONNECT_DELAY,
|
||||
ZAP_DISCOVERY_TIMEOUT,
|
||||
} from '../src/shared/zap';
|
||||
@@ -41,8 +41,11 @@ describe('Protocol constants', () => {
|
||||
expect(MSG_PONG).toBe(0xFF);
|
||||
});
|
||||
|
||||
it('DEFAULT_ZAP_PORTS is correct', () => {
|
||||
expect(DEFAULT_ZAP_PORTS).toEqual([9999, 9998, 9997, 9996, 9995]);
|
||||
it('HANZO_SERVICE_TYPE is _hanzo._tcp.local. (HIP-0069 mDNS-only)', () => {
|
||||
// Replaces the legacy DEFAULT_ZAP_PORTS port-probe list. Discovery is
|
||||
// now exclusively mDNS — the OS-assigned ZAP port is advertised by
|
||||
// hanzo-zap-mdns and consumed via the extension's mDNS resolver.
|
||||
expect(HANZO_SERVICE_TYPE).toBe('_hanzo._tcp.local.');
|
||||
});
|
||||
|
||||
it('timing constants are defined', () => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* webextension-polyfill integration.
|
||||
*
|
||||
* The 1.9.0 unification: every entrypoint imports `webextension-polyfill`
|
||||
* as its first import. On Chrome/Edge that defines `globalThis.browser`
|
||||
* as the Promise-returning WebExtension API; on Firefox/Safari it's a
|
||||
* no-op because `browser.*` is already native.
|
||||
*
|
||||
* The unified `webExtensionAdapter()` factory in `shared/auth.ts` then
|
||||
* picks up that global so we have ONE adapter codepath instead of two
|
||||
* (chromeAdapter / firefoxAdapter) with subtly different behaviours.
|
||||
*
|
||||
* This suite verifies the contract structurally (the polyfill is in
|
||||
* deps, the entrypoints import it, the adapter exists) — runtime
|
||||
* verification is in the e2e specs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'),
|
||||
);
|
||||
|
||||
describe('webextension-polyfill is a runtime dependency', () => {
|
||||
it('listed in dependencies (not devDependencies)', () => {
|
||||
expect(pkg.dependencies['webextension-polyfill']).toBeDefined();
|
||||
});
|
||||
|
||||
it('pinned to a known version (no caret) so MV3 behavior is reproducible', () => {
|
||||
// Caret ranges across major versions of the polyfill have historically
|
||||
// changed return-value shapes. Pin to a known version.
|
||||
const v = pkg.dependencies['webextension-polyfill'];
|
||||
expect(v).not.toMatch(/^\^/);
|
||||
expect(v).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared/auth.ts unified adapter', () => {
|
||||
const sharedAuth = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src/shared/auth.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
it('exports webExtensionAdapter with storage.get returning a Promise', () => {
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+webExtensionAdapter\s*\(\s*\)\s*:\s*BrowserAdapter/);
|
||||
// Verify the adapter wraps with Promise.resolve so the surface is
|
||||
// identical regardless of underlying API style.
|
||||
expect(sharedAuth).toMatch(/Promise\.resolve\(b\.storage\.local\.get/);
|
||||
});
|
||||
|
||||
it('falls back to globalThis.chrome if globalThis.browser is missing', () => {
|
||||
// The legacy fallback handles tests / non-extension contexts where
|
||||
// neither has been set up. It uses ?? so an empty browser object
|
||||
// doesn't shadow chrome.
|
||||
expect(sharedAuth).toMatch(/globalThis as any\)\.browser\s*\?\?\s*\(globalThis as any\)\.chrome/);
|
||||
});
|
||||
|
||||
it('chromeAdapter and firefoxAdapter are aliases for back-compat', () => {
|
||||
// Existing callers from before 1.9.0 must continue to work.
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+chromeAdapter[\s\S]*?return\s+webExtensionAdapter\(\)/);
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+firefoxAdapter[\s\S]*?return\s+webExtensionAdapter\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('build pipeline ships the polyfill', () => {
|
||||
it('chrome bundle includes webextension-polyfill code', () => {
|
||||
const distChrome = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'dist/browser-extension/chrome/background.js',
|
||||
);
|
||||
if (!fs.existsSync(distChrome)) {
|
||||
// Build hasn't run yet — skip. CI runs build before tests.
|
||||
return;
|
||||
}
|
||||
const bg = fs.readFileSync(distChrome, 'utf8');
|
||||
// The polyfill module sets `globalThis.browser`. We grep for its
|
||||
// distinctive marker so we know it actually ended up in the bundle.
|
||||
expect(bg).toMatch(/webextension-polyfill|browserPolyfill|chromeApiAvailable|globalThis\.browser/);
|
||||
});
|
||||
|
||||
it('firefox bundle does not break if the polyfill is a no-op', () => {
|
||||
const distFirefox = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'dist/browser-extension/firefox/background.js',
|
||||
);
|
||||
if (!fs.existsSync(distFirefox)) return;
|
||||
const bg = fs.readFileSync(distFirefox, 'utf8');
|
||||
// The Firefox bundle should still be valid JS regardless of whether
|
||||
// the polyfill no-ops or wraps. Probe for any obvious syntax failure.
|
||||
expect(bg.length).toBeGreaterThan(1000);
|
||||
// Must NOT contain leftover NodeJS-only syntax (require()) since the
|
||||
// bundle target is browser.
|
||||
expect(bg).not.toMatch(/^const\s+\w+\s*=\s*require\(/m);
|
||||
});
|
||||
});
|
||||
Generated
+16
-1
@@ -129,6 +129,9 @@ importers:
|
||||
commander:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
webextension-polyfill:
|
||||
specifier: 0.12.0
|
||||
version: 0.12.0
|
||||
ws:
|
||||
specifier: ^8.18.3
|
||||
version: 8.18.3
|
||||
@@ -2602,6 +2605,7 @@ packages:
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
|
||||
'@vitest/coverage-v8@0.34.6':
|
||||
resolution: {integrity: sha512-fivy/OK2d/EsJFoEoxHFEnNGTg+MmdZBAVK9Ka4qhXR2K3J0DS08vcGVwzDtXSuUMabLv4KtPcpSKkcMXFDViw==}
|
||||
@@ -3967,11 +3971,12 @@ packages:
|
||||
|
||||
glob@10.4.5:
|
||||
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global@4.4.0:
|
||||
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
|
||||
@@ -5401,11 +5406,13 @@ packages:
|
||||
prebuild-install@5.3.6:
|
||||
resolution: {integrity: sha512-s8Aai8++QQGi4sSbs/M1Qku62PFK49Jm1CbgXklGz4nmHveDq0wzJkg7Na5QbnO1uNH8K7iqx2EQ/mV0MZEmOg==}
|
||||
engines: {node: '>=6'}
|
||||
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
|
||||
hasBin: true
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
|
||||
hasBin: true
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
@@ -5980,6 +5987,7 @@ packages:
|
||||
tar@7.4.3:
|
||||
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
terser@5.43.1:
|
||||
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
|
||||
@@ -6300,10 +6308,12 @@ packages:
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
v8-to-istanbul@9.3.0:
|
||||
@@ -6435,6 +6445,9 @@ packages:
|
||||
web-vitals@4.2.4:
|
||||
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
|
||||
|
||||
webextension-polyfill@0.12.0:
|
||||
resolution: {integrity: sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -13524,6 +13537,8 @@ snapshots:
|
||||
|
||||
web-vitals@4.2.4: {}
|
||||
|
||||
webextension-polyfill@0.12.0: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@4.0.2: {}
|
||||
|
||||
Reference in New Issue
Block a user