From 986c5e1cb059c2b0d5c8f87f3fa5e9f3ee7db455 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:09:56 -0400 Subject: [PATCH] test(ext): failing-test scaffold for org read UI (Plan B Tasks 1-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing tests written against Dev-A's published org SW message contract (org_list_configs / org_switch / org_list_items / org_get_item / org_list_collections), plus compile-clean stubs. vitest: 6 fail / 3 pass — the 3 passes are A-independent (currentContext default + org reflect, personal "+ new" kept); the 6 fails are exactly the org behaviors HELD until Dev-A merges to main. Integration (flip-on at Dev-A merge): - org-context messageForList/messageForGet org branches (org_list_items/org_get_item) - org-switcher org_list_configs fetch + org_switch dispatch + list reload + offline banner - item-list org initial-load-on-render (org_list_items) + hide "+ new" in org context - collection facet populate from org_list_collections + click-to-filter Scaffold contents: - shared/org-context.ts (+__tests__): currentContext complete; messageFor* personal-only (HOLD) - shared/popup-state.ts: optional orgContext / orgOffline fields (non-breaking; A-typed orgConfigs/orgCollections join at integration) - popup/components/org-switcher.ts (+__tests__): select shell only (HOLD switch + Task 5 banner) - popup/components/__tests__/item-list.test.ts: org load + read-only (+ new hidden) assertions - vault/vault-sidebar.ts renderCollectionFacet (+__tests__): empty container (HOLD) Read-only this plan: write affordances stay hidden (Dev-C adds them). No org-message-type references in src — branch stays compile-clean; build:all type-check is the integration gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W3b8DA2mXmLWkd4zDbHXHg --- .../components/__tests__/item-list.test.ts | 63 +++++++++++++++++++ .../components/__tests__/org-switcher.test.ts | 62 ++++++++++++++++++ .../src/popup/components/org-switcher.ts | 25 ++++++++ .../src/shared/__tests__/org-context.test.ts | 37 +++++++++++ extension/src/shared/org-context.ts | 28 +++++++++ extension/src/shared/popup-state.ts | 7 +++ .../vault/__tests__/collection-facet.test.ts | 41 ++++++++++++ extension/src/vault/vault-sidebar.ts | 23 +++++++ 8 files changed, 286 insertions(+) create mode 100644 extension/src/popup/components/__tests__/item-list.test.ts create mode 100644 extension/src/popup/components/__tests__/org-switcher.test.ts create mode 100644 extension/src/popup/components/org-switcher.ts create mode 100644 extension/src/shared/__tests__/org-context.test.ts create mode 100644 extension/src/shared/org-context.ts create mode 100644 extension/src/vault/__tests__/collection-facet.test.ts diff --git a/extension/src/popup/components/__tests__/item-list.test.ts b/extension/src/popup/components/__tests__/item-list.test.ts new file mode 100644 index 0000000..cd8fcd7 --- /dev/null +++ b/extension/src/popup/components/__tests__/item-list.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// item-list.ts imports getState/setState/sendMessage/navigate/escapeHtml/openVaultTab +// from shared/state — mock the whole module (the established component-test idiom). +vi.mock('../../../shared/state', () => ({ + getState: vi.fn(() => ({ orgContext: 'personal', entries: [], searchQuery: '', selectedIndex: 0 })), + setState: vi.fn(), + sendMessage: vi.fn().mockResolvedValue({ ok: true, data: { items: [] } }), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), +})); + +import { renderItemList } from '../item-list'; +import { getState, sendMessage } from '../../../shared/state'; + +const mockGetState = getState as ReturnType; +const send = sendMessage as ReturnType; + +describe('popup/item-list in org context', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = '
'; + }); + + // Task 3 — list loads via the context-aware message helper. In an org + // context the initial render must populate the list from `org_list_items` + // (the personal entries don't carry over across an org_switch). + it('loads org items (grant-filtered) when context is an org', async () => { + mockGetState.mockReturnValue({ orgContext: 'org-1', entries: [], searchQuery: '', selectedIndex: 0 }); + send.mockResolvedValue({ ok: true, data: [{ id: 'a', title: 'db', collection: 'prod-infra', modified: 1 }] }); + + renderItemList(document.getElementById('app')!); + await Promise.resolve(); + await Promise.resolve(); + + expect(send).toHaveBeenCalledWith({ type: 'org_list_items' }); + }); + + // Task 3 — read-only this plan: no add affordance in org context. Dev-C's + // write plan re-introduces it. + it('hides the "+ new" affordance in org context', () => { + mockGetState.mockReturnValue({ orgContext: 'org-1', entries: [], searchQuery: '', selectedIndex: 0 }); + const app = document.getElementById('app')!; + + renderItemList(app); + + expect(app.querySelector('#new-btn')).toBeNull(); + }); + + // Personal path is unchanged: render shows the add affordance and does not + // auto-fire a list load. + it('keeps the "+ new" affordance in personal context', () => { + mockGetState.mockReturnValue({ orgContext: 'personal', entries: [], searchQuery: '', selectedIndex: 0 }); + const app = document.getElementById('app')!; + + renderItemList(app); + + expect(app.querySelector('#new-btn')).not.toBeNull(); + }); +}); diff --git a/extension/src/popup/components/__tests__/org-switcher.test.ts b/extension/src/popup/components/__tests__/org-switcher.test.ts new file mode 100644 index 0000000..89b4069 --- /dev/null +++ b/extension/src/popup/components/__tests__/org-switcher.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../shared/state', () => ({ + getState: vi.fn(() => ({ orgContext: 'personal', orgConfigs: [], orgOffline: false })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), +})); + +import { renderOrgSwitcher } from '../org-switcher'; +import { sendMessage, navigate } from '../../../shared/state'; + +const send = sendMessage as ReturnType; +const nav = navigate as ReturnType; + +describe('popup/org-switcher', () => { + let host: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = '
'; + host = document.getElementById('host')!; + }); + + // Task 2 — switching contexts. + it('switching to an org sends org_switch and reloads the list', async () => { + send.mockImplementation(async (req: { type: string }) => { + if (req.type === 'org_list_configs') return { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] }; + if (req.type === 'org_switch') return { ok: true, data: { context: 'org-1', offline: false } }; + return { ok: true, data: [] }; + }); + + await renderOrgSwitcher(host); + const sel = host.querySelector('select') as HTMLSelectElement; + sel.value = 'org-1'; + sel.dispatchEvent(new Event('change')); + await Promise.resolve(); + + expect(send).toHaveBeenCalledWith({ type: 'org_switch', context: 'org-1' }); + expect(nav).toHaveBeenCalledWith('list', expect.anything()); + }); + + // Task 5 — offline read-only banner. + it('offline org_switch renders the writes-disabled banner', async () => { + send.mockImplementation(async (req: { type: string }) => + req.type === 'org_switch' ? { ok: true, data: { context: 'org-1', offline: true } } + : req.type === 'org_list_configs' ? { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] } + : { ok: true, data: [] }); + + await renderOrgSwitcher(host); + const sel = host.querySelector('select') as HTMLSelectElement; + sel.value = 'org-1'; + sel.dispatchEvent(new Event('change')); + await Promise.resolve(); + + expect(host.textContent).toContain('org offline — writes disabled'); + }); +}); diff --git a/extension/src/popup/components/org-switcher.ts b/extension/src/popup/components/org-switcher.ts new file mode 100644 index 0000000..2aca8db --- /dev/null +++ b/extension/src/popup/components/org-switcher.ts @@ -0,0 +1,25 @@ +// Org context switcher + offline banner (Plan B Tasks 2 & 5). +// +// A Personal / ' + + '' + + ''; +} + +export function teardown(): void { + // HOLD(dev-a): detach the change listener once renderOrgSwitcher wires it. +} diff --git a/extension/src/shared/__tests__/org-context.test.ts b/extension/src/shared/__tests__/org-context.test.ts new file mode 100644 index 0000000..fe6ee28 --- /dev/null +++ b/extension/src/shared/__tests__/org-context.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Drive getState() so we can flip the current context per assertion. The +// `vi.mock` idiom (vs vi.spyOn on an ESM namespace) is the established +// component-test pattern in this codebase and is reliable across modules. +vi.mock('../state', () => ({ + getState: vi.fn(), +})); + +import { currentContext, messageForList, messageForGet } from '../org-context'; +import { getState } from '../state'; + +const mockGetState = getState as ReturnType; + +describe('shared/org-context: context-aware SW message selection', () => { + beforeEach(() => vi.clearAllMocks()); + + it('currentContext defaults to personal when orgContext is unset', () => { + mockGetState.mockReturnValue({}); + expect(currentContext()).toBe('personal'); + }); + + it('currentContext reflects the active org', () => { + mockGetState.mockReturnValue({ orgContext: 'org-1' }); + expect(currentContext()).toBe('org-1'); + }); + + it('messageForList/Get pick personal vs org by current context', () => { + mockGetState.mockReturnValue({ orgContext: 'personal' }); + expect(messageForList()).toEqual({ type: 'list_items' }); + expect(messageForGet('x')).toEqual({ type: 'get_item', id: 'x' }); + + mockGetState.mockReturnValue({ orgContext: 'org-1' }); + expect(messageForList()).toEqual({ type: 'org_list_items' }); + expect(messageForGet('x')).toEqual({ type: 'org_get_item', id: 'x' }); + }); +}); diff --git a/extension/src/shared/org-context.ts b/extension/src/shared/org-context.ts new file mode 100644 index 0000000..388745c --- /dev/null +++ b/extension/src/shared/org-context.ts @@ -0,0 +1,28 @@ +// Context-aware SW message selection (Plan B Task 1). +// +// Single source of truth for "which SW message does the current context use": +// the personal vault sends `list_items`/`get_item`; an org context sends the +// org-scoped equivalents. The list + detail views consume these so neither has +// to branch on context itself. +// +// SCAFFOLD STATE (HOLD until Dev-A merges): `currentContext()` is complete. +// `messageForList`/`messageForGet` return the PERSONAL message only; the org +// branch is held until Dev-A lands `org_list_items` / `org_get_item` in the +// `shared/messages.ts` Request union. Flip the two HOLD lines at integration. + +import { getState } from './state'; +import type { Request } from './messages'; + +export function currentContext(): 'personal' | string { + return getState().orgContext ?? 'personal'; +} + +export function messageForList(): Request { + // HOLD(dev-a): in org context return { type: 'org_list_items' }. + return { type: 'list_items' }; +} + +export function messageForGet(id: string): Request { + // HOLD(dev-a): in org context return { type: 'org_get_item', id }. + return { type: 'get_item', id }; +} diff --git a/extension/src/shared/popup-state.ts b/extension/src/shared/popup-state.ts index 09e94e7..dfee3e6 100644 --- a/extension/src/shared/popup-state.ts +++ b/extension/src/shared/popup-state.ts @@ -47,6 +47,13 @@ export interface PopupState { vaultSettings: VaultSettings | null; generatorDefaults: GeneratorRequest | null; historyItemId: ItemId | null; + // Org (enterprise) context — Plan B (v0.9.0). `orgContext` is 'personal' or an + // orgId; `orgOffline` flags a degraded read-only org (set by org_switch). Optional + // so existing personal-only state constructions need no change; currentContext() + // (shared/org-context.ts) falls back to 'personal'. orgConfigs/orgCollections + // (Dev-A's OrgConfigSummary[]/Collection[]) join at org-track integration. + orgContext?: 'personal' | string; + orgOffline?: boolean; // Vault-tab-only fields. The popup surface leaves these at their defaults // (unlocked=false implicit via separate lock-screen view, drawer/panel false). // Kept on the shared shape so VaultState satisfies StateHost.getState() diff --git a/extension/src/vault/__tests__/collection-facet.test.ts b/extension/src/vault/__tests__/collection-facet.test.ts new file mode 100644 index 0000000..9702df8 --- /dev/null +++ b/extension/src/vault/__tests__/collection-facet.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// renderCollectionFacet reads getState().orgCollections (shared/state). vault-sidebar +// pulls in vault-context/vault-status/glyphs transitively; none have import-time +// side effects, so importing it under happy-dom is safe. +vi.mock('../../shared/state', () => ({ + getState: vi.fn(() => ({ orgContext: 'personal', orgCollections: [] })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), +})); + +import { renderCollectionFacet } from '../vault-sidebar'; +import { getState } from '../../shared/state'; + +const mockGetState = getState as ReturnType; + +describe('vault collection facet (org context)', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = '
'; + }); + + // Task 4 — a collection nav parallel to the type-category nav, populated from + // org_list_collections, shown only in org context. + it('renders a collection facet from org_list_collections', () => { + mockGetState.mockReturnValue({ + orgContext: 'org-1', + orgCollections: [{ slug: 'prod-infra', display_name: 'Production Infra' }], + }); + const el = document.getElementById('facet')!; + + renderCollectionFacet(el); + + expect(el.textContent).toContain('Production Infra'); + }); +}); diff --git a/extension/src/vault/vault-sidebar.ts b/extension/src/vault/vault-sidebar.ts index 3004467..aebeb48 100644 --- a/extension/src/vault/vault-sidebar.ts +++ b/extension/src/vault/vault-sidebar.ts @@ -13,6 +13,7 @@ import { renderStatusIndicator, type VaultStatus } from './vault-status'; import { type VaultController, typeIcon, typeLabel, getFilteredEntries, } from './vault-context'; +import { getState } from '../shared/state'; const SEARCH_DEBOUNCE_MS = 80; @@ -188,3 +189,25 @@ export function renderSidebarCategories(ctx: VaultController): void { }); }); } + +// --------------------------------------------------------------------------- +// Org collection facet (Plan B Task 4) +// --------------------------------------------------------------------------- + +/// Render a collection nav (parallel to the type-category nav) into `host`, +/// shown only in org context. Selecting a collection filters the org list to +/// that slug. +/// +/// SCAFFOLD STATE (HOLD until Dev-A merges): renders the empty container only. +/// Populating from getState().orgCollections, the click-to-filter wiring, and +/// the org_list_collections fetch on org_switch are held until Dev-A's org +/// messages land. +export function renderCollectionFacet(host: HTMLElement): void { + // Inline shape until orgCollections joins PopupState at integration (it needs + // Dev-A's Collection type). HOLD(dev-a): render each as a vault-category-row + // mirroring renderSidebarCategories, wired to set a collection filter. + const collections = + (getState() as { orgCollections?: Array<{ slug: string; display_name: string }> }).orgCollections ?? []; + host.innerHTML = + ``; +}