UNPKG

@accounter/scraper-app

Version:
857 lines (671 loc) 32.8 kB
# Vault Download & Upload — Implementation Prompts Reference plan: [`vault-download-upload.md`](./vault-download-upload.md) These prompts are ordered so each one compiles and passes tests before the next begins. No step leaves orphaned code. Each prompt is self-contained — paste it verbatim into the LLM. --- ## Blueprint ``` Step 1 — Server: GET /api/vault/env-path (trivial, no auth, safe foundation) Step 2 — Server: GET /api/vault/download (stream file, no auth) Step 3 — Server: POST /api/vault/upload (write file, lock vault, 409/force) Step 4 — vault.ts: add vaultPath to SettingsSchema Step 5 — vault-store.ts: _vaultPath cache + move-on-save Step 6 — settings-routes.ts: wire path-move into PUT /api/vault/settings Step 7 — UI api.ts: vaultUpload, vaultDownload, getEnvVaultPath Step 8 — vault-context.tsx: add upload() Step 9 — VaultUnlock: upload-a-different-vault section Step 10 — VaultSetup: upload-existing-vault alternative Step 11 — SettingsTab: editable vaultPath + Download button ``` --- ## Prompt 1 — Server: `GET /api/vault/env-path` > **Goal:** Add the simplest new endpoint first — no auth, no file I/O beyond reading an env var. > This gives us a tested route registration pattern to follow for the harder endpoints. ``` You are working in packages/scraper-app/src/server/. TASK Add GET /api/vault/env-path to vault-routes.ts. BEHAVIOUR - No authentication required. - Returns JSON: { path: string } where path is process.env['VAULT_PATH'] ?? '.vault'. - Register the route inside the existing registerVaultRoutes function, before the closing brace. TESTS (add to src/server/__tests__/vault-routes.test.ts) Add a new describe block: 'GET /api/vault/env-path' - it 'returns the VAULT_PATH env var when set' Set process.env['VAULT_PATH'] = '/tmp/custom.vault' in beforeEach (alongside the existing vaultPath override), call the route, expect { path: '/tmp/custom.vault' }. - it 'returns ".vault" when VAULT_PATH is not set' delete process.env['VAULT_PATH'] before the request, expect { path: '.vault' }. Use the same buildApp / makeTmpPath / afterEach(lockVault + rm + delete env) pattern already in the file. Ensure afterEach restores process.env['VAULT_PATH'] to whatever buildApp set. Run: yarn workspace @accounter/scraper-app test --reporter=verbose All existing tests must still pass. ``` --- ## Prompt 2 — Server: `GET /api/vault/download` > **Goal:** Serve the raw encrypted vault file as a binary download. No auth needed — the blob is > useless without the password. This is pure file I/O; vault-store is not involved. ``` You are working in packages/scraper-app/src/server/. TASK Add GET /api/vault/download to vault-routes.ts. BEHAVIOUR - No authentication required. - Reads the file at getVaultPath() using fs/promises readFile. - If the file does not exist (ENOENT), return reply.status(404).send({ error: 'no-vault-file' }). - On success: reply.header('Content-Type', 'application/octet-stream'); reply.header('Content-Disposition', 'attachment; filename=".vault"'); return reply.send(fileBuffer); // send the Buffer directly Do NOT import vault-store or check isLocked(). This endpoint intentionally skips auth. IMPORTS needed: import { readFile } from 'node:fs/promises'; getVaultPath is already imported from './vault.js'. TESTS (add to src/server/__tests__/vault-routes.test.ts) New describe block: 'GET /api/vault/download' Setup (beforeEach): create a real vault file with saveVaultFile(vaultPath, defaultVault(), PASSWORD) so the file exists on disk. - it 'returns 200 with octet-stream content-type when vault file exists' inject GET /api/vault/download expect statusCode 200 expect headers['content-type'] to match /application\/octet-stream/ expect headers['content-disposition'] to equal 'attachment; filename=".vault"' expect res.rawPayload to be a non-empty Buffer (res.rawPayload.length > 0) - it 'returns 404 when no vault file exists' Use a separate describe/beforeEach that does NOT create the file. inject GET /api/vault/download expect statusCode 404 expect res.json().error to equal 'no-vault-file' Run: yarn workspace @accounter/scraper-app test --reporter=verbose ``` --- ## Prompt 3 — Server: `POST /api/vault/upload` > **Goal:** Accept a vault file upload, write it atomically, and lock the in-memory vault. This is > the most complex server step — handle the 409 / `?force` conflict flow here. ``` You are working in packages/scraper-app/src/server/. TASK Add POST /api/vault/upload to vault-routes.ts. BEHAVIOUR - Accepts a raw binary body (the encrypted vault blob). Content-Type is application/octet-stream. Use Fastify's built-in body handling — add the route with: { config: { rawBody: false } } and read the body as req.body (Fastify exposes it as a Buffer when Content-Type is octet-stream). - Query param: force (string, optional). Treat any truthy value as "overwrite allowed". - Logic: 1. const exists = await hasVaultFile(); 2. If exists && !force → return reply.status(409).send({ error: 'vault-already-exists' }); 3. Write the body to a temp file path (vaultPath + '.tmp') using writeFile. 4. Rename the temp file to getVaultPath() using rename (from node:fs/promises). This is the atomic write — partial writes never replace the live file. 5. lockVault() ← always lock after writing so the user must re-unlock. 6. return reply.status(200).send({ ok: true }); - On any fs error, return reply.status(500).send({ error: 'write-failed' }). IMPORTS needed: import { rename, writeFile } from 'node:fs/promises'; hasVaultFile and lockVault are already available via vault-store imports. TESTS (add to src/server/__tests__/vault-routes.test.ts) New describe block: 'POST /api/vault/upload' Use makeTmpPath() for vaultPath. afterEach: lockVault, app.close, rm(vaultPath, { force: true }), rm(vaultPath + '.tmp', { force: true }). Helper: async function uploadVault(app, vaultPath, opts = {}) — creates a real vault file in memory via encryptVault(defaultVault(), PASSWORD) (import from vault.ts), then injects: method: 'POST', url: '/api/vault/upload' + (opts.force ? '?force=true' : ''), payload: Buffer.from(blob, 'base64'), headers: { 'content-type': 'application/octet-stream' } Tests: - it 'returns 200 and writes the vault file when no file exists' const res = await uploadVault(app, vaultPath) expect statusCode 200, res.json() { ok: true } const exists = await access(vaultPath).then(()=>true).catch(()=>false) expect exists to be true - it 'returns 409 when vault already exists and force is not set' saveVaultFile(vaultPath, defaultVault(), PASSWORD) in beforeEach to pre-create const res = await uploadVault(app, vaultPath) expect statusCode 409, error 'vault-already-exists' - it 'overwrites and returns 200 when force=true and vault exists' pre-create the file, then uploadVault(app, vaultPath, { force: true }) expect statusCode 200 - it 'locks the vault after successful upload even if it was unlocked' unlock the vault first (POST /api/vault/unlock), confirm isLocked()===false, then uploadVault, then inject GET /api/vault/status and expect locked: true Run: yarn workspace @accounter/scraper-app test --reporter=verbose ``` --- ## Prompt 4 — `vault.ts`: add `vaultPath` to `SettingsSchema` > **Goal:** Persist the vault's own file path inside the encrypted vault, so moving the file is a > user-controlled setting. Small, isolated schema change — no behavior change yet. ``` You are working in packages/scraper-app/src/server/vault.ts. TASK Add an optional vaultPath field to SettingsSchema. CHANGE In the SettingsSchema z.object({...}) definition, add: vaultPath: z.string().optional(), That is the only change to vault.ts. Do not change getVaultPath() yet — that comes in the next step. WHY this ordering: vault-store.ts reads vault.ts types, so the schema must be updated before vault-store.ts can use the new field. TESTS (add to src/server/__tests__/vault.test.ts — check the existing file structure first) In the existing vault.test.ts, add a case inside whichever describe covers SettingsSchema or VaultSchema parsing: - it 'accepts vaultPath in settings' const v = VaultSchema.parse({ settings: { vaultPath: '/custom/path/.vault' } }) expect(v.settings.vaultPath).toBe('/custom/path/.vault') - it 'accepts settings without vaultPath' const v = VaultSchema.parse({ settings: {} }) expect(v.settings.vaultPath).toBeUndefined() Run: yarn workspace @accounter/scraper-app test --reporter=verbose ``` --- ## Prompt 5 — `vault-store.ts`: `_vaultPath` cache and `moveVaultFile` > **Goal:** The store must track the resolved vault path independently of the env var so it can move > the file when the user changes `settings.vaultPath`. Introduce the module-level variable and the > move helper here — not yet wired into settings-routes. ``` You are working in packages/scraper-app/src/server/vault-store.ts. TASK Add a cached _vaultPath variable and a moveVaultFile helper. CHANGES 1. Add at the top of the module (after existing let declarations): let _vaultPath: string | null = null; 2. Export a function getCurrentVaultPath(): string that returns _vaultPath ?? getVaultPath(). This is now the single source of truth for the active path. Callers that currently call getVaultPath() directly should be updated to call getCurrentVaultPath() — but only within vault-store.ts itself. (vault-routes.ts still calls getVaultPath() for the env-path endpoint, which is correct.) 3. In unlockVault(): after setting _vault and _password, also set: _vaultPath = getVaultPath(); This captures the path at the moment of unlock (env var / default). 4. In lockVault(): also reset _vaultPath = null. 5. Export async function moveVaultFile(newPath: string): Promise<void> - If _vault === null || _password === null, throw new Error('Vault is locked'). - const oldPath = getCurrentVaultPath(); - if (oldPath === newPath) return; // no-op - await rename(oldPath, newPath); // from node:fs/promises - _vaultPath = newPath; Import rename from 'node:fs/promises'. 6. In the existing updateVault() function, replace saveVaultFile(getVaultPath(), ...) with saveVaultFile(getCurrentVaultPath(), ...). TESTS (add to src/server/__tests__/vault.test.ts or create vault-store.test.ts if none exists) Use the same makeTmpPath / saveVaultFile / lockVault afterEach pattern from vault-routes.test.ts. - it 'getCurrentVaultPath returns env default before unlock' delete process.env['VAULT_PATH']; const p = getCurrentVaultPath(); expect(p).toBe('.vault') - it 'getCurrentVaultPath returns the path set at unlock' process.env['VAULT_PATH'] = vaultPath; await unlockVault(PASSWORD) expect(getCurrentVaultPath()).toBe(vaultPath) - it 'moveVaultFile moves the file and updates getCurrentVaultPath' await unlockVault(PASSWORD) (with existing vault file at vaultPath) const newPath = makeTmpPath() await moveVaultFile(newPath) expect(getCurrentVaultPath()).toBe(newPath) const exists = await access(newPath).then(()=>true).catch(()=>false) expect(exists).toBe(true) cleanup: rm(newPath, { force: true }) - it 'moveVaultFile is a no-op when paths are the same' await unlockVault(PASSWORD) await expect(moveVaultFile(vaultPath)).resolves.not.toThrow() - it 'moveVaultFile throws when vault is locked' // do not unlock await expect(moveVaultFile('/some/path')).rejects.toThrow('Vault is locked') Run: yarn workspace @accounter/scraper-app test --reporter=verbose ``` --- ## Prompt 6 — `settings-routes.ts`: move vault file when `vaultPath` changes > **Goal:** Wire the `moveVaultFile` helper into the settings `PUT` route so saving a new > `vaultPath` atomically moves the file on disk. This completes the server-side feature. ``` You are working in packages/scraper-app/src/server/settings-routes.ts. TASK When a PUT /api/vault/settings request includes a vaultPath change, move the vault file. CHANGES 1. Import moveVaultFile and getCurrentVaultPath from './vault-store.js'. 2. In the PUT handler, after validating parsed.data with SettingsSchema.partial().safeParse(): a. Extract the requested new path: const newPath = parsed.data.vaultPath; b. If newPath is defined and newPath !== getCurrentVaultPath(): try { await moveVaultFile(newPath); } catch { return reply.status(500).send({ error: 'move-failed' }); } c. Then continue with the existing updateVault call. Order matters: move the file BEFORE updateVault writes the new vault content, so the vault is always written to the correct (new) location. TESTS (add to src/server/__tests__/settings-routes.test.ts) Check the existing file for the setup pattern. Add a new describe block: 'PUT /api/vault/settings — vaultPath change' beforeEach: create vault file at vaultPath, unlock vault (POST /api/vault/unlock). afterEach: lockVault, app.close, rm both paths with { force: true }. - it 'moves the vault file and returns updated settings when vaultPath changes' const newPath = makeTmpPath() const res = await app.inject({ method: 'PUT', url: '/api/vault/settings', payload: { vaultPath: newPath } }) expect statusCode 200 expect res.json().vaultPath to equal newPath const oldExists = await access(vaultPath).then(()=>true).catch(()=>false) expect(oldExists).toBe(false) const newExists = await access(newPath).then(()=>true).catch(()=>false) expect(newExists).toBe(true) cleanup rm(newPath) in afterEach - it 'returns 200 without moving anything when vaultPath is not in the payload' await app.inject({ method: 'PUT', url: '/api/vault/settings', payload: { showBrowser: true } }) const exists = await access(vaultPath).then(()=>true).catch(()=>false) expect(exists).toBe(true) // original file untouched Run: yarn workspace @accounter/scraper-app test --reporter=verbose ``` --- ## Prompt 7 — UI `api.ts`: `vaultUpload`, `vaultDownload`, `getEnvVaultPath` > **Goal:** Add the three new client-side API functions. Pure data-layer; no UI yet. This step also > updates the existing `getVaultPath` to be consistent with the new naming. ``` You are working in packages/scraper-app/src/ui/lib/api.ts. TASK Add three new API helper functions. ADDITIONS (append after the existing getVaultPath function): export function getEnvVaultPath(): Promise<{ path: string }> { return apiFetch('/api/vault/env-path'); } export async function vaultUpload(file: File, force = false): Promise<{ ok: boolean }> { const url = force ? '/api/vault/upload?force=true' : '/api/vault/upload'; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file, }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`); } return res.json() as Promise<{ ok: boolean }>; } export async function vaultDownload(suggestedName = '.vault'): Promise<void> { const res = await fetch('/api/vault/download'); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = suggestedName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } No test file currently exists for api.ts. Create src/ui/__tests__/api.test.ts with @vitest-environment happy-dom: import { describe, it, expect, vi, afterEach } from 'vitest'; import { vaultUpload, vaultDownload, getEnvVaultPath, ApiError } from '../lib/api.js'; afterEach(() => vi.unstubAllGlobals()); describe('getEnvVaultPath', () => { it('returns the path from the server', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ path: '/custom/.vault' }) })); const result = await getEnvVaultPath(); expect(result.path).toBe('/custom/.vault'); }); }); describe('vaultUpload', () => { it('POSTs to /api/vault/upload with octet-stream', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) }); vi.stubGlobal('fetch', fetchMock); const file = new File(['data'], 'vault'); await vaultUpload(file); expect(fetchMock).toHaveBeenCalledWith('/api/vault/upload', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'Content-Type': 'application/octet-stream' }), })); }); it('appends ?force=true when force=true', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) }); vi.stubGlobal('fetch', fetchMock); await vaultUpload(new File(['d'], 'v'), true); expect(fetchMock.mock.calls[0][0]).toContain('force=true'); }); it('throws ApiError with status 409 on conflict', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 409, json: async () => ({ error: 'vault-already-exists' }) })); await expect(vaultUpload(new File(['d'], 'v'))).rejects.toBeInstanceOf(ApiError); }); }); describe('vaultDownload', () => { it('creates and clicks a download link', async () => { const blob = new Blob(['data']); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, blob: async () => blob })); const clickSpy = vi.fn(); vi.spyOn(document, 'createElement').mockReturnValue( Object.assign(document.createElement('a'), { click: clickSpy }) ); await vaultDownload('.vault'); expect(clickSpy).toHaveBeenCalled(); }); }); Run: yarn workspace @accounter/scraper-app test --reporter=verbose ``` --- ## Prompt 8 — `vault-context.tsx`: add `upload()` > **Goal:** Surface the upload function in the vault context so screens can call it without directly > importing api.ts. Follows the exact same pattern as `unlock` and `create`. ``` You are working in packages/scraper-app/src/ui/contexts/vault-context.tsx. TASK Add upload(file: File, force?: boolean): Promise<void> to the vault context. CHANGES 1. In the VaultContextValue type, add: upload(file: File, force?: boolean): Promise<void>; 2. Import vaultUpload and ApiError from '../lib/api.js'. 3. Inside VaultProvider, add a useCallback for upload: const upload = useCallback(async (file: File, force = false) => { setError(null); try { await vaultUpload(file, force); setStatus('locked'); } catch (e) { if (e instanceof ApiError && e.status === 409) { // Re-throw so the caller can handle the conflict (show confirm dialog) throw e; } setError('Failed to upload vault. Please try again.'); } }, []); 4. Add upload to the VaultContext.Provider value prop. 5. Update the makeCtx helper in src/ui/__tests__/vault-unlock.test.tsx and src/ui/__tests__/vault-setup.test.tsx to include upload: vi.fn() so existing tests still compile. (The contexts in those files spread overrides, so just add upload to the base object in makeCtx.) TESTS (add to a new describe block in src/ui/__tests__/vault-unlock.test.tsx or create src/ui/__tests__/vault-context.test.tsx) The simplest approach: add to vault-unlock.test.tsx since it already has the VaultContext infrastructure. - it 'calls upload with the file and sets status to locked on success' Use a stateful wrapper: function UploadWrapper() { const [status, setStatus] = useState<VaultStatus>('locked'); const upload = vi.fn().mockImplementation(async () => setStatus('locked')); return ( <VaultContext.Provider value={{ status, error: null, unlock: vi.fn(), create: vi.fn(), upload }}> <VaultUnlock /> </VaultContext.Provider> ); } (VaultUnlock will gain a file input in the next step — this test is a forward stub, just verify the context value shape compiles and renders without error for now.) render(<UploadWrapper />) expect(screen.getByLabelText(/master password/i)).toBeTruthy() Run: yarn workspace @accounter/scraper-app test --reporter=verbose ``` --- ## Prompt 9 — `VaultUnlock`: upload-a-different-vault section > **Goal:** When the vault exists but the user wants to use a different vault, they can upload it > from this screen. Handles the 409 conflict with a window.confirm fallback. ``` You are working in packages/scraper-app/src/ui/screens/vault-unlock.tsx. TASK Add an "or upload a different vault" file input section below the unlock form. CURRENT SHAPE: password input + Unlock button. FINAL SHAPE: [Unlock Vault] <form> password + Unlock button </form> <hr /> <section> file input "or upload a different vault" </section> CHANGES 1. Import useRef from 'react'. 2. Import { useVault } from '../contexts/vault-context.js' (already imported — keep it). 3. Add state: const [uploadError, setUploadError] = useState<string | null>(null); 4. Add a ref: const fileInputRef = useRef<HTMLInputElement>(null); 5. Add handler: async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0]; if (!file) return; setUploadError(null); try { await vault.upload(file); } catch { // 409: vault exists, ask to overwrite const confirmed = window.confirm( 'A vault already exists. Replace it with the uploaded file?' ); if (confirmed) { try { await vault.upload(file, true); } catch { setUploadError('Failed to replace vault. Please try again.'); } } } // Reset so the same file can be re-selected if (fileInputRef.current) fileInputRef.current.value = ''; } 6. Below the existing </form>, add: <hr /> <div> <p>or upload a different vault</p> <input ref={fileInputRef} id="vault-upload" type="file" aria-label="Upload vault file" onChange={handleFileChange} /> {uploadError && <p role="alert">{uploadError}</p>} </div> TESTS (add to src/ui/__tests__/vault-unlock.test.tsx) Update makeCtx to include upload: vi.fn().mockResolvedValue(undefined) in the base object. New describe block: 'VaultUnlock — file upload' - it 'renders the upload file input' renderUnlock() expect(screen.getByLabelText(/upload vault file/i)).toBeTruthy() - it 'calls vault.upload when a file is selected' const upload = vi.fn().mockResolvedValue(undefined) renderUnlock(makeCtx({ upload })) const input = screen.getByLabelText(/upload vault file/i) const file = new File(['blob'], 'test.vault') await userEvent.upload(input, file) expect(upload).toHaveBeenCalledWith(file) - it 'shows confirm dialog and calls upload with force=true on 409' const ApiError = (await import('../lib/api.js')).ApiError const upload = vi.fn() .mockRejectedValueOnce(new ApiError(409, 'vault-already-exists')) .mockResolvedValueOnce(undefined) vi.stubGlobal('confirm', vi.fn().mockReturnValue(true)) renderUnlock(makeCtx({ upload })) await userEvent.upload(screen.getByLabelText(/upload vault file/i), new File(['b'], 'v')) await waitFor(() => expect(upload).toHaveBeenCalledTimes(2)) expect(upload).toHaveBeenLastCalledWith(expect.any(File), true) vi.unstubAllGlobals() - it 'shows uploadError when force upload also fails' const upload = vi.fn().mockRejectedValue(new (await import('../lib/api.js')).ApiError(409, 'x')) vi.stubGlobal('confirm', vi.fn().mockReturnValue(true)) renderUnlock(makeCtx({ upload })) await userEvent.upload(screen.getByLabelText(/upload vault file/i), new File(['b'], 'v')) await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('Failed to replace')) vi.unstubAllGlobals() Run: yarn workspace @accounter/scraper-app test --reporter=verbose ``` --- ## Prompt 10 — `VaultSetup`: upload-existing-vault alternative > **Goal:** When there is no vault file at all, the user should be able to upload one instead of > going through the 3-step creation wizard. ``` You are working in packages/scraper-app/src/ui/screens/vault-setup.tsx. TASK Add an "Upload an existing vault" section above the creation wizard. CURRENT SHAPE: 3-step form. FINAL SHAPE: [Setup Vault] <section> upload existing vault </section> <hr /> <section> step 1 / 2 / 3 wizard (unchanged) </section> CHANGES 1. Import useRef from 'react'. 2. Import { useVault } from '../contexts/vault-context.js' (already imported — keep it). 3. Add state: const [uploadError, setUploadError] = useState<string | null>(null); 4. Add ref: const fileInputRef = useRef<HTMLInputElement>(null); 5. Add handler (simpler than VaultUnlock — no vault exists, so 409 cannot happen): async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0]; if (!file) return; setUploadError(null); try { await vault.upload(file); } catch { setUploadError('Failed to upload vault. Please try again.'); } if (fileInputRef.current) fileInputRef.current.value = ''; } 6. At the top of the returned JSX, before the step === 1 block, insert: <div> <p>Upload an existing vault</p> <input ref={fileInputRef} id="vault-upload-setup" type="file" aria-label="Upload existing vault file" onChange={handleFileChange} /> {uploadError && <p role="alert">{uploadError}</p>} </div> <hr /> TESTS (add to src/ui/__tests__/vault-setup.test.tsx) Update makeCtx to add upload: vi.fn().mockResolvedValue(undefined) to the base context object. New describe block: 'VaultSetup — file upload' - it 'renders the upload file input alongside the wizard' renderSetup() expect(screen.getByLabelText(/upload existing vault file/i)).toBeTruthy() expect(screen.getByText(/step 1/i)).toBeTruthy() // wizard still visible - it 'calls vault.upload when a file is selected' const upload = vi.fn().mockResolvedValue(undefined) renderSetup(makeCtx(vi.fn(), upload)) // adjust makeCtx signature to accept upload const file = new File(['blob'], 'test.vault') await userEvent.upload(screen.getByLabelText(/upload existing vault file/i), file) expect(upload).toHaveBeenCalledWith(file) Note: update makeCtx to accept a second argument upload = vi.fn(): function makeCtx(create = vi.fn()..., upload = vi.fn()...) { return { ..., upload } } - it 'shows uploadError when upload fails' const upload = vi.fn().mockRejectedValue(new Error('fail')) renderSetup(makeCtx(vi.fn(), upload)) await userEvent.upload(screen.getByLabelText(/upload existing vault file/i), new File(['b'], 'v')) await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('Failed to upload')) Run: yarn workspace @accounter/scraper-app test --reporter=verbose ``` --- ## Prompt 11 — `SettingsTab`: editable vault path + Download button > **Goal:** The final UI piece. Replace the read-only vault path display with an editable input that > moves the file on save, and add a Download button that triggers a browser save dialog. ``` You are working in packages/scraper-app/src/ui/screens/config/settings-tab.tsx. TASK 1. Make the vault path input editable (saves vaultPath to vault settings on blur). 2. Add a Download vault button next to it. CONTEXT Currently the vault path section renders: <input readOnly value={vaultPath} aria-label="Vault file path" /> <button>Copy path</button> The vaultPath state is loaded from GET /api/vault/path (requires unlock). The env-path endpoint returns the path to use as download filename hint when no saved path exists. CHANGES 1. Import vaultDownload from '../../lib/api.js'. Import { basename } from — note: this is a browser context, node:path is not available. Instead, inline the basename extraction: function getBasename(p: string) { return p.split('/').pop()?.split('\\').pop() ?? '.vault'; } 2. Add state: const [downloading, setDownloading] = useState(false); 3. The vaultPath state already exists and is loaded. Change the display input from readOnly to editable, following the same onBlur auto-save pattern as historyFilePath: Replace: <input readOnly value={vaultPath} ... aria-label="Vault file path" /> With: <input id="vaultFilePath" type="text" value={vaultPath} onChange={e => setVaultPath(e.target.value)} onBlur={async e => { const val = e.target.value.trim(); if (!val || val === vaultPath) return; await autoSave({ vaultPath: val }); // autoSave updates settings; update local vaultPath display too setVaultPath(val); }} aria-label="Vault file path" style={{ padding: '4px 8px', width: '100%', maxWidth: 360 }} placeholder=".vault" /> 4. Add a Download button after the Copy path button: <button type="button" disabled={downloading} onClick={async () => { setDownloading(true); try { await vaultDownload(getBasename(vaultPath || '.vault')); } catch { setError('Failed to download vault.'); } finally { setDownloading(false); } }} style={{ padding: '4px 12px', whiteSpace: 'nowrap' }} > {downloading ? 'Downloading…' : 'Download'} </button> 5. The existing getVaultPath() call in useEffect should remain — it still populates the initial vaultPath display. No change needed there. TESTS (add to src/ui/__tests__/settings-tab.test.tsx) Update mockFetch to also handle '/api/vault/path' returning { path: '/data/.vault' } and '/api/vault/env-path' returning { path: '.vault' }. The current mock returns ok: false for unknown URLs — extend it: if (url === '/api/vault/path') return { ok: true, json: async () => ({ path: '/data/.vault' }) } if (url === '/api/vault/env-path') return { ok: true, json: async () => ({ path: '.vault' }) } New tests: - it 'renders the vault path input as editable' render(<SettingsTab />) await waitFor(() => screen.getByLabelText(/vault file path/i)) const input = screen.getByLabelText(/vault file path/i) as HTMLInputElement expect(input.readOnly).toBe(false) - it 'saves vaultPath to settings on blur' const fetchMock = mockFetch() render(<SettingsTab />) await waitFor(() => screen.getByLabelText(/vault file path/i)) const input = screen.getByLabelText(/vault file path/i) await userEvent.clear(input) await userEvent.type(input, '/new/path/.vault') await userEvent.tab() // triggers blur await waitFor(() => { const putCalls = fetchMock.mock.calls.filter(([, opts]) => opts?.method === 'PUT') const bodies = putCalls.map(([, opts]) => JSON.parse(opts!.body as string)) expect(bodies.some(b => b.vaultPath === '/new/path/.vault')).toBe(true) }) - it 'renders the Download button' render(<SettingsTab />) await waitFor(() => screen.getByRole('button', { name: /download/i })) expect(screen.getByRole('button', { name: /download/i })).toBeTruthy() - it 'calls vaultDownload when Download is clicked' vi.mock('../../lib/api.js', async (importOriginal) => { const mod = await importOriginal<typeof import('../../lib/api.js')>() return { ...mod, vaultDownload: vi.fn().mockResolvedValue(undefined) } }) render(<SettingsTab />) await waitFor(() => screen.getByRole('button', { name: /download/i })) await userEvent.click(screen.getByRole('button', { name: /download/i })) const { vaultDownload } = await import('../../lib/api.js') expect(vaultDownload).toHaveBeenCalled() Run: yarn workspace @accounter/scraper-app test --reporter=verbose All tests across the project must pass: yarn workspace @accounter/scraper-app test ``` --- ## Final verification After all 11 prompts are complete, run the full test suite to confirm nothing regressed: ```bash yarn workspace @accounter/scraper-app test --reporter=verbose ``` And confirm TypeScript compiles cleanly: ```bash yarn workspace @accounter/scraper-app build ```