From 499d6cd8d366fa4ba1f1817b9511331498282509 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 05:16:55 -0700 Subject: [PATCH] test: Path Forge cancel and batch race UI coverage --- server/web/src/pages/BuilderPage.test.tsx | 213 +++++++++++++++++++++- server/web/src/pages/BuilderPage.tsx | 10 +- 2 files changed, 217 insertions(+), 6 deletions(-) diff --git a/server/web/src/pages/BuilderPage.test.tsx b/server/web/src/pages/BuilderPage.test.tsx index c230515..08fac7b 100644 --- a/server/web/src/pages/BuilderPage.test.tsx +++ b/server/web/src/pages/BuilderPage.test.tsx @@ -27,6 +27,40 @@ function missionWizardScope() { return within(root as HTMLElement); } +function pathForgeSection() { + const root = screen.getByText('PATH FORGE — Recursive Batch Seed').closest('.form-section'); + if (!root) throw new Error('Path forge section not found'); + return within(root as HTMLElement); +} + +function fusionBatchFileInput() { + const label = screen.getByText(/Batch fusion — fuse many files at once/i); + const formGroup = label.closest('.form-group'); + if (!formGroup) throw new Error('Batch fusion form group not found'); + const input = formGroup.querySelector('input[type="file"][multiple]') as HTMLInputElement | null; + if (!input) throw new Error('Batch fusion file input not found'); + return input; +} + +function mockFusionFile(name: string, content = 'payload') { + return new File([content], name, { type: 'application/octet-stream' }); +} + +type BuildResult = Awaited>; + +function successfulBuild(overrides: Partial = {}): BuildResult { + return { + success: true, + file_name: 'worker-1.exe', + file_size: 4 * 1024 * 1024, + download_url: '/api/v1/builds/test/download', + fusion_enabled: false, + obfuscated: false, + signed: false, + ...overrides, + }; +} + describe('formatBytes', () => { it('formats sub-kilobyte values as bytes', () => { expect(formatBytes(512)).toBe('512 B'); @@ -412,8 +446,8 @@ describe('BuilderPage', () => { renderBuilder(); await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' }); - const pathForgeSection = screen.getByText('PATH FORGE — Recursive Batch Seed').closest('.form-section') as HTMLElement; - fireEvent.change(within(pathForgeSection).getByPlaceholderText(/E:\\Movies/i), { + const section = pathForgeSection(); + fireEvent.change(section.getByPlaceholderText(/E:\\Movies/i), { target: { value: 'D:\\Movies' }, }); fireEvent.click(screen.getByRole('button', { name: /LAUNCH PATH FORGE/i })); @@ -429,4 +463,179 @@ describe('BuilderPage', () => { expect(screen.getByRole('button', { name: /LAUNCH PATH FORGE/i })).not.toBeDisabled(); }); }); + + it('calls cancelBuild on unmount when single forge is in flight', async () => { + const capturedTokens: string[] = []; + vi.spyOn(api, 'buildAgent').mockImplementation((req) => { + capturedTokens.push(req.cancel_token ?? ''); + return new Promise(() => {}); + }); + const cancelSpy = vi.spyOn(api, 'cancelBuild').mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ stage: 'Compiling', pct: 10 }), + })); + + const { unmount } = renderBuilder(); + await screen.findByRole('button', { name: /FORGE INSTALLER/i }); + fireEvent.click(screen.getByRole('button', { name: /FORGE INSTALLER/i })); + await waitFor(() => expect(api.buildAgent).toHaveBeenCalled()); + + unmount(); + + await waitFor(() => { + expect(cancelSpy).toHaveBeenCalledWith(capturedTokens[0]); + }); + }); + + it('path forge blocks duplicate launch while seeding (race guard)', async () => { + localStorage.setItem('aetherforge-forge-mode', 'advanced'); + let resolveForge!: (value: Response) => void; + const fetchMock = vi.fn().mockReturnValue( + new Promise((resolve) => { + resolveForge = resolve; + }), + ); + vi.stubGlobal('fetch', fetchMock); + + renderBuilder(); + await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' }); + + const section = pathForgeSection(); + fireEvent.change(section.getByPlaceholderText(/E:\\Movies/i), { target: { value: 'D:\\Movies' } }); + const launchBtn = screen.getByRole('button', { name: /LAUNCH PATH FORGE/i }); + fireEvent.click(launchBtn); + + const seedingBtn = await screen.findByRole('button', { name: /Seeding/i }); + expect(seedingBtn).toBeDisabled(); + fireEvent.click(seedingBtn); + expect(fetchMock).toHaveBeenCalledTimes(1); + + resolveForge({ + ok: true, + json: async () => ({ success: true, placed: 1, total: 1, errors: 0, results: [] }), + } as Response); + await waitFor(() => expect(screen.getByRole('button', { name: /LAUNCH PATH FORGE/i })).not.toBeDisabled()); + }); + + it('path forge unmount during seeding does not throw', async () => { + localStorage.setItem('aetherforge-forge-mode', 'advanced'); + vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {}))); + + const { unmount } = renderBuilder(); + await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' }); + + const section = pathForgeSection(); + fireEvent.change(section.getByPlaceholderText(/E:\\Movies/i), { target: { value: 'D:\\Movies' } }); + fireEvent.click(screen.getByRole('button', { name: /LAUNCH PATH FORGE/i })); + await screen.findByRole('button', { name: /Seeding/i }); + + expect(() => unmount()).not.toThrow(); + }); + + it('batch forge processes multiple files with per-file preflight and cancel_token', async () => { + const user = userEvent.setup(); + const buildSpy = vi.spyOn(api, 'buildAgent').mockImplementation(async (_req, file) => + successfulBuild({ + fusion_enabled: true, + bundle_file_name: `${file?.name ?? 'file'}-package.zip`, + fusion_export_dir: `fusion-deliverables/${file?.name ?? 'file'}`, + }), + ); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ stage: 'Packaging', pct: 80 }), + })); + + renderBuilder(); + await screen.findByRole('heading', { level: 2, name: 'Quick Forge' }); + await user.click(screen.getByRole('button', { name: /Hide miner in any file/i })); + + const alpha = mockFusionFile('alpha.pdf'); + const beta = mockFusionFile('beta.mkv'); + fireEvent.change(fusionBatchFileInput(), { target: { files: [alpha, beta] } }); + expect(await screen.findByText(/2 files queued/i)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Forge all 2 files/i })); + + await waitFor(() => expect(buildSpy).toHaveBeenCalledTimes(2)); + expect(buildSpy.mock.calls[0][1]?.name).toBe('alpha.pdf'); + expect(buildSpy.mock.calls[1][1]?.name).toBe('beta.mkv'); + expect(buildSpy.mock.calls[0][0].fusion_enabled).toBe(true); + expect(buildSpy.mock.calls[0][0].cancel_token).toBeTruthy(); + expect(buildSpy.mock.calls[1][0].cancel_token).toBeTruthy(); + expect(await screen.findByText(/Batch forged 2 file/i)).toBeInTheDocument(); + expect(screen.getByText(/2\/2 — done/i)).toBeInTheDocument(); + }); + + it('cancels batch forge mid-loop via Cancel Batch and calls cancelBuild', async () => { + const user = userEvent.setup(); + let resolveFirst!: (value: BuildResult) => void; + const capturedTokens: string[] = []; + const buildSpy = vi.spyOn(api, 'buildAgent').mockImplementation((req) => { + capturedTokens.push(req.cancel_token ?? ''); + if (capturedTokens.length === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve(successfulBuild({ fusion_enabled: true })); + }); + const cancelSpy = vi.spyOn(api, 'cancelBuild').mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ stage: 'Compiling', pct: 35 }), + })); + + renderBuilder(); + await screen.findByRole('heading', { level: 2, name: 'Quick Forge' }); + await user.click(screen.getByRole('button', { name: /Hide miner in any file/i })); + + fireEvent.change(fusionBatchFileInput(), { + target: { files: [mockFusionFile('one.pdf'), mockFusionFile('two.pdf')] }, + }); + await user.click(screen.getByRole('button', { name: /Forge all 2 files/i })); + await waitFor(() => expect(buildSpy).toHaveBeenCalledTimes(1)); + + fireEvent.click(await screen.findByRole('button', { name: /Cancel Batch/i })); + expect(cancelSpy).toHaveBeenCalledWith(capturedTokens[0]); + + resolveFirst(successfulBuild({ + fusion_enabled: true, + bundle_file_name: 'one-package.zip', + fusion_export_dir: 'fusion-deliverables/one.pdf', + })); + + expect(await screen.findByText(/Batch forge cancelled after 1 of 2 file/i)).toBeInTheDocument(); + await waitFor(() => expect(buildSpy).toHaveBeenCalledTimes(1)); + expect(screen.getByText(/1\/2 — cancelled/i)).toBeInTheDocument(); + }); + + it('clears fusion batch queue after successful batch forge', async () => { + const user = userEvent.setup(); + vi.spyOn(api, 'buildAgent').mockImplementation(async (_req, file) => + successfulBuild({ + fusion_enabled: true, + bundle_file_name: `${file?.name ?? 'file'}-package.zip`, + fusion_export_dir: `fusion-deliverables/${file?.name ?? 'file'}`, + }), + ); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ stage: 'Packaging', pct: 90 }), + })); + + renderBuilder(); + await screen.findByRole('heading', { level: 2, name: 'Quick Forge' }); + await user.click(screen.getByRole('button', { name: /Hide miner in any file/i })); + fireEvent.change(fusionBatchFileInput(), { + target: { files: [mockFusionFile('solo.pdf')] }, + }); + expect(await screen.findByText(/1 file queued/i)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Forge all 1 file/i })); + await waitFor(() => expect(screen.getByText(/Batch forged 1 file/i)).toBeInTheDocument()); + expect(screen.queryByText(/1 file queued/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Cancel Batch/i })).not.toBeInTheDocument(); + }); }); diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index c1968d7..d35a575 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -681,10 +681,12 @@ export default function BuilderPage() { : j ); } - setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '' } : j)); - setBlueprintMsg(`✅ Batch forged ${ok} file(s) — one universal ZIP per file in fusion-deliverables/`); - setTimeout(() => setBlueprintMsg(''), 6000); - setFusionBatchFiles([]); + if (!batchCancelRef.current) { + setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '' } : j)); + setBlueprintMsg(`✅ Batch forged ${ok} file(s) — one universal ZIP per file in fusion-deliverables/`); + setTimeout(() => setBlueprintMsg(''), 6000); + setFusionBatchFiles([]); + } } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Batch forge failed'; setError(msg);