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

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:
AetherForge
2026-06-07 00:52:55 -07:00
parent fd5cc1dd61
commit e37eb24369
8 changed files with 85 additions and 17 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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();
});
});

View File

@@ -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();
};
}

View File

@@ -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';
}