diff --git a/__tests__/route-error-retry.test.tsx b/__tests__/route-error-retry.test.tsx new file mode 100644 index 0000000..73ce8a6 --- /dev/null +++ b/__tests__/route-error-retry.test.tsx @@ -0,0 +1,38 @@ +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import RootError from '@/app/error'; +import GlobalError from '@/app/global-error'; +import DashboardError from '@/app/dashboard/error'; +import EditorError from '@/app/editor/[id]/error'; +import TailorError from '@/app/tailor/[jobId]/error'; +import CoverLetterError from '@/app/cover-letter/[jobId]/error'; +import InterviewError from '@/app/interview-prep/[jobId]/error'; + +vi.mock('@/lib/foundry-monitoring', () => ({ captureError: vi.fn() })); +afterEach(() => vi.restoreAllMocks()); +for (const [name, Boundary] of [ + ['root', RootError], + ['global', GlobalError], + ['dashboard', DashboardError], + ['editor', EditorError], + ['tailor', TailorError], + ['cover letter', CoverLetterError], + ['interview', InterviewError], +] as const) { + it(`${name} retries the failed server segment instead of merely clearing its cached error`, async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const retry = vi.fn(); + const reset = vi.fn(); + const node = document.createElement('div'); + document.body.append(node); + const root = createRoot(node); + const props = { error: new Error('Synthetic read failure'), retry, reset }; + await act(async () => root.render(createElement(Boundary, props))); + await act(async () => node.querySelector('button')!.click()); + expect(retry).toHaveBeenCalledOnce(); + expect(reset).not.toHaveBeenCalled(); + await act(async () => root.unmount()); + node.remove(); + }); +} diff --git a/e2e/editor-toolbar.spec.ts b/e2e/editor-toolbar.spec.ts new file mode 100644 index 0000000..6dbd32a --- /dev/null +++ b/e2e/editor-toolbar.spec.ts @@ -0,0 +1,104 @@ +import { expect, test } from '@playwright/test'; + +for (const width of [390, 768, 1440]) { + test(`initial editor toolbar hit targets and keyboard access at ${width}`, async ({ + context, + page, + }, testInfo) => { + test.setTimeout(90_000); + await page.setViewportSize({ width, height: 900 }); + await context.route('**/*', (route) => { + const url = new URL(route.request().url()); + if (url.origin !== new URL(testInfo.project.use.baseURL!).origin) return route.abort(); + if (url.pathname === '/api/auth/get-session') return route.fulfill({ json: null }); + return route.continue(); + }); + await page.goto('/resume-builder'); + await expect(page.getByRole('button', { name: '+ New Resume', exact: true })).toBeEnabled({ + timeout: 30_000, + }); + await page.evaluate(() => + localStorage.setItem( + 'rt-resumes', + JSON.stringify([ + { + id: 'synthetic-toolbar', + name: 'Synthetic Resume', + source: '# Ada Example\n\n## Experience\n- Built accessible TypeScript screens.\n', + created_at: 1767225600, + updated_at: 1767225600, + }, + ]) + ) + ); + await page.goto('/editor/synthetic-toolbar'); + await expect(page.locator('.cm-editor')).toBeVisible({ timeout: 30_000 }); + await page.evaluate(() => document.fonts.ready); + const toolbar = page.locator('.product-editor-toolbar'); + const targets = [ + toolbar.getByTitle('Back to Resume Builder'), + toolbar.getByTitle('Click to rename'), + toolbar.getByTitle('Format settings'), + toolbar.locator('summary').filter({ hasText: 'Export resume' }), + toolbar.getByRole('button', { name: 'Save', exact: true }), + ]; + const initialScroll = await toolbar.evaluate((element) => { + const pane = element.parentElement!; + return { document: window.scrollX, pane: pane.scrollLeft }; + }); + // No locator.click(), focus() or scrollIntoView() before these initial hit tests. + for (const target of targets) { + expect( + await target.evaluate((element) => { + const box = element.getBoundingClientRect(); + const first = document.elementsFromPoint( + box.x + box.width / 2, + box.y + box.height / 2 + )[0]; + return ( + box.width > 0 && + box.height > 0 && + box.x >= 0 && + box.right <= innerWidth && + first != null && + (first === element || element.contains(first)) + ); + }) + ).toBe(true); + } + expect( + await toolbar.evaluate((element) => ({ + document: window.scrollX, + pane: element.parentElement!.scrollLeft, + })) + ).toEqual(initialScroll); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( + true + ); + await expect(page.locator('.resume-page').first()).toHaveCSS('width', '816px'); + await page.screenshot({ path: testInfo.outputPath('initial.png'), fullPage: true }); + const reached = new Set(); + for (let index = 0; index < 50 && reached.size < targets.length; index += 1) { + await page.keyboard.press('Tab'); + for (let n = 0; n < targets.length; n += 1) + if (await targets[n].evaluate((element) => element === document.activeElement)) + reached.add(String(n)); + } + expect(reached.size).toBe(targets.length); + // Open at its measured coordinate; pointer qualification cannot silently pan the pane. + const format = await toolbar.getByTitle('Format settings').boundingBox(); + await page.mouse.click(format!.x + format!.width / 2, format!.y + format!.height / 2); + for (const control of ['select', 'input[type=number]']) { + for (const element of await toolbar.locator(control).all()) { + expect( + await element.evaluate((node) => { + const box = node.getBoundingClientRect(); + const first = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2); + return box.width > 0 && first != null && (first === node || node.contains(first)); + }) + ).toBe(true); + } + } + await page.screenshot({ path: testInfo.outputPath('settings.png'), fullPage: true }); + }); +} diff --git a/src/app/cover-letter/[jobId]/error.tsx b/src/app/cover-letter/[jobId]/error.tsx index 8e5be45..fdf67cd 100644 --- a/src/app/cover-letter/[jobId]/error.tsx +++ b/src/app/cover-letter/[jobId]/error.tsx @@ -7,10 +7,10 @@ import { captureError } from '@/lib/foundry-monitoring'; export default function CoverLetterError({ error, - reset, + retry, }: { error: Error & { digest?: string }; - reset: () => void; + retry: () => void; }) { useEffect(() => { console.error(error); @@ -25,7 +25,7 @@ export default function CoverLetterError({ Something went wrong while preparing this page. Any saved drafts are safe — try again.

- diff --git a/src/app/dashboard/error.tsx b/src/app/dashboard/error.tsx index 9beac22..eccb2f3 100644 --- a/src/app/dashboard/error.tsx +++ b/src/app/dashboard/error.tsx @@ -6,10 +6,10 @@ import { captureError } from '@/lib/foundry-monitoring'; export default function DashboardError({ error, - reset, + retry, }: { error: Error & { digest?: string }; - reset: () => void; + retry: () => void; }) { useEffect(() => { console.error(error); @@ -23,7 +23,7 @@ export default function DashboardError({

Something went wrong while loading your resumes and jobs. Your data is safe — try again.

- {error.digest ?

Reference: {error.digest}

: null} diff --git a/src/app/editor/[id]/error.tsx b/src/app/editor/[id]/error.tsx index 80c7ecb..b3b692a 100644 --- a/src/app/editor/[id]/error.tsx +++ b/src/app/editor/[id]/error.tsx @@ -7,10 +7,10 @@ import { captureError } from '@/lib/foundry-monitoring'; export default function EditorError({ error, - reset, + retry, }: { error: Error & { digest?: string }; - reset: () => void; + retry: () => void; }) { useEffect(() => { console.error(error); @@ -25,7 +25,7 @@ export default function EditorError({ Something went wrong while loading this resume. Your saved content is safe — try again.

- diff --git a/src/app/error.tsx b/src/app/error.tsx index c08527c..18f69a9 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -6,10 +6,10 @@ import { captureError } from '@/lib/foundry-monitoring'; export default function Error({ error, - reset, + retry, }: { error: Error & { digest?: string }; - reset: () => void; + retry: () => void; }) { useEffect(() => { // Full detail goes to the console + PostHog — never to the user. @@ -26,7 +26,7 @@ export default function Error({ happening, come back in a few minutes.

- diff --git a/src/app/tailor/[jobId]/error.tsx b/src/app/tailor/[jobId]/error.tsx index 1edba7f..9c7f2ef 100644 --- a/src/app/tailor/[jobId]/error.tsx +++ b/src/app/tailor/[jobId]/error.tsx @@ -7,10 +7,10 @@ import { captureError } from '@/lib/foundry-monitoring'; export default function TailorError({ error, - reset, + retry, }: { error: Error & { digest?: string }; - reset: () => void; + retry: () => void; }) { useEffect(() => { console.error(error); @@ -26,7 +26,7 @@ export default function TailorError({ again.

- diff --git a/src/components/resume-editor.tsx b/src/components/resume-editor.tsx index 6e06dc1..67ba29e 100644 --- a/src/components/resume-editor.tsx +++ b/src/components/resume-editor.tsx @@ -416,7 +416,7 @@ export function ResumeEditor({ resumeId, initialSource, resumeName }: Props) { return (
{/* Editor pane */} -
+
{/* Toolbar */}
{saving ? 'Saving...' : ''} -
+
{showConfig && ( -
+