|
| 1 | +/** |
| 2 | + * Feature assertion: the command palette's "Recently used" group. |
| 3 | + * |
| 4 | + * Drives the real built app over CDP in the draft (no-session) state — no |
| 5 | + * seeded conversation and no backend. It proves the full recents lifecycle: |
| 6 | + * seed one recent → it renders in a dedicated "Recently used" group → |
| 7 | + * a search query hides the group → clearing restores it → running a *new* |
| 8 | + * action from the palette records it and, on reopen, it is the top recent. |
| 9 | + * |
| 10 | + * The reopen step is the load-bearing one: reading the palette after running |
| 11 | + * an action proves the palette actually *persists* what you run and surfaces |
| 12 | + * it newest-first — not merely that a seeded list can render. |
| 13 | + */ |
| 14 | + |
| 15 | +import type { Assertion } from '../runner'; |
| 16 | + |
| 17 | +const PALETTE = '[data-testid="command-palette"]'; |
| 18 | +const INPUT = '[data-testid="command-palette-input"]'; |
| 19 | +const ITEM = '[data-testid="command-palette-item"]'; |
| 20 | +const RECENT = '[data-testid="command-palette-recent-item"]'; |
| 21 | + |
| 22 | +/** localStorage key the palette reads recents from (shared helper's `craft-` prefix). */ |
| 23 | +const RECENTS_KEY = 'craft-command-palette-recents'; |
| 24 | + |
| 25 | +/** Elements actually visible (cmdk keeps filtered rows in the DOM but hidden). */ |
| 26 | +function visibleExpr(selector: string): string { |
| 27 | + return `[...document.querySelectorAll(${JSON.stringify(selector)})].filter(el => el.offsetParent !== null)`; |
| 28 | +} |
| 29 | + |
| 30 | +/** Visible text of each matching row, in DOM order. */ |
| 31 | +function textsExpr(selector: string): string { |
| 32 | + return `${visibleExpr(selector)}.map(el => (el.textContent || '').trim())`; |
| 33 | +} |
| 34 | + |
| 35 | +/** Set a controlled input's value the way React/cmdk expect (native setter + input event). */ |
| 36 | +function setInputExpr(value: string): string { |
| 37 | + return `(() => { |
| 38 | + const input = document.querySelector(${JSON.stringify(INPUT)}); |
| 39 | + if (!input) return false; |
| 40 | + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; |
| 41 | + setter.call(input, ${JSON.stringify(value)}); |
| 42 | + input.dispatchEvent(new Event('input', { bubbles: true })); |
| 43 | + return true; |
| 44 | + })()`; |
| 45 | +} |
| 46 | + |
| 47 | +/** Dispatch the Cmd/Ctrl+K palette hotkey at the window level (both modifiers for cross-platform). */ |
| 48 | +const OPEN_PALETTE_EXPR = `(() => { |
| 49 | + window.dispatchEvent(new KeyboardEvent('keydown', { |
| 50 | + key: 'k', code: 'KeyK', ctrlKey: true, metaKey: true, bubbles: true, cancelable: true, |
| 51 | + })); |
| 52 | + return true; |
| 53 | +})()`; |
| 54 | + |
| 55 | +const assertion: Assertion = { |
| 56 | + name: 'command palette surfaces and records recently-used actions', |
| 57 | + async run(app) { |
| 58 | + const { session } = app; |
| 59 | + |
| 60 | + // App must be mounted and at the ready AppShell state. |
| 61 | + await session.waitForFunction( |
| 62 | + '!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0', |
| 63 | + { timeoutMs: 30000, message: 'React UI did not mount' }, |
| 64 | + ); |
| 65 | + await session.waitForSelector('[aria-label="Craft menu"]', { |
| 66 | + timeoutMs: 30000, |
| 67 | + message: 'app did not reach the ready AppShell state', |
| 68 | + }); |
| 69 | + |
| 70 | + // 1. Seed a single recent action. The palette reads recents from |
| 71 | + // localStorage each time it opens, so no reload is needed. |
| 72 | + const seeded = await session.evaluate<boolean>(`(() => { |
| 73 | + localStorage.setItem(${JSON.stringify(RECENTS_KEY)}, JSON.stringify(['app.toggleTheme'])); |
| 74 | + return true; |
| 75 | + })()`); |
| 76 | + if (!seeded) throw new Error('failed to seed command-palette recents'); |
| 77 | + |
| 78 | + // 2. Open the palette; the "Recently used" group shows exactly the seeded action. |
| 79 | + if (!(await session.evaluate<boolean>(OPEN_PALETTE_EXPR))) { |
| 80 | + throw new Error('failed to dispatch command-palette hotkey'); |
| 81 | + } |
| 82 | + await session.waitForSelector(PALETTE, { |
| 83 | + timeoutMs: 8000, |
| 84 | + message: 'command palette did not open on Ctrl/Cmd+K', |
| 85 | + }); |
| 86 | + await session.waitForFunction(`${visibleExpr(RECENT)}.length === 1`, { |
| 87 | + timeoutMs: 5000, |
| 88 | + message: 'seeded "Recently used" group did not render exactly one row', |
| 89 | + }); |
| 90 | + const firstRecent = await session.evaluate<string[]>(textsExpr(RECENT)); |
| 91 | + if (!/toggle theme/i.test(firstRecent[0] || '')) { |
| 92 | + throw new Error(`expected seeded recent to be "Toggle Theme", got ${JSON.stringify(firstRecent)}`); |
| 93 | + } |
| 94 | + |
| 95 | + // 2b. The recent row renders ABOVE the first category row. |
| 96 | + const recentIsFirst = await session.evaluate<boolean>(`(() => { |
| 97 | + const recent = ${visibleExpr(RECENT)}[0]; |
| 98 | + const item = ${visibleExpr(ITEM)}[0]; |
| 99 | + if (!recent || !item) return false; |
| 100 | + return !!(recent.compareDocumentPosition(item) & Node.DOCUMENT_POSITION_FOLLOWING); |
| 101 | + })()`); |
| 102 | + if (!recentIsFirst) { |
| 103 | + throw new Error('"Recently used" group did not render above the first category'); |
| 104 | + } |
| 105 | + |
| 106 | + // 3. Typing a query hides the recents group (recency yields to relevance), |
| 107 | + // while normal filtering still works (a matching category row survives). |
| 108 | + await session.evaluate(setInputExpr('sidebar')); |
| 109 | + await session.waitForFunction(`${visibleExpr(RECENT)}.length === 0`, { |
| 110 | + timeoutMs: 5000, |
| 111 | + message: 'recents group did not hide while searching', |
| 112 | + }); |
| 113 | + await session.waitForFunction( |
| 114 | + `${visibleExpr(ITEM)}.some(el => /toggle sidebar/i.test(el.textContent || ''))`, |
| 115 | + { timeoutMs: 5000, message: 'expected a "Toggle Sidebar" match while filtering by "sidebar"' }, |
| 116 | + ); |
| 117 | + |
| 118 | + // 4. Clearing the query brings the recents group back. |
| 119 | + await session.evaluate(setInputExpr('')); |
| 120 | + await session.waitForFunction(`${visibleExpr(RECENT)}.length === 1`, { |
| 121 | + timeoutMs: 5000, |
| 122 | + message: 'recents group did not reappear after clearing the query', |
| 123 | + }); |
| 124 | + |
| 125 | + // 5. Run a *new* (not-yet-recent) action from the palette: Toggle Sidebar. |
| 126 | + const clicked = await session.evaluate<boolean>(`(() => { |
| 127 | + const el = ${visibleExpr(ITEM)}.find(el => /toggle sidebar/i.test(el.textContent || '')); |
| 128 | + if (!el) return false; |
| 129 | + el.click(); |
| 130 | + return true; |
| 131 | + })()`); |
| 132 | + if (!clicked) throw new Error('could not click the "Toggle Sidebar" row'); |
| 133 | + await session.waitForFunction(`!document.querySelector(${JSON.stringify(PALETTE)})`, { |
| 134 | + timeoutMs: 5000, |
| 135 | + message: 'palette did not close after running an action', |
| 136 | + }); |
| 137 | + |
| 138 | + // 6. Reopen: the just-run action is now the top recent, ahead of the seed — |
| 139 | + // proving the palette recorded it and orders recents newest-first. |
| 140 | + if (!(await session.evaluate<boolean>(OPEN_PALETTE_EXPR))) { |
| 141 | + throw new Error('failed to reopen the command palette'); |
| 142 | + } |
| 143 | + await session.waitForSelector(PALETTE, { |
| 144 | + timeoutMs: 8000, |
| 145 | + message: 'command palette did not reopen', |
| 146 | + }); |
| 147 | + await session.waitForFunction(`${visibleExpr(RECENT)}.length === 2`, { |
| 148 | + timeoutMs: 5000, |
| 149 | + message: 'recents group did not grow to two rows after running a new action', |
| 150 | + }); |
| 151 | + const recents = await session.evaluate<string[]>(textsExpr(RECENT)); |
| 152 | + if (!/toggle sidebar/i.test(recents[0] || '')) { |
| 153 | + throw new Error(`expected "Toggle Sidebar" as the top recent, got ${JSON.stringify(recents)}`); |
| 154 | + } |
| 155 | + if (!/toggle theme/i.test(recents[1] || '')) { |
| 156 | + throw new Error(`expected "Toggle Theme" as the second recent, got ${JSON.stringify(recents)}`); |
| 157 | + } |
| 158 | + }, |
| 159 | +}; |
| 160 | + |
| 161 | +export default assertion; |
0 commit comments