Files
zeekay eb64196297 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.
2026-05-07 17:52:58 -07:00

152 lines
6.0 KiB
TypeScript

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);
});
});