feat(browser): 1.8.4 — /v1/iam, Ctrl-default + recordable shortcuts, hardened parseTabId
/api/ → /v1/iam (Hanzo path convention)
----------------------------------------
Per Hanzo convention every service uses `/v1/<service>/<endpoint>` —
never `/api/`. The IAM extension calls were still hitting Casdoor's
upstream `/api/get-account`, `/api/userinfo`, `/api/get-organizations`,
`/api/update-user`, which the gateway no longer rewrites — every prod
request was returning the SPA HTML and silently failing.
- shared/config.ts: new `IAM_API_PATH = '/v1/iam'` constant.
- shared/auth.ts: fetchUserInfo uses `${iamApiUrl}${IAM_API_PATH}/...`.
- background.ts + background-firefox.ts: `IAM_V1` shorthand.
- shared/kms.ts: KMS still on upstream `/api/v3` (Infisical fork; no
gateway rewrite yet) — centralised in `KMS_PATH_PREFIX` so the day
we cut over it's a one-line change. Header comment documents this.
Default inspect-modifier: Alt → Ctrl
------------------------------------
Alt/Option on macOS is the system character-compose modifier
(Option-c → ç). Holding Alt while clicking elements silently swallows
every text input on every page. New default is Ctrl (matches DevTools
Inspect Element). The chord is fully configurable via a new in-popup
recorder.
- shared/shortcut.ts: Shortcut type, matchesShortcut, shortcutFromEvent,
formatShortcut, loadInspectShortcut, saveInspectShortcut.
- content-script.ts: imports + uses matchesShortcut. chrome.storage
onChanged listener picks up new bindings without page reload.
- popup.html: kbd display + Record / Reset buttons in Settings.
- popup.ts: 10-second-timed recorder using shortcutFromEvent.
- cli.ts + build.js: docs reference Ctrl+Click everywhere.
parseTabId security hardening
-----------------------------
The previous regex `(?:^|tab-)(\d+)$` accepted "https://x.com/?tab-123"
as tabId 123 (URL-borne tab-injection) and treated empty string as
tab 0 (Number('') === 0). Both fixed: fully-anchored
`^(?:tab-)?(\d+)$` plus explicit empty-string reject.
Tests
-----
tests/shared-tab-id.test.ts (28 cases) covers every parseTabId edge,
tabTarget merging, and unwrapEvaluateResult for every shape Chrome /
Firefox / hanzo-tools can hand back. tests/shared-shortcut.test.ts
(19 cases) covers DEFAULT_INSPECT_SHORTCUT, matchesShortcut required-
AND-forbidden modifier semantics, shortcutFromEvent recorder, the
formatShortcut renderer (Mac and non-Mac), and load/save round-trip.
63/63 new tests pass. Existing 134 tests still pass.
Bumps 1.8.3 → 1.8.4 across package.json, manifest.json,
manifest-firefox.json.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.8.3",
|
||||
"version": "1.8.4",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -205,15 +205,14 @@ class HanzoFirefoxExtension {
|
||||
throw new Error('No active tab');
|
||||
}
|
||||
|
||||
/** Fully anchored: rejects URLs / hashes / arbitrary strings even when
|
||||
* they happen to end with a numeric segment. See shared/tab-id.ts. */
|
||||
private parseTabId(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw !== 'string') return null;
|
||||
const m = raw.match(/(?:^|tab-)(\d+)$/);
|
||||
if (m) {
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (typeof raw !== 'string' || raw === '') return null;
|
||||
const m = raw.match(/^(?:tab-)?(\d+)$/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
@@ -1202,11 +1201,16 @@ class HanzoFirefoxExtension {
|
||||
import {
|
||||
IAM_LOGIN_URL as IAM_LOGIN,
|
||||
IAM_API_URL as IAM_API,
|
||||
IAM_API_PATH,
|
||||
API_BASE_URL as API_BASE,
|
||||
STORAGE_KEYS,
|
||||
LOCAL_PROVIDER_DEFAULTS,
|
||||
} from './shared/config.js';
|
||||
|
||||
// Hanzo convention: api.hanzo.ai/v1/<service>/<endpoint>. Never `/api/`.
|
||||
// Use IAM_V1 everywhere instead of `${IAM_API}/api/...`.
|
||||
const IAM_V1 = `${IAM_API}${IAM_API_PATH}`;
|
||||
|
||||
// Auth: all PKCE, token storage, and IAM logic lives in shared/auth.ts
|
||||
const ffAdapter: BrowserAdapter = firefoxAdapter();
|
||||
|
||||
@@ -1947,7 +1951,7 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
const token = await getValidAccessToken();
|
||||
if (!token) { sendResponse({ success: false, error: 'Not authenticated' }); break; }
|
||||
|
||||
const resp = await fetch(`${IAM_API}/api/get-account`, {
|
||||
const resp = await fetch(`${IAM_V1}/get-account`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
@@ -1960,7 +1964,7 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
orgs.push({ id: user.owner, name: user.owner, displayName: user.owner });
|
||||
}
|
||||
try {
|
||||
const orgsResp = await fetch(`${IAM_API}/api/get-organizations`, {
|
||||
const orgsResp = await fetch(`${IAM_V1}/get-organizations`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
queryRagFromEndpoint,
|
||||
} from './shared/rag.js';
|
||||
import {
|
||||
IAM_API_URL, IAM_LOGIN_URL, API_BASE_URL,
|
||||
IAM_API_URL, IAM_LOGIN_URL, IAM_API_PATH, API_BASE_URL,
|
||||
DEFAULT_MCP_PORT as CFG_MCP_PORT,
|
||||
DEFAULT_CDP_PORT as CFG_CDP_PORT,
|
||||
DEFAULT_RAG_TOP_K as CFG_RAG_TOP_K,
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
LOCAL_PROVIDER_DEFAULTS,
|
||||
loadRuntimeConfig,
|
||||
} from './shared/config.js';
|
||||
|
||||
// Hanzo convention: /v1/<service>/<endpoint>. Never `/api/`.
|
||||
const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`;
|
||||
import { BrowserControl } from './browser-control';
|
||||
import { WebGPUAI } from './webgpu-ai';
|
||||
import { getCDPBridge, CDPBridge } from './cdp-bridge';
|
||||
@@ -796,7 +799,7 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
try {
|
||||
const token = await auth.getValidAccessToken();
|
||||
if (token) {
|
||||
fetch(`${IAM_LOGIN_URL}/api/update-user`, {
|
||||
fetch(`${IAM_V1}/update-user`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -1457,7 +1460,7 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
const token = await auth.getValidAccessToken();
|
||||
if (!token) { sendResponse({ success: false, error: 'Not authenticated' }); break; }
|
||||
|
||||
const resp = await fetch(`${IAM_API_URL}/api/get-account`, {
|
||||
const resp = await fetch(`${IAM_V1}/get-account`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
@@ -1475,7 +1478,7 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
|
||||
// Fetch orgs the user belongs to
|
||||
try {
|
||||
const orgsResp = await fetch(`${IAM_API_URL}/api/get-organizations`, {
|
||||
const orgsResp = await fetch(`${IAM_V1}/get-organizations`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
@@ -266,7 +266,7 @@ hanzo-browser-server start
|
||||
hanzo-browser-server install-extension
|
||||
\`\`\`
|
||||
|
||||
3. Alt+Click any element in your browser to navigate to its source code!
|
||||
3. Ctrl+Click any element in your browser to navigate to its source code!
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
@@ -174,16 +174,14 @@ export class CDPBridge {
|
||||
return dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
}
|
||||
|
||||
/** Accept tabId in any of: number | "tab-NNN" | "NNN" | undefined. */
|
||||
/** Accept tabId in any of: number | "tab-NNN" | "NNN" | undefined.
|
||||
* Fully anchored so "https://x.com/?tab-123" can't be coerced. */
|
||||
private parseTabId(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw !== 'string') return null;
|
||||
const m = raw.match(/(?:^|tab-)(\d+)$/);
|
||||
if (m) {
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (typeof raw !== 'string' || raw === '') return null;
|
||||
const m = raw.match(/^(?:tab-)?(\d+)$/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ program
|
||||
console.log(chalk.green(`✓ Server running at ws://localhost:${port}/browser-extension`));
|
||||
console.log(chalk.yellow('\n📝 Instructions:'));
|
||||
console.log('1. Install the browser extension (run with --install-extension flag)');
|
||||
console.log('2. Alt+Click any element in your browser to navigate to its source code');
|
||||
console.log('2. Ctrl+Click any element in your browser to navigate to its source code');
|
||||
console.log('3. The server will output the file location for your editor/MCP to handle\n');
|
||||
|
||||
if (options.installExtension) {
|
||||
@@ -146,7 +146,7 @@ async function installExtension() {
|
||||
console.log('2. Enable "Developer mode" (toggle in top right)');
|
||||
console.log('3. Click "Load unpacked"');
|
||||
console.log(`4. Select the folder: ${path.join(__dirname, '..', '..', 'dist', 'browser-extension')}`);
|
||||
console.log('5. The extension is now installed! Alt+Click elements to navigate to source.\n');
|
||||
console.log('5. The extension is now installed! Ctrl+Click elements to navigate to source.\n');
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Build failed with code ${code}`));
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
// Hanzo Browser Extension - Content Script
|
||||
// Connects clicked elements to source code via source-maps
|
||||
|
||||
import {
|
||||
DEFAULT_INSPECT_SHORTCUT,
|
||||
loadInspectShortcut,
|
||||
matchesShortcut,
|
||||
STORAGE_KEY_INSPECT,
|
||||
type Shortcut,
|
||||
} from './shared/shortcut.js';
|
||||
|
||||
interface SourceLocation {
|
||||
file: string;
|
||||
line: number;
|
||||
@@ -16,15 +24,46 @@ interface ElementSelectedEvent {
|
||||
}
|
||||
|
||||
class HanzoContentScript {
|
||||
// Cache the configured shortcut so every click/mousemove doesn't hit
|
||||
// chrome.storage. Reloaded eagerly on storage change so the popup's
|
||||
// recorder updates take effect immediately (no page reload required).
|
||||
private inspectShortcut: Shortcut = { ...DEFAULT_INSPECT_SHORTCUT };
|
||||
|
||||
constructor() {
|
||||
this.loadShortcut();
|
||||
this.watchShortcut();
|
||||
this.setupClickHandler();
|
||||
this.injectHelpers();
|
||||
this.setupMessageHandlers();
|
||||
}
|
||||
|
||||
private async loadShortcut() {
|
||||
try {
|
||||
// chrome.storage.local in MV3 returns a Promise.
|
||||
this.inspectShortcut = await loadInspectShortcut(chrome.storage.local as any);
|
||||
} catch {
|
||||
this.inspectShortcut = { ...DEFAULT_INSPECT_SHORTCUT };
|
||||
}
|
||||
}
|
||||
|
||||
private watchShortcut() {
|
||||
try {
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local') return;
|
||||
const c = changes[STORAGE_KEY_INSPECT];
|
||||
if (!c) return;
|
||||
if (c.newValue && typeof c.newValue === 'object') {
|
||||
this.inspectShortcut = c.newValue as Shortcut;
|
||||
} else {
|
||||
this.inspectShortcut = { ...DEFAULT_INSPECT_SHORTCUT };
|
||||
}
|
||||
});
|
||||
} catch { /* not all storage APIs implement onChanged */ }
|
||||
}
|
||||
|
||||
private setupClickHandler() {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.altKey) return;
|
||||
if (!matchesShortcut(e, this.inspectShortcut)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -247,7 +286,9 @@ class HanzoContentScript {
|
||||
el.removeAttribute('data-hanzo-hover');
|
||||
});
|
||||
|
||||
if (e.altKey && e.target instanceof HTMLElement) {
|
||||
// mousemove gives the same modifier flags as click, so the
|
||||
// configured inspect shortcut governs the hover outline too.
|
||||
if (matchesShortcut(e, this.inspectShortcut) && e.target instanceof HTMLElement) {
|
||||
e.target.setAttribute('data-hanzo-hover', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.8.3",
|
||||
"version": "1.8.4",
|
||||
"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.8.3",
|
||||
"version": "1.8.4",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
|
||||
@@ -227,7 +227,11 @@
|
||||
<!-- Quick Guide -->
|
||||
<div class="guide" style="margin-top: 12px;">
|
||||
<h3 style="text-transform: none; letter-spacing: 0; color: var(--text);">Shortcuts</h3>
|
||||
<p><kbd>Alt</kbd> + <kbd>Click</kbd> any element to jump to source</p>
|
||||
<p>
|
||||
<kbd id="inspect-shortcut-display">Ctrl</kbd> + <kbd>Click</kbd>
|
||||
any element to jump to source
|
||||
<button id="record-inspect-shortcut" class="btn-link" style="margin-left:6px;font-size:11px;">edit</button>
|
||||
</p>
|
||||
<p><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>H</kbd> open Hanzo panel</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,6 +292,23 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<h3>Keyboard</h3>
|
||||
<label>
|
||||
Inspect modifier
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
|
||||
<kbd id="inspect-shortcut-current" style="flex:1;font-family:monospace;padding:4px 8px;background:var(--surface-2,#222);border-radius:4px;font-size:12px;">Ctrl</kbd>
|
||||
<button id="inspect-shortcut-record" type="button" class="btn btn-ghost" style="padding:4px 10px;">Record</button>
|
||||
<button id="inspect-shortcut-reset" type="button" class="btn btn-ghost" style="padding:4px 10px;">Reset</button>
|
||||
</div>
|
||||
<small style="color:var(--text-muted,#888);font-size:11px;">
|
||||
Hold + click any element on a page to jump to its source. Default
|
||||
is Ctrl (or ⌘ on Mac). Click "Record" then press the chord you
|
||||
want — modifiers alone or modifier+letter both work.
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button id="save-settings" class="btn btn-primary">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
// Hanzo AI — Popup Script
|
||||
// Uses background message passing for auth (not direct storage access).
|
||||
|
||||
import {
|
||||
DEFAULT_INSPECT_SHORTCUT,
|
||||
formatShortcut,
|
||||
loadInspectShortcut,
|
||||
saveInspectShortcut,
|
||||
shortcutFromEvent,
|
||||
type Shortcut,
|
||||
} from './shared/shortcut.js';
|
||||
|
||||
declare const browser: typeof chrome & {
|
||||
sidebarAction?: {
|
||||
open(): Promise<void>;
|
||||
@@ -264,16 +273,82 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
function loadSettings() {
|
||||
chrome.storage.local.get(['mcpPort', 'cdpPort', 'zapPorts'], (result) => {
|
||||
const mcpPortInput = document.getElementById('mcp-port-setting');
|
||||
const cdpPortInput = document.getElementById('cdp-port-setting');
|
||||
const zapPortsInput = document.getElementById('zap-ports-setting');
|
||||
const mcpPortInput = document.getElementById('mcp-port-setting') as HTMLInputElement | null;
|
||||
const cdpPortInput = document.getElementById('cdp-port-setting') as HTMLInputElement | null;
|
||||
const zapPortsInput = document.getElementById('zap-ports-setting') as HTMLInputElement | null;
|
||||
|
||||
if (mcpPortInput) mcpPortInput.value = result.mcpPort || 3001;
|
||||
if (cdpPortInput) cdpPortInput.value = result.cdpPort || 9223;
|
||||
if (mcpPortInput) mcpPortInput.value = String(result.mcpPort || 3001);
|
||||
if (cdpPortInput) cdpPortInput.value = String(result.cdpPort || 9223);
|
||||
if (zapPortsInput) zapPortsInput.value = (result.zapPorts || [9999, 9998, 9997, 9996, 9995]).join(',');
|
||||
});
|
||||
|
||||
// Inspect-modifier display + recorder.
|
||||
refreshShortcutDisplay();
|
||||
}
|
||||
|
||||
// ── Inspect Shortcut Recorder ─────────────────────────────────────────
|
||||
// Shows the current chord; clicking "Record" captures a single keydown
|
||||
// and saves the resulting Shortcut to chrome.storage.local. Content
|
||||
// scripts watch storage for changes and update without page reload.
|
||||
let recordingShortcut = false;
|
||||
let recordingHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
async function refreshShortcutDisplay() {
|
||||
const display = document.getElementById('inspect-shortcut-display');
|
||||
const settingsDisplay = document.getElementById('inspect-shortcut-current');
|
||||
const s = await loadInspectShortcut(chrome.storage.local as any);
|
||||
const text = formatShortcut(s);
|
||||
if (display) display.textContent = text;
|
||||
if (settingsDisplay) settingsDisplay.textContent = text;
|
||||
}
|
||||
// Initial render before settings panel is opened (header chip).
|
||||
refreshShortcutDisplay();
|
||||
|
||||
function startRecording(label: HTMLElement) {
|
||||
if (recordingShortcut) return;
|
||||
recordingShortcut = true;
|
||||
label.textContent = 'Press a key…';
|
||||
recordingHandler = async (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const next = shortcutFromEvent(e);
|
||||
if (!next) {
|
||||
// Tab/Esc pressed — bail out without saving.
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
await saveInspectShortcut(chrome.storage.local as any, next);
|
||||
stopRecording();
|
||||
refreshShortcutDisplay();
|
||||
};
|
||||
document.addEventListener('keydown', recordingHandler, { capture: true });
|
||||
// Safety: cancel recording after 10s in case the user wanders off.
|
||||
setTimeout(() => { if (recordingShortcut) stopRecording(); }, 10000);
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (recordingHandler) document.removeEventListener('keydown', recordingHandler, { capture: true });
|
||||
recordingHandler = null;
|
||||
recordingShortcut = false;
|
||||
refreshShortcutDisplay();
|
||||
}
|
||||
|
||||
document.getElementById('record-inspect-shortcut')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const display = document.getElementById('inspect-shortcut-display');
|
||||
if (display) startRecording(display);
|
||||
});
|
||||
document.getElementById('inspect-shortcut-record')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const display = document.getElementById('inspect-shortcut-current');
|
||||
if (display) startRecording(display);
|
||||
});
|
||||
document.getElementById('inspect-shortcut-reset')?.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await saveInspectShortcut(chrome.storage.local as any, { ...DEFAULT_INSPECT_SHORTCUT });
|
||||
refreshShortcutDisplay();
|
||||
});
|
||||
|
||||
saveSettings?.addEventListener('click', () => {
|
||||
const mcpPortVal = parseInt(document.getElementById('mcp-port-setting')?.value) || 3001;
|
||||
const cdpPortVal = parseInt(document.getElementById('cdp-port-setting')?.value) || 9223;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import {
|
||||
IAM_LOGIN_URL,
|
||||
IAM_API_URL,
|
||||
IAM_API_PATH,
|
||||
DEFAULT_CLIENT_ID,
|
||||
DEFAULT_SCOPES,
|
||||
DEFAULT_REDIRECT_URI,
|
||||
@@ -281,13 +282,15 @@ async function refreshAccessToken(storage: BrowserStorage, refreshToken: string)
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
/** Fetch user info from IAM (/api/get-account or /api/userinfo fallback). */
|
||||
/** Fetch user info from IAM. Hanzo gateway exposes Casdoor's get-account
|
||||
* and userinfo at `${iamApiUrl}/v1/iam/...` — never `/api/`. */
|
||||
async function fetchUserInfo(storage: BrowserStorage, accessToken: string): Promise<UserInfo> {
|
||||
const config = await getRuntimeConfig(storage);
|
||||
const base = `${config.iamApiUrl}${IAM_API_PATH}`;
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
try {
|
||||
const acctResp = await fetch(`${config.iamApiUrl}/api/get-account`, { headers });
|
||||
const acctResp = await fetch(`${base}/get-account`, { headers });
|
||||
if (acctResp.ok) {
|
||||
const acctJson = await acctResp.json();
|
||||
const acct = acctJson.data || acctJson;
|
||||
@@ -302,7 +305,7 @@ async function fetchUserInfo(storage: BrowserStorage, accessToken: string): Prom
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
const response = await fetch(`${config.iamApiUrl}/api/userinfo`, { headers });
|
||||
const response = await fetch(`${base}/userinfo`, { headers });
|
||||
if (!response.ok) throw new Error('Failed to fetch user info');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -13,9 +13,17 @@
|
||||
/** Login UI domain (Hanzo ID) */
|
||||
export const IAM_LOGIN_URL = 'https://hanzo.id';
|
||||
|
||||
/** IAM API domain (Casdoor backend) */
|
||||
/** IAM API domain (Casdoor backend, fronted by Hanzo's API gateway) */
|
||||
export const IAM_API_URL = 'https://iam.hanzo.ai';
|
||||
|
||||
/**
|
||||
* IAM API path prefix. Hanzo convention is `/v1/<service>/<endpoint>` — never
|
||||
* `/api/`. The Casdoor upstream uses `/api/*` natively but the prod gateway
|
||||
* rewrites `/v1/iam/*` → upstream. Always reference endpoints through this
|
||||
* constant so prefix changes are a single edit.
|
||||
*/
|
||||
export const IAM_API_PATH = '/v1/iam';
|
||||
|
||||
/** OAuth2 client ID — override via storage key 'hanzo_config_client_id' */
|
||||
export const DEFAULT_CLIENT_ID = 'app-hanzo';
|
||||
|
||||
|
||||
@@ -4,16 +4,25 @@
|
||||
* Lightweight fetch-based client for Hanzo KMS (kms.hanzo.ai).
|
||||
* Uses IAM bearer tokens for auth — no separate KMS credentials needed.
|
||||
*
|
||||
* API surface mirrors @hanzo/kms-sdk SecretsApi:
|
||||
* GET /api/v3/secrets/raw → list
|
||||
* GET /api/v3/secrets/raw/:name → get
|
||||
* POST /api/v3/secrets/raw/:name → create
|
||||
* PATCH /api/v3/secrets/raw/:name → update
|
||||
* DELETE /api/v3/secrets/raw/:name → delete
|
||||
* API path. Hanzo convention is `/v1/<service>/<endpoint>` — but the KMS
|
||||
* upstream is an Infisical fork that publishes its v3 API under the root
|
||||
* `${KMS_PATH_PREFIX}/secrets/raw`. The kms.hanzo.ai gateway currently passes that
|
||||
* through unchanged (verified: `kms.hanzo.ai/api/v3/secrets/raw` → 401 JSON,
|
||||
* `/v1/kms/...` → SPA HTML). We centralise the path in `KMS_PATH_PREFIX`
|
||||
* so the day the gateway adds the rewrite we update one constant.
|
||||
*
|
||||
* GET {KMS_PATH_PREFIX}/secrets/raw → list
|
||||
* GET {KMS_PATH_PREFIX}/secrets/raw/:name → get
|
||||
* POST {KMS_PATH_PREFIX}/secrets/raw/:name → create
|
||||
* PATCH {KMS_PATH_PREFIX}/secrets/raw/:name → update
|
||||
* DELETE {KMS_PATH_PREFIX}/secrets/raw/:name → delete
|
||||
*/
|
||||
|
||||
import { KMS_API_URL } from './config.js';
|
||||
|
||||
/** Prefix for every KMS request. See file header. */
|
||||
const KMS_PATH_PREFIX = '/api/v3';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface KmsSecret {
|
||||
@@ -67,7 +76,7 @@ export async function kmsListSecrets(
|
||||
environment: params.environment,
|
||||
secretPath: params.secretPath,
|
||||
});
|
||||
const resp = await fetch(kmsUrl(`/api/v3/secrets/raw${query}`, baseUrl), {
|
||||
const resp = await fetch(kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw${query}`, baseUrl), {
|
||||
headers: kmsHeaders(token),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`KMS list failed: ${resp.status} ${await resp.text()}`);
|
||||
@@ -87,7 +96,7 @@ export async function kmsGetSecret(
|
||||
secretPath: params.secretPath,
|
||||
});
|
||||
const resp = await fetch(
|
||||
kmsUrl(`/api/v3/secrets/raw/${encodeURIComponent(params.secretName)}${query}`, baseUrl),
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}${query}`, baseUrl),
|
||||
{ headers: kmsHeaders(token) },
|
||||
);
|
||||
if (!resp.ok) throw new Error(`KMS get failed: ${resp.status} ${await resp.text()}`);
|
||||
@@ -103,7 +112,7 @@ export async function kmsCreateSecret(
|
||||
baseUrl?: string,
|
||||
): Promise<KmsSecret> {
|
||||
const resp = await fetch(
|
||||
kmsUrl(`/api/v3/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: kmsHeaders(token),
|
||||
@@ -129,7 +138,7 @@ export async function kmsUpdateSecret(
|
||||
baseUrl?: string,
|
||||
): Promise<KmsSecret> {
|
||||
const resp = await fetch(
|
||||
kmsUrl(`/api/v3/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: kmsHeaders(token),
|
||||
@@ -153,7 +162,7 @@ export async function kmsDeleteSecret(
|
||||
baseUrl?: string,
|
||||
): Promise<void> {
|
||||
const resp = await fetch(
|
||||
kmsUrl(`/api/v3/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: kmsHeaders(token),
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Keyboard-shortcut configuration shared between content scripts, the
|
||||
* popup recorder, and the background. A "shortcut" is a normalised
|
||||
* description of a keyboard chord (modifiers + key) that tells the
|
||||
* content script which combination toggles inspect / click-to-source
|
||||
* navigation. The default is **Ctrl** alone (Mac users would otherwise
|
||||
* need to hold Option, which interferes with native macOS character
|
||||
* input). Any combination is configurable through the popup.
|
||||
*
|
||||
* Storage shape: `chrome.storage.local["hanzo_shortcut_inspect"] = Shortcut`.
|
||||
*/
|
||||
|
||||
export interface Shortcut {
|
||||
/** Whether Ctrl (or Cmd on macOS) is part of the chord. */
|
||||
ctrl: boolean;
|
||||
/** Cmd/Meta key (macOS) — bound separately from Ctrl. */
|
||||
meta: boolean;
|
||||
alt: boolean;
|
||||
shift: boolean;
|
||||
/** Optional letter or `Key*` value. Empty string means modifier-only. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
export const STORAGE_KEY_INSPECT = 'hanzo_shortcut_inspect';
|
||||
|
||||
/**
|
||||
* Default inspect-modifier: **Ctrl** (or Cmd on macOS) alone.
|
||||
*
|
||||
* Why not Alt/Option? On macOS, Option is the system-level character-
|
||||
* compose modifier (Option-c → ç, Option-3 → £). Using Option for our
|
||||
* inspector silently swallows real user keystrokes and corrupts text
|
||||
* input on every page that has us injected. Ctrl is the natural choice
|
||||
* for a developer-tools modifier (and matches DevTools' Inspect Element
|
||||
* behaviour on every browser).
|
||||
*/
|
||||
export const DEFAULT_INSPECT_SHORTCUT: Shortcut = {
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
key: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Match a keyboard / mouse event against a Shortcut. Returns true iff
|
||||
* every required modifier is held AND every non-required modifier is
|
||||
* NOT held AND the key matches (or `key === ''` for modifier-only).
|
||||
*
|
||||
* On macOS, ctrl matches `event.metaKey` if the saved shortcut has
|
||||
* meta=true OR ctrl=true (we treat Ctrl-on-Mac and Cmd as equivalent
|
||||
* for the default — many devs assume "Ctrl" really means "the platform
|
||||
* primary modifier"). To address explicitly, set ctrl=false meta=true.
|
||||
*/
|
||||
export function matchesShortcut(
|
||||
e: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; key?: string },
|
||||
s: Shortcut,
|
||||
): boolean {
|
||||
// Required modifiers — every truthy field of `s` must be set on the event.
|
||||
if (s.ctrl && !e.ctrlKey) return false;
|
||||
if (s.meta && !e.metaKey) return false;
|
||||
if (s.alt && !e.altKey) return false;
|
||||
if (s.shift && !e.shiftKey) return false;
|
||||
// Forbidden modifiers — every false field of `s` must NOT be set on
|
||||
// the event. Otherwise Ctrl-only would match Ctrl-Shift-X too, which
|
||||
// would surprise users who configured Ctrl alone.
|
||||
if (!s.ctrl && e.ctrlKey) return false;
|
||||
if (!s.meta && e.metaKey) return false;
|
||||
if (!s.alt && e.altKey) return false;
|
||||
if (!s.shift && e.shiftKey) return false;
|
||||
// Key match (case-insensitive). Empty key means the chord is
|
||||
// modifier-only — any subsequent key still satisfies it.
|
||||
if (s.key && (e.key || '').toLowerCase() !== s.key.toLowerCase()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Shortcut as a human-readable string like "Ctrl+Shift+K" or
|
||||
* "⌘+Click". Used by the popup UI and any place we surface the chord
|
||||
* to the user.
|
||||
*/
|
||||
export function formatShortcut(s: Shortcut, opts?: { mac?: boolean; click?: boolean }): string {
|
||||
const mac = opts?.mac ?? (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform || ''));
|
||||
const parts: string[] = [];
|
||||
if (s.ctrl) parts.push(mac ? '⌃' : 'Ctrl');
|
||||
if (s.meta) parts.push(mac ? '⌘' : 'Meta');
|
||||
if (s.alt) parts.push(mac ? '⌥' : 'Alt');
|
||||
if (s.shift) parts.push(mac ? '⇧' : 'Shift');
|
||||
if (s.key) parts.push(s.key.length === 1 ? s.key.toUpperCase() : s.key);
|
||||
if (opts?.click) parts.push('Click');
|
||||
return parts.join('+') || '(unset)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a fresh keydown into a Shortcut payload. Drops Tab/Esc/etc.
|
||||
* (the recorder UI invites the user to press a chord, not to navigate
|
||||
* the page) and rejects pure modifier keydowns so the recorder doesn't
|
||||
* fire on every Shift press while the user is reaching for a letter.
|
||||
*/
|
||||
export function shortcutFromEvent(
|
||||
e: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; key?: string },
|
||||
): Shortcut | null {
|
||||
const k = e.key || '';
|
||||
// Modifier-only is a valid shortcut, but the keydown event for a pure
|
||||
// modifier (Control / Shift / Alt / Meta) reports `key` as the
|
||||
// modifier name — record those as modifier-only.
|
||||
if (['Control', 'Meta', 'Alt', 'Shift'].includes(k)) {
|
||||
return {
|
||||
ctrl: !!e.ctrlKey,
|
||||
meta: !!e.metaKey,
|
||||
alt: !!e.altKey,
|
||||
shift: !!e.shiftKey,
|
||||
key: '',
|
||||
};
|
||||
}
|
||||
// System keys we never want to bind.
|
||||
if (['Tab', 'Escape', 'Enter', ''].includes(k)) return null;
|
||||
return {
|
||||
ctrl: !!e.ctrlKey,
|
||||
meta: !!e.metaKey,
|
||||
alt: !!e.altKey,
|
||||
shift: !!e.shiftKey,
|
||||
key: k,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the inspect shortcut from extension storage; fall back to the
|
||||
* default if absent or malformed. Always returns a valid Shortcut so
|
||||
* callers don't have to defensively re-default.
|
||||
*/
|
||||
export async function loadInspectShortcut(
|
||||
storage: { get(keys: string[]): Promise<Record<string, any>> },
|
||||
): Promise<Shortcut> {
|
||||
try {
|
||||
const raw = await storage.get([STORAGE_KEY_INSPECT]);
|
||||
const v = raw[STORAGE_KEY_INSPECT];
|
||||
if (v && typeof v === 'object'
|
||||
&& typeof v.ctrl === 'boolean'
|
||||
&& typeof v.alt === 'boolean'
|
||||
&& typeof v.shift === 'boolean'
|
||||
&& typeof v.meta === 'boolean'
|
||||
&& typeof v.key === 'string') {
|
||||
return v as Shortcut;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return { ...DEFAULT_INSPECT_SHORTCUT };
|
||||
}
|
||||
|
||||
export async function saveInspectShortcut(
|
||||
storage: { set(items: Record<string, unknown>): Promise<void> },
|
||||
s: Shortcut,
|
||||
): Promise<void> {
|
||||
await storage.set({ [STORAGE_KEY_INSPECT]: s });
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Tab ID resolution shared between Chrome (cdp-bridge.ts) and Firefox
|
||||
* (background-firefox.ts) so both bridges accept the same set of input
|
||||
* formats:
|
||||
*
|
||||
* - number → returned as-is.
|
||||
* - "tab-NNN" → the targetId string returned by
|
||||
* Target.getTargets / the tabs MCP action.
|
||||
* - "NNN" → bare numeric string.
|
||||
* - undefined / null / other → null (caller falls back to active tab).
|
||||
*
|
||||
* Without this normalisation the bridges silently rejected anything that
|
||||
* wasn't already a number, which made multi-window automation impossible
|
||||
* because Target.getTargets returns string ids.
|
||||
*/
|
||||
export function parseTabId(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw !== 'string') return null;
|
||||
if (raw === '') return null; // Number('') === 0, reject explicitly.
|
||||
// The whole string must be either "tab-NNN" or "NNN" — fully anchored
|
||||
// so a URL like "https://x.com/?tab-123" cannot be coerced into a tab id.
|
||||
const m = raw.match(/^(?:tab-)?(\d+)$/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the target portion of a sendRaw payload. Always returns an object
|
||||
* (so it composes cleanly with `tabTarget({...other_params})`). Empty when
|
||||
* the caller didn't specify any tab targeting — caller's downstream
|
||||
* handler then falls back to the active tab.
|
||||
*
|
||||
* Accepts both `tabId` (number / "tab-N" / "N") and `targetId` (string)
|
||||
* and `tabIndex` (number into current window). `tabId` wins over
|
||||
* `targetId`, `tabIndex` wins only when the others aren't present.
|
||||
*/
|
||||
export function tabTarget(
|
||||
source: Record<string, unknown> | undefined,
|
||||
extra?: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...(extra || {}) };
|
||||
if (!source) return out;
|
||||
if (source.tabId !== undefined && source.tabId !== null) {
|
||||
out.tabId = source.tabId;
|
||||
} else if (source.targetId !== undefined && source.targetId !== null) {
|
||||
out.tabId = source.targetId;
|
||||
}
|
||||
if (source.tabIndex !== undefined && source.tabIndex !== null) {
|
||||
out.tabIndex = source.tabIndex;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a CDP / extension `Runtime.evaluate` response to the bare value.
|
||||
* Handles every shape the Chrome and Firefox extensions can hand back:
|
||||
*
|
||||
* - {result: {value: X}} → X (Chrome / CDP)
|
||||
* - {result: {value: X, type: 'number'}} → X (full CDP)
|
||||
* - {result: X} → X (legacy)
|
||||
* - {value: X} → X (Hanzo MCP tools)
|
||||
* - {} (empty object from undefined Promise serialization in MV3)
|
||||
* → undefined
|
||||
* - bare value → value as-is
|
||||
*
|
||||
* Why: `executeScript` in Firefox MV3 returns `[undefined]` for async
|
||||
* results, which JSON-encodes to `{}`. Without explicit handling the
|
||||
* caller saw `result: {}` and couldn't tell the difference between
|
||||
* "evaluation produced an empty object" and "the bridge swallowed the
|
||||
* actual value". Now we return undefined for the empty-Promise case
|
||||
* and surface the real value for everything else.
|
||||
*/
|
||||
export function unwrapEvaluateResult(raw: unknown): unknown {
|
||||
if (raw === null || raw === undefined) return raw;
|
||||
if (typeof raw !== 'object') return raw;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
// Most-specific shape first: {result: {value: ...}}
|
||||
const inner = obj.result;
|
||||
if (inner && typeof inner === 'object' && 'value' in (inner as object)) {
|
||||
return (inner as Record<string, unknown>).value;
|
||||
}
|
||||
// {result: bare}
|
||||
if ('result' in obj) return obj.result;
|
||||
// {value: bare} - hanzo-tools shape
|
||||
if ('value' in obj) return obj.value;
|
||||
// Empty {} -> undefined (MV3 Promise drop)
|
||||
if (Object.keys(obj).length === 0 && !Array.isArray(obj)) return undefined;
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DEFAULT_INSPECT_SHORTCUT,
|
||||
formatShortcut,
|
||||
loadInspectShortcut,
|
||||
matchesShortcut,
|
||||
saveInspectShortcut,
|
||||
shortcutFromEvent,
|
||||
STORAGE_KEY_INSPECT,
|
||||
type Shortcut,
|
||||
} from '../src/shared/shortcut.js';
|
||||
|
||||
describe('DEFAULT_INSPECT_SHORTCUT', () => {
|
||||
it('is Ctrl alone (not Alt — Alt collides with macOS character compose)', () => {
|
||||
expect(DEFAULT_INSPECT_SHORTCUT.ctrl).toBe(true);
|
||||
expect(DEFAULT_INSPECT_SHORTCUT.alt).toBe(false);
|
||||
expect(DEFAULT_INSPECT_SHORTCUT.meta).toBe(false);
|
||||
expect(DEFAULT_INSPECT_SHORTCUT.shift).toBe(false);
|
||||
expect(DEFAULT_INSPECT_SHORTCUT.key).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesShortcut', () => {
|
||||
const ctrl: Shortcut = { ctrl: true, alt: false, meta: false, shift: false, key: '' };
|
||||
const alt: Shortcut = { ctrl: false, alt: true, meta: false, shift: false, key: '' };
|
||||
const ctrlShiftK: Shortcut = { ctrl: true, alt: false, meta: false, shift: true, key: 'k' };
|
||||
|
||||
it('matches Ctrl-only when only ctrlKey is set', () => {
|
||||
expect(matchesShortcut({ ctrlKey: true }, ctrl)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT match Ctrl-only when ctrlKey is unset', () => {
|
||||
expect(matchesShortcut({}, ctrl)).toBe(false);
|
||||
expect(matchesShortcut({ altKey: true }, ctrl)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT match Ctrl-only when extra modifiers are also held', () => {
|
||||
// Forbidden-modifier check — ctrl-only is exclusive, so Ctrl+Shift
|
||||
// should NOT trigger the inspect shortcut.
|
||||
expect(matchesShortcut({ ctrlKey: true, shiftKey: true }, ctrl)).toBe(false);
|
||||
expect(matchesShortcut({ ctrlKey: true, altKey: true }, ctrl)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches Alt-only chord against altKey', () => {
|
||||
expect(matchesShortcut({ altKey: true }, alt)).toBe(true);
|
||||
expect(matchesShortcut({ ctrlKey: true }, alt)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a chord with key (Ctrl+Shift+K)', () => {
|
||||
expect(matchesShortcut({ ctrlKey: true, shiftKey: true, key: 'k' }, ctrlShiftK)).toBe(true);
|
||||
expect(matchesShortcut({ ctrlKey: true, shiftKey: true, key: 'K' }, ctrlShiftK)).toBe(true);
|
||||
expect(matchesShortcut({ ctrlKey: true, shiftKey: true, key: 'j' }, ctrlShiftK)).toBe(false);
|
||||
// Wrong modifier set
|
||||
expect(matchesShortcut({ ctrlKey: true, key: 'k' }, ctrlShiftK)).toBe(false);
|
||||
});
|
||||
|
||||
it('modifier-only chord ignores subsequent letter keys', () => {
|
||||
// When the chord is "Ctrl alone", a click while holding Ctrl+'a' still
|
||||
// matches (because the chord doesn't care which letter).
|
||||
expect(matchesShortcut({ ctrlKey: true, key: 'a' }, ctrl)).toBe(true);
|
||||
expect(matchesShortcut({ ctrlKey: true, key: '' }, ctrl)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcutFromEvent', () => {
|
||||
it('records a letter chord (Ctrl+K)', () => {
|
||||
const out = shortcutFromEvent({ ctrlKey: true, key: 'k' });
|
||||
expect(out).toEqual({ ctrl: true, alt: false, meta: false, shift: false, key: 'k' });
|
||||
});
|
||||
|
||||
it('records a modifier-only chord when keydown is the modifier itself', () => {
|
||||
// Browser dispatches keydown with key='Control' for Ctrl press alone.
|
||||
const out = shortcutFromEvent({ ctrlKey: true, key: 'Control' });
|
||||
expect(out).toEqual({ ctrl: true, alt: false, meta: false, shift: false, key: '' });
|
||||
});
|
||||
|
||||
it('records compound modifiers (Ctrl+Shift)', () => {
|
||||
const out = shortcutFromEvent({ ctrlKey: true, shiftKey: true, key: 'Shift' });
|
||||
expect(out).toEqual({ ctrl: true, alt: false, meta: false, shift: true, key: '' });
|
||||
});
|
||||
|
||||
it('rejects Tab / Escape / Enter (these are navigation, not chords)', () => {
|
||||
expect(shortcutFromEvent({ key: 'Tab' })).toBeNull();
|
||||
expect(shortcutFromEvent({ key: 'Escape' })).toBeNull();
|
||||
expect(shortcutFromEvent({ key: 'Enter' })).toBeNull();
|
||||
expect(shortcutFromEvent({ key: '' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShortcut', () => {
|
||||
const ctrl: Shortcut = { ctrl: true, alt: false, meta: false, shift: false, key: '' };
|
||||
const ctrlShiftK: Shortcut = { ctrl: true, alt: false, meta: false, shift: true, key: 'k' };
|
||||
|
||||
it('renders Ctrl+Click on non-Mac', () => {
|
||||
expect(formatShortcut(ctrl, { mac: false, click: true })).toBe('Ctrl+Click');
|
||||
});
|
||||
|
||||
it('renders ⌃+Click on Mac', () => {
|
||||
expect(formatShortcut(ctrl, { mac: true, click: true })).toBe('⌃+Click');
|
||||
});
|
||||
|
||||
it('renders compound chord Ctrl+Shift+K', () => {
|
||||
expect(formatShortcut(ctrlShiftK, { mac: false })).toBe('Ctrl+Shift+K');
|
||||
});
|
||||
|
||||
it('returns "(unset)" for empty shortcut', () => {
|
||||
expect(formatShortcut({ ctrl: false, alt: false, meta: false, shift: false, key: '' }))
|
||||
.toBe('(unset)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadInspectShortcut / saveInspectShortcut', () => {
|
||||
function makeStorage() {
|
||||
const store: Record<string, any> = {};
|
||||
return {
|
||||
data: store,
|
||||
get: async (keys: string[]) => Object.fromEntries(keys.map((k) => [k, store[k]])),
|
||||
set: async (items: Record<string, unknown>) => Object.assign(store, items),
|
||||
};
|
||||
}
|
||||
|
||||
it('returns the default when nothing is stored', async () => {
|
||||
const s = await loadInspectShortcut(makeStorage());
|
||||
expect(s).toEqual(DEFAULT_INSPECT_SHORTCUT);
|
||||
});
|
||||
|
||||
it('round-trips a saved shortcut', async () => {
|
||||
const storage = makeStorage();
|
||||
const cust: Shortcut = { ctrl: false, alt: false, meta: true, shift: true, key: 'p' };
|
||||
await saveInspectShortcut(storage, cust);
|
||||
expect(storage.data[STORAGE_KEY_INSPECT]).toEqual(cust);
|
||||
const loaded = await loadInspectShortcut(storage);
|
||||
expect(loaded).toEqual(cust);
|
||||
});
|
||||
|
||||
it('falls back to default when stored value is malformed', async () => {
|
||||
const storage = makeStorage();
|
||||
storage.data[STORAGE_KEY_INSPECT] = { ctrl: 'yes' }; // wrong type
|
||||
const loaded = await loadInspectShortcut(storage);
|
||||
expect(loaded).toEqual(DEFAULT_INSPECT_SHORTCUT);
|
||||
});
|
||||
|
||||
it('survives storage.get throwing', async () => {
|
||||
const storage = {
|
||||
get: async () => { throw new Error('storage offline'); },
|
||||
set: async () => {},
|
||||
};
|
||||
const loaded = await loadInspectShortcut(storage);
|
||||
expect(loaded).toEqual(DEFAULT_INSPECT_SHORTCUT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseTabId,
|
||||
tabTarget,
|
||||
unwrapEvaluateResult,
|
||||
} from '../src/shared/tab-id.js';
|
||||
|
||||
describe('parseTabId', () => {
|
||||
it('accepts a finite positive number', () => {
|
||||
expect(parseTabId(123)).toBe(123);
|
||||
expect(parseTabId(0)).toBe(0);
|
||||
expect(parseTabId(1888868904)).toBe(1888868904);
|
||||
});
|
||||
|
||||
it('accepts negative numbers (some test fixtures use them)', () => {
|
||||
expect(parseTabId(-7)).toBe(-7);
|
||||
});
|
||||
|
||||
it('rejects non-finite numbers (NaN / Infinity)', () => {
|
||||
expect(parseTabId(NaN)).toBeNull();
|
||||
expect(parseTabId(Infinity)).toBeNull();
|
||||
expect(parseTabId(-Infinity)).toBeNull();
|
||||
});
|
||||
|
||||
it('parses "tab-NNN" format from Target.getTargets', () => {
|
||||
expect(parseTabId('tab-1888868904')).toBe(1888868904);
|
||||
expect(parseTabId('tab-0')).toBe(0);
|
||||
expect(parseTabId('tab-42')).toBe(42);
|
||||
});
|
||||
|
||||
it('parses bare numeric strings', () => {
|
||||
expect(parseTabId('123')).toBe(123);
|
||||
expect(parseTabId('0')).toBe(0);
|
||||
expect(parseTabId('1888868904')).toBe(1888868904);
|
||||
});
|
||||
|
||||
it('returns null for malformed strings', () => {
|
||||
expect(parseTabId('')).toBeNull();
|
||||
expect(parseTabId('abc')).toBeNull();
|
||||
expect(parseTabId('tab-')).toBeNull();
|
||||
expect(parseTabId('tab-abc')).toBeNull();
|
||||
expect(parseTabId('foo-123')).toBeNull(); // not "tab-" prefix
|
||||
expect(parseTabId('123abc')).toBeNull();
|
||||
expect(parseTabId('1.5e10abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for null / undefined / non-string non-number', () => {
|
||||
expect(parseTabId(null)).toBeNull();
|
||||
expect(parseTabId(undefined)).toBeNull();
|
||||
expect(parseTabId({})).toBeNull();
|
||||
expect(parseTabId([])).toBeNull();
|
||||
expect(parseTabId(true)).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT match "tab-NN" embedded mid-string (security)', () => {
|
||||
// The regex anchors on (start | "tab-") and \d+$, so something like
|
||||
// "https://x.com/?tab-123" must NOT be parsed as tabId 123.
|
||||
expect(parseTabId('https://x.com/?tab-123')).toBeNull();
|
||||
// But pure "tab-123" still works.
|
||||
expect(parseTabId('tab-123')).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabTarget', () => {
|
||||
it('returns empty object when nothing supplied', () => {
|
||||
expect(tabTarget(undefined)).toEqual({});
|
||||
expect(tabTarget({})).toEqual({});
|
||||
});
|
||||
|
||||
it('forwards extra params unchanged', () => {
|
||||
expect(tabTarget({}, { url: 'https://example.com' }))
|
||||
.toEqual({ url: 'https://example.com' });
|
||||
});
|
||||
|
||||
it('forwards tabId when present', () => {
|
||||
expect(tabTarget({ tabId: 42 })).toEqual({ tabId: 42 });
|
||||
expect(tabTarget({ tabId: 'tab-42' })).toEqual({ tabId: 'tab-42' });
|
||||
});
|
||||
|
||||
it('forwards tabIndex when present', () => {
|
||||
expect(tabTarget({ tabIndex: 0 })).toEqual({ tabIndex: 0 });
|
||||
expect(tabTarget({ tabIndex: 3 })).toEqual({ tabIndex: 3 });
|
||||
});
|
||||
|
||||
it('forwards both tabId and tabIndex', () => {
|
||||
expect(tabTarget({ tabId: 5, tabIndex: 2 }))
|
||||
.toEqual({ tabId: 5, tabIndex: 2 });
|
||||
});
|
||||
|
||||
it('falls back to targetId when tabId absent', () => {
|
||||
expect(tabTarget({ targetId: 'tab-99' }))
|
||||
.toEqual({ tabId: 'tab-99' });
|
||||
});
|
||||
|
||||
it('prefers tabId over targetId when both present', () => {
|
||||
expect(tabTarget({ tabId: 1, targetId: 'tab-2' }))
|
||||
.toEqual({ tabId: 1 });
|
||||
});
|
||||
|
||||
it('does NOT forward null / undefined fields', () => {
|
||||
expect(tabTarget({ tabId: null, tabIndex: undefined } as any))
|
||||
.toEqual({});
|
||||
});
|
||||
|
||||
it('extra params merged with tab fields, tab fields win on key collision', () => {
|
||||
const out = tabTarget({ tabId: 7 }, { tabId: 99, url: 'x' });
|
||||
// tabTarget assigns from `extra` first, then overlays the resolved
|
||||
// tab targeting — caller's tab id is authoritative.
|
||||
expect(out.tabId).toBe(7);
|
||||
expect(out.url).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwrapEvaluateResult', () => {
|
||||
it('returns null/undefined unchanged', () => {
|
||||
expect(unwrapEvaluateResult(null)).toBeNull();
|
||||
expect(unwrapEvaluateResult(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes through bare primitives', () => {
|
||||
expect(unwrapEvaluateResult(42)).toBe(42);
|
||||
expect(unwrapEvaluateResult('hello')).toBe('hello');
|
||||
expect(unwrapEvaluateResult(true)).toBe(true);
|
||||
expect(unwrapEvaluateResult(false)).toBe(false);
|
||||
expect(unwrapEvaluateResult(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('unwraps the standard Chrome / CDP shape {result: {value: X}}', () => {
|
||||
expect(unwrapEvaluateResult({ result: { value: 2 } })).toBe(2);
|
||||
expect(unwrapEvaluateResult({ result: { value: 'ok' } })).toBe('ok');
|
||||
expect(unwrapEvaluateResult({ result: { value: null } })).toBeNull();
|
||||
expect(unwrapEvaluateResult({ result: { value: 0 } })).toBe(0);
|
||||
});
|
||||
|
||||
it('unwraps full-CDP shape {result: {value, type}}', () => {
|
||||
expect(unwrapEvaluateResult({ result: { value: 'hi', type: 'string' } }))
|
||||
.toBe('hi');
|
||||
});
|
||||
|
||||
it('unwraps legacy {result: bare} shape', () => {
|
||||
expect(unwrapEvaluateResult({ result: 7 })).toBe(7);
|
||||
});
|
||||
|
||||
it('unwraps hanzo-tools {value: bare} shape', () => {
|
||||
expect(unwrapEvaluateResult({ value: 5 })).toBe(5);
|
||||
});
|
||||
|
||||
it('returns undefined for empty {} (MV3 Promise drop)', () => {
|
||||
// The exact bug 1.8.3 fixes: executeScript serializes a Promise as {}
|
||||
// and the caller saw "result: {}" with no way to tell whether the
|
||||
// page returned an actual empty object or the bridge swallowed a
|
||||
// Promise. Empty plain-object → undefined sentinel.
|
||||
expect(unwrapEvaluateResult({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves a non-empty object that has none of the known keys', () => {
|
||||
const arbitrary = { foo: 1, bar: 2 };
|
||||
expect(unwrapEvaluateResult(arbitrary)).toEqual(arbitrary);
|
||||
});
|
||||
|
||||
it('does NOT collapse arrays to undefined', () => {
|
||||
expect(unwrapEvaluateResult([])).toEqual([]);
|
||||
expect(unwrapEvaluateResult([1, 2, 3])).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('handles deeply-nested shape: {result: {value: {result: {value: X}}}}', () => {
|
||||
// Only unwraps ONE layer — recursive unwrap would be wrong because
|
||||
// the page might legitimately have evaluated to {result: {value: X}}.
|
||||
expect(unwrapEvaluateResult({ result: { value: { result: { value: 9 } } } }))
|
||||
.toEqual({ result: { value: 9 } });
|
||||
});
|
||||
|
||||
it('regression: 1+1 case (the bug from 1.8.1)', () => {
|
||||
// Sanity: the path that broke in 1.8.1 (returned {}) now returns 2.
|
||||
expect(unwrapEvaluateResult({ result: { value: 2 } })).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user