@immediately-run/grove 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -115
- package/llms.txt +5 -2
- package/package.json +10 -8
- package/src/App.tsx +11 -7
- package/src/GroveApp.css +484 -152
- package/src/GroveWiki.tsx +105 -30
- package/src/components/AssetImage.tsx +1 -12
- package/src/components/Backlinks.tsx +2 -2
- package/src/components/Catalogue.test.tsx +157 -0
- package/src/components/ContentTheme.test.tsx +146 -0
- package/src/components/ContentTheme.tsx +111 -0
- package/src/components/DocList.infinite.test.tsx +177 -0
- package/src/components/DocList.tsx +82 -9
- package/src/components/Drawer.tsx +14 -2
- package/src/components/EntryHeader.tsx +23 -12
- package/src/components/EntryImage.test.tsx +210 -0
- package/src/components/EntryImage.tsx +47 -0
- package/src/components/Galleries.test.tsx +98 -0
- package/src/components/GroveAgent.test.tsx +330 -0
- package/src/components/GroveAgent.tsx +269 -150
- package/src/components/GroveNav.test.tsx +145 -5
- package/src/components/GroveNav.tsx +51 -8
- package/src/components/Icon.tsx +0 -1
- package/src/components/InlineProse.tsx +37 -0
- package/src/components/LayoutGallery.tsx +65 -0
- package/src/components/PageView.tsx +18 -3
- package/src/components/Search.test.tsx +113 -0
- package/src/components/Search.tsx +53 -18
- package/src/components/Sidebar.test.tsx +135 -0
- package/src/components/Sidebar.tsx +114 -12
- package/src/components/TableOfContents.test.tsx +27 -16
- package/src/components/ThemeAssets.test.tsx +109 -0
- package/src/components/ThemeAssets.tsx +63 -0
- package/src/components/ThemeGallery.tsx +31 -0
- package/src/components/Timeline.tsx +5 -2
- package/src/components/WikiLink.tsx +2 -2
- package/src/data/catalogue.ts +54 -0
- package/src/data/themeFonts.ts +58 -0
- package/src/data/themes.ts +16 -7
- package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
- package/src/hooks/useContentComponents.ts +1 -1
- package/src/hooks/useEditAffordance.ts +17 -4
- package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
- package/src/hooks/useOverlayFocusDismiss.ts +118 -0
- package/src/hooks/useScrollReset.test.tsx +132 -0
- package/src/hooks/useScrollReset.ts +52 -0
- package/src/index.css +9 -2
- package/src/lib/agentPrompt.test.ts +96 -0
- package/src/lib/agentPrompt.ts +86 -0
- package/src/lib/agentTools.test.ts +115 -0
- package/src/lib/agentTools.ts +132 -0
- package/src/lib/agentTranscript.ts +50 -0
- package/src/lib/assetPath.test.ts +36 -0
- package/src/lib/assetPath.ts +42 -0
- package/src/lib/collectionCalls.test.ts +69 -0
- package/src/lib/content.test.ts +10 -2
- package/src/lib/content.ts +7 -0
- package/src/lib/contentStylesheet.test.ts +80 -0
- package/src/lib/contentStylesheet.ts +103 -0
- package/src/lib/corpusScan.test.ts +37 -3
- package/src/lib/corpusScan.ts +17 -2
- package/src/lib/inlineProse.parity.test.ts +52 -0
- package/src/lib/layout.ts +29 -0
- package/src/lib/pageVariants.test.tsx +87 -0
- package/src/lib/queries.test.ts +33 -1
- package/src/lib/queries.ts +33 -1
- package/src/lib/reachCard.test.ts +94 -0
- package/src/lib/reachCard.ts +112 -0
- package/src/lib/shell.ts +7 -0
- package/src/lib/starterSweep.test.tsx +160 -0
- package/src/lib/starterSweep.ts +97 -0
- package/src/lib/themeAssets.test.ts +135 -0
- package/src/lib/themeAssets.ts +143 -0
- package/src/lib/themeSelection.test.ts +2 -2
- package/src/mdxComponents.ts +4 -0
- package/viewer-manifest.schema.json +37 -0
- package/viewer.manifest.json +132 -6
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// G-GA-1 / G-GA-2 — the surface itself: with `writable=false` and no provider, the
|
|
3
|
+
// DOM contains no write-flavored chip and no "proposes the edit"/"host confirms"
|
|
4
|
+
// copy; with `writable=false` but a configured, granted provider, Q&A is fully
|
|
5
|
+
// enabled (read-only NEVER blocks asking — R-GA-5). Provider/grant state is driven
|
|
6
|
+
// through the SDK's REAL push channels (an emitting stub transport), not mocks of
|
|
7
|
+
// the hooks — the component reads what the host would have said.
|
|
8
|
+
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
|
9
|
+
import { act } from 'react';
|
|
10
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
11
|
+
|
|
12
|
+
// `useHeadings` scans the DOM (no .grove-prose in this harness ⇒ no headings — fine).
|
|
13
|
+
// `fs` is read only through safeSources on the stuffing path, never in these cases.
|
|
14
|
+
vi.mock('fs', () => ({ default: { promises: { readFile: vi.fn(async () => '') } } }));
|
|
15
|
+
|
|
16
|
+
// runAgent is controllable for the stop-abort case (R3-608); everything else in
|
|
17
|
+
// the barrel stays real — the existing cases never invoke it.
|
|
18
|
+
const runAgentMock = vi.fn();
|
|
19
|
+
vi.mock('@immediately-run/sdk', async (importOriginal) => ({
|
|
20
|
+
...(await importOriginal<typeof import('@immediately-run/sdk')>()),
|
|
21
|
+
runAgent: (opts: unknown) => runAgentMock(opts),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// An EMITTING stub transport: push channels subscribe per type and tests emit
|
|
25
|
+
// host→app messages through it (the same wire the real host speaks).
|
|
26
|
+
// The transport contract (`hostTransport.ts`): `onMessage(handler)` receives EVERY
|
|
27
|
+
// host→app message; the SDK's addListener filters by `msg.type` itself.
|
|
28
|
+
type Handler = (msg: Record<string, unknown>) => void;
|
|
29
|
+
const handlers = new Set<Handler>();
|
|
30
|
+
const emit = (msg: Record<string, unknown>): void => {
|
|
31
|
+
for (const h of handlers) h(msg);
|
|
32
|
+
};
|
|
33
|
+
// Controllable per-case (the edit-refusal probe rejects through it); the
|
|
34
|
+
// default resolves like a permissive host.
|
|
35
|
+
const protocolRequest = vi.fn(async () => ({}));
|
|
36
|
+
(globalThis as { __immediatelyRun__?: unknown }).__immediatelyRun__ = {
|
|
37
|
+
transport: {
|
|
38
|
+
sendMessage: vi.fn(),
|
|
39
|
+
protocolRequest,
|
|
40
|
+
onMessage: (handler: Handler) => {
|
|
41
|
+
handlers.add(handler);
|
|
42
|
+
return { dispose: () => handlers.delete(handler) };
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
import type { GroveShell } from '../lib/shell';
|
|
48
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
49
|
+
const { default: GroveAgent } = await import('./GroveAgent');
|
|
50
|
+
const { GroveShellContext } = await import('../lib/shell');
|
|
51
|
+
|
|
52
|
+
const NAV = {
|
|
53
|
+
mode: 'github',
|
|
54
|
+
namespace: 'immediately-run',
|
|
55
|
+
provider: 'github',
|
|
56
|
+
repository: 'docs',
|
|
57
|
+
ref: 'main',
|
|
58
|
+
sandboxPath: '/app',
|
|
59
|
+
hash: '',
|
|
60
|
+
search: '',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const shell: GroveShell = { openEditor: vi.fn() } as unknown as GroveShell;
|
|
64
|
+
|
|
65
|
+
async function renderAgent(props: { writable: boolean }): Promise<{ root: Root; container: HTMLElement }> {
|
|
66
|
+
const container = document.createElement('div');
|
|
67
|
+
document.body.appendChild(container);
|
|
68
|
+
const root = createRoot(container);
|
|
69
|
+
await act(async () => {
|
|
70
|
+
root.render(
|
|
71
|
+
<GroveShellContext.Provider value={shell}>
|
|
72
|
+
<TinkerableContext.Provider
|
|
73
|
+
value={{ outerHref: '', navigationState: NAV, routingSpec: {} as never, filesMetadata: {} }}
|
|
74
|
+
>
|
|
75
|
+
<GroveAgent {...props} entryKey="/app/content/wiki/security.mdx" entryTitle="Security" />
|
|
76
|
+
</TinkerableContext.Provider>
|
|
77
|
+
</GroveShellContext.Provider>,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
return { root, container };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const openPanel = async (container: HTMLElement): Promise<void> => {
|
|
84
|
+
await act(async () => {
|
|
85
|
+
(container.querySelector('.ga-line input') as HTMLInputElement)?.focus();
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Drive host→app pushes the way the host would: after the app subscribed. */
|
|
90
|
+
const push = async (msg: Record<string, unknown>): Promise<void> => {
|
|
91
|
+
await act(async () => {
|
|
92
|
+
emit(msg);
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// NOTE: no per-test handler reset. The SDK's push channels are module-global and
|
|
97
|
+
// subscribe to the transport exactly once (the `started` flag in `createPushChannel`),
|
|
98
|
+
// so clearing the handler set would sever every later emit from the channels. Each
|
|
99
|
+
// test drives the FULL desired host state through its own pushes instead.
|
|
100
|
+
|
|
101
|
+
describe('G-GA-1 — no unbacked capability claims in the DOM', () => {
|
|
102
|
+
it('writable=false + no provider: no write-flavored chip, no phantom-write copy', async () => {
|
|
103
|
+
const { container } = await renderAgent({ writable: false });
|
|
104
|
+
await push({ type: 'llm-provider', provider: null }); // answered: not-configured
|
|
105
|
+
await push({ type: 'api-catalog', methods: [] }); // nothing granted
|
|
106
|
+
await openPanel(container);
|
|
107
|
+
const text = container.textContent ?? '';
|
|
108
|
+
expect(text).not.toMatch(/add an entry|fix broken links|reorganize the sidebar|add a timeline/i);
|
|
109
|
+
expect(text).not.toMatch(/proposes the edit|host confirms the write/i);
|
|
110
|
+
expect(container.querySelectorAll('.ga-chip').length).toBe(0); // no ✓ rows ⇒ no chips
|
|
111
|
+
// And the honest causes ARE there:
|
|
112
|
+
expect(text).toContain('no model key connected');
|
|
113
|
+
expect(text).toContain('you’re a reader here');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('G-GA-2 / R-GA-5 — read-only never blocks Q&A', () => {
|
|
118
|
+
it('writable=false + configured granted provider ⇒ input enabled, reach card says so', async () => {
|
|
119
|
+
const { container } = await renderAgent({ writable: false });
|
|
120
|
+
await push({
|
|
121
|
+
type: 'llm-provider',
|
|
122
|
+
provider: {
|
|
123
|
+
providerId: 'llm.chat.anthropic',
|
|
124
|
+
hostVouched: true,
|
|
125
|
+
features: { vision: false, tools: true, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
await push({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
|
|
129
|
+
await openPanel(container);
|
|
130
|
+
const input = container.querySelector('.ga-foot input') as HTMLInputElement;
|
|
131
|
+
expect(input.disabled).toBe(false);
|
|
132
|
+
const text = container.textContent ?? '';
|
|
133
|
+
expect(text).toContain('Answer questions about this wiki');
|
|
134
|
+
expect(text).toContain('you’re a reader here'); // draft row still honest
|
|
135
|
+
expect(container.textContent).not.toMatch(/Suggest an edit/i); // no write chip while read-only
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('an ungranted fork renders the DISTINCT forbidden cause, never connect-a-key copy', async () => {
|
|
139
|
+
const { container } = await renderAgent({ writable: true });
|
|
140
|
+
await push({
|
|
141
|
+
type: 'llm-provider',
|
|
142
|
+
provider: {
|
|
143
|
+
providerId: 'llm.chat.anthropic',
|
|
144
|
+
hostVouched: true,
|
|
145
|
+
features: { vision: false, tools: true, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
await push({ type: 'api-catalog', methods: [] }); // a key exists; this copy wasn't granted chat
|
|
149
|
+
await openPanel(container);
|
|
150
|
+
const text = container.textContent ?? '';
|
|
151
|
+
expect(text).toContain("this Grove wasn't granted chat — reading works as normal");
|
|
152
|
+
expect(text).not.toContain('add one in Settings');
|
|
153
|
+
const input = container.querySelector('.ga-foot input') as HTMLInputElement;
|
|
154
|
+
expect(input.disabled).toBe(true); // cannot ask — but reading works, and the card says why
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('R-GA-6 — the egress disclosure shows whenever a provider is bound', async () => {
|
|
158
|
+
const { container } = await renderAgent({ writable: false });
|
|
159
|
+
await push({
|
|
160
|
+
type: 'llm-provider',
|
|
161
|
+
provider: {
|
|
162
|
+
providerId: 'llm.chat.anthropic',
|
|
163
|
+
hostVouched: true,
|
|
164
|
+
features: { vision: false, tools: true, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
await push({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
|
|
168
|
+
await openPanel(container);
|
|
169
|
+
expect(container.textContent).toContain('answers come from your connected model provider');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('R3-608 — the composer stops the run; a refusal surfaces, a cancel does not', () => {
|
|
174
|
+
beforeAll(() => {
|
|
175
|
+
// jsdom lacks Element.scrollTo; the auto-scroll effect only needs to be a no-op.
|
|
176
|
+
HTMLElement.prototype.scrollTo = () => undefined as unknown as void;
|
|
177
|
+
});
|
|
178
|
+
const configured = {
|
|
179
|
+
type: 'llm-provider',
|
|
180
|
+
provider: {
|
|
181
|
+
providerId: 'llm.chat.anthropic',
|
|
182
|
+
hostVouched: true,
|
|
183
|
+
features: { vision: false, tools: true, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
|
|
184
|
+
},
|
|
185
|
+
} as const;
|
|
186
|
+
const granted = {
|
|
187
|
+
type: 'api-catalog',
|
|
188
|
+
methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }],
|
|
189
|
+
} as const;
|
|
190
|
+
|
|
191
|
+
it('stop aborts the in-flight run — signal aborted, streaming cleared, a Stopped row, no error', async () => {
|
|
192
|
+
// The REAL loop contract on abort: the promise RESOLVES with the partial
|
|
193
|
+
// transcript (a clean stop, never a thrown error) — the mock mirrors it.
|
|
194
|
+
runAgentMock.mockImplementationOnce(
|
|
195
|
+
(opts: { signal?: AbortSignal }) =>
|
|
196
|
+
new Promise((resolve) => {
|
|
197
|
+
opts.signal?.addEventListener('abort', () =>
|
|
198
|
+
resolve([
|
|
199
|
+
{ role: 'user', content: 'what is here?' },
|
|
200
|
+
{ role: 'assistant', content: 'partial answer' },
|
|
201
|
+
]),
|
|
202
|
+
);
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
const { container } = await renderAgent({ writable: false });
|
|
206
|
+
await push(configured);
|
|
207
|
+
await push(granted);
|
|
208
|
+
await openPanel(container);
|
|
209
|
+
const input = container.querySelector('.ga-foot input') as HTMLInputElement;
|
|
210
|
+
await act(async () => {
|
|
211
|
+
// The native value setter: React's tracker ignores a bare .value assign.
|
|
212
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!;
|
|
213
|
+
setter.call(input, 'what is here?');
|
|
214
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
215
|
+
});
|
|
216
|
+
await act(async () => {
|
|
217
|
+
(container.querySelector('.ga-foot .go') as HTMLElement).click();
|
|
218
|
+
});
|
|
219
|
+
// Streaming: the stop control exists and the send is spent.
|
|
220
|
+
const stop = container.querySelector('.ga-foot .stop') as HTMLElement;
|
|
221
|
+
expect(stop).toBeTruthy();
|
|
222
|
+
let signal: AbortSignal | undefined;
|
|
223
|
+
runAgentMock.mock.calls.forEach((c) => {
|
|
224
|
+
signal = (c[0] as { signal?: AbortSignal }).signal;
|
|
225
|
+
});
|
|
226
|
+
expect(signal).toBeTruthy();
|
|
227
|
+
await act(async () => {
|
|
228
|
+
stop.click();
|
|
229
|
+
});
|
|
230
|
+
expect(signal!.aborted).toBe(true);
|
|
231
|
+
expect(container.textContent).toContain('Stopped');
|
|
232
|
+
expect(container.textContent).not.toContain('the model or backend errored');
|
|
233
|
+
expect(container.querySelector('.ga-foot .stop')).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('a REFUSING edit renders the failure text; a cancelled one renders nothing', async () => {
|
|
237
|
+
// The real hook, through the real requestEdit → the controllable transport.
|
|
238
|
+
const { useEditAffordance } = await import('../hooks/useEditAffordance');
|
|
239
|
+
const refusedStates: boolean[] = [];
|
|
240
|
+
function Probe() {
|
|
241
|
+
const aff = useEditAffordance(false);
|
|
242
|
+
refusedStates.push(aff.refused);
|
|
243
|
+
return (
|
|
244
|
+
<div>
|
|
245
|
+
<button onClick={() => aff.openEditor('/app/content/wiki/security.mdx')}>edit</button>
|
|
246
|
+
<div data-testid="refused">{aff.refused ? 'REFUSED' : ''}</div>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const host = document.createElement('div');
|
|
251
|
+
document.body.appendChild(host);
|
|
252
|
+
const root = createRoot(host);
|
|
253
|
+
await act(async () => {
|
|
254
|
+
root.render(<Probe />);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// A refusal (anything not `cancelled`): the host said no — it must surface.
|
|
258
|
+
protocolRequest.mockRejectedValueOnce(Object.assign(new Error('denied'), { code: 'forbidden' }));
|
|
259
|
+
await act(async () => {
|
|
260
|
+
(host.querySelector('button') as HTMLElement).click();
|
|
261
|
+
});
|
|
262
|
+
expect(host.querySelector('[data-testid="refused"]')?.textContent).toBe('REFUSED');
|
|
263
|
+
|
|
264
|
+
// A cancellation: the reader closed the editor — silent by contract.
|
|
265
|
+
await act(async () => {
|
|
266
|
+
root.render(<div />);
|
|
267
|
+
root.render(<Probe />);
|
|
268
|
+
});
|
|
269
|
+
refusedStates.length = 0;
|
|
270
|
+
protocolRequest.mockRejectedValueOnce(Object.assign(new Error('closed'), { code: 'cancelled' }));
|
|
271
|
+
await act(async () => {
|
|
272
|
+
(host.querySelector('button') as HTMLElement).click();
|
|
273
|
+
});
|
|
274
|
+
expect(host.querySelector('[data-testid="refused"]')?.textContent).toBe('');
|
|
275
|
+
await act(async () => {
|
|
276
|
+
root.unmount();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('the agent panel renders the refusal text where the affordance is offered', async () => {
|
|
281
|
+
const host = document.createElement('div');
|
|
282
|
+
document.body.appendChild(host);
|
|
283
|
+
const root = createRoot(host);
|
|
284
|
+
const refusingShell: GroveShell = { openEditor: vi.fn(), editRefused: true } as unknown as GroveShell;
|
|
285
|
+
await act(async () => {
|
|
286
|
+
root.render(
|
|
287
|
+
<GroveShellContext.Provider value={refusingShell}>
|
|
288
|
+
<TinkerableContext.Provider
|
|
289
|
+
value={{ outerHref: '', navigationState: NAV, routingSpec: {} as never, filesMetadata: {} }}
|
|
290
|
+
>
|
|
291
|
+
<GroveAgent writable={false} entryKey="/app/content/wiki/security.mdx" entryTitle="Security" />
|
|
292
|
+
</TinkerableContext.Provider>
|
|
293
|
+
</GroveShellContext.Provider>,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
await openPanel(host);
|
|
297
|
+
expect(host.textContent).toContain('Could not open the editor — the host refused');
|
|
298
|
+
await act(async () => {
|
|
299
|
+
root.unmount();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('R3-608 round 2 — the panel returns focus to its (unmounted) trigger', () => {
|
|
305
|
+
it('Escape-close lands focus back on the resting input, not <body>', async () => {
|
|
306
|
+
const { container } = await renderAgent({ writable: false });
|
|
307
|
+
await push({ type: 'llm-provider', provider: null });
|
|
308
|
+
await push({ type: 'api-catalog', methods: [] });
|
|
309
|
+
// Open through the resting input (the real trigger path).
|
|
310
|
+
const resting = container.querySelector('.ga-line input') as HTMLInputElement;
|
|
311
|
+
await act(async () => {
|
|
312
|
+
resting.focus();
|
|
313
|
+
});
|
|
314
|
+
expect(container.querySelector('.ga-panel')).toBeTruthy();
|
|
315
|
+
await act(async () => {
|
|
316
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
|
317
|
+
});
|
|
318
|
+
expect(container.querySelector('.ga-panel')).toBeNull();
|
|
319
|
+
// The focus return rides a requestAnimationFrame — flush the frame.
|
|
320
|
+
await act(async () => {
|
|
321
|
+
await new Promise((r) => setTimeout(r, 16));
|
|
322
|
+
});
|
|
323
|
+
// The resting line remounted and its SUBMIT control took focus — a real
|
|
324
|
+
// stop that cannot reopen the panel, never <body>.
|
|
325
|
+
const remounted = container.querySelector('.ga-line .go') as HTMLButtonElement;
|
|
326
|
+
expect(remounted).toBeTruthy();
|
|
327
|
+
expect(document.activeElement).toBe(remounted);
|
|
328
|
+
expect(container.querySelector('.ga-panel')).toBeNull(); // focus did not reopen
|
|
329
|
+
});
|
|
330
|
+
});
|