test(ext): failing-test scaffold for org read UI (Plan B Tasks 1-5)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W3b8DA2mXmLWkd4zDbHXHg
This commit is contained in:
63
extension/src/popup/components/__tests__/item-list.test.ts
Normal file
63
extension/src/popup/components/__tests__/item-list.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||||
|
const send = sendMessage as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
describe('popup/item-list in org context', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn>;
|
||||||
|
const nav = navigate as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
describe('popup/org-switcher', () => {
|
||||||
|
let host: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = '<div id="host"></div>';
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
25
extension/src/popup/components/org-switcher.ts
Normal file
25
extension/src/popup/components/org-switcher.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Org context switcher + offline banner (Plan B Tasks 2 & 5).
|
||||||
|
//
|
||||||
|
// A Personal / <org>… <select> mounted in the popup header and the vault
|
||||||
|
// sidebar header. Selecting an org sends `org_switch`, records the new context
|
||||||
|
// (and its offline flag) into shared state, and reloads the list. An offline
|
||||||
|
// org renders a read-only banner; Dev-C's write UI reads `orgOffline` to
|
||||||
|
// disable writes.
|
||||||
|
//
|
||||||
|
// SCAFFOLD STATE (HOLD until Dev-A merges): renders the select shell only. The
|
||||||
|
// `org_list_configs` fetch, the `org_switch` dispatch + list reload, and the
|
||||||
|
// offline banner are held until Dev-A's org messages land in the Request union.
|
||||||
|
|
||||||
|
export async function renderOrgSwitcher(host: HTMLElement): Promise<void> {
|
||||||
|
// HOLD(dev-a): fetch org_list_configs -> append an <option> per org; on change
|
||||||
|
// send org_switch, setState({ orgContext, orgOffline }), navigate('list', {}),
|
||||||
|
// and render the "org offline — writes disabled" banner when data.offline.
|
||||||
|
host.innerHTML =
|
||||||
|
'<select class="org-switcher" aria-label="Vault context">' +
|
||||||
|
'<option value="personal">Personal</option>' +
|
||||||
|
'</select>';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardown(): void {
|
||||||
|
// HOLD(dev-a): detach the change listener once renderOrgSwitcher wires it.
|
||||||
|
}
|
||||||
37
extension/src/shared/__tests__/org-context.test.ts
Normal file
37
extension/src/shared/__tests__/org-context.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
28
extension/src/shared/org-context.ts
Normal file
28
extension/src/shared/org-context.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -47,6 +47,13 @@ export interface PopupState {
|
|||||||
vaultSettings: VaultSettings | null;
|
vaultSettings: VaultSettings | null;
|
||||||
generatorDefaults: GeneratorRequest | null;
|
generatorDefaults: GeneratorRequest | null;
|
||||||
historyItemId: ItemId | 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
|
// Vault-tab-only fields. The popup surface leaves these at their defaults
|
||||||
// (unlocked=false implicit via separate lock-screen view, drawer/panel false).
|
// (unlocked=false implicit via separate lock-screen view, drawer/panel false).
|
||||||
// Kept on the shared shape so VaultState satisfies StateHost.getState()
|
// Kept on the shared shape so VaultState satisfies StateHost.getState()
|
||||||
|
|||||||
41
extension/src/vault/__tests__/collection-facet.test.ts
Normal file
41
extension/src/vault/__tests__/collection-facet.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
describe('vault collection facet (org context)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = '<div id="facet"></div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import { renderStatusIndicator, type VaultStatus } from './vault-status';
|
|||||||
import {
|
import {
|
||||||
type VaultController, typeIcon, typeLabel, getFilteredEntries,
|
type VaultController, typeIcon, typeLabel, getFilteredEntries,
|
||||||
} from './vault-context';
|
} from './vault-context';
|
||||||
|
import { getState } from '../shared/state';
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 80;
|
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 =
|
||||||
|
`<nav class="vault-sidebar__collections" aria-label="Collections" data-count="${collections.length}"></nav>`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user