Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
282 changes: 236 additions & 46 deletions test/aspire-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
* Requires Docker. Skipped when SKIP_DOCKER_TESTS=1 or Docker unavailable.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execSync } from 'node:child_process';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { execSync, spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { chromium, type Browser } from 'playwright';
import { trace, metrics } from '@opentelemetry/api';
Expand All @@ -34,50 +34,151 @@ function playwrightBrowserSkipReason(): string | null {
const SKIP_REASON = dockerSkipReason() ?? playwrightBrowserSkipReason();

const CONTAINER_NAME = 'squad-aspire-dashboard';
const DASHBOARD_IMAGE = 'mcr.microsoft.com/dotnet/aspire-dashboard:13.5.2@sha256:0ef531119b8073aed12b0db2b4e4ab02866c6c69b7a52264269abd00cfb48a34';
const DASHBOARD_URL = 'http://localhost:18888';
const OTLP_GRPC_TARGET = 'http://localhost:4317';
// Pin to a verified-good patch version, not a floating tag: `:latest` and
// the major-version tag `:13` both currently resolve to a broken same-day
// upstream build (missing /app/Aspire.Dashboard.dll, confirmed via MCR
// manifest digest comparison). `13.5.2` is a distinct, older build that
// does not exhibit the problem. Re-pin forward once a newer verified-good
// patch tag is published upstream.
const DASHBOARD_IMAGE = 'mcr.microsoft.com/dotnet/aspire-dashboard:13.5.2';
const HEALTH_TIMEOUT_MS = 120_000;
const HEALTH_REQUEST_TIMEOUT_MS = 5_000;
const SETUP_TIMEOUT_MS = 300_000;
const DOCKER_LOG_TAIL_LINES = 50;
const MAX_DOCKER_LOG_CHARS = 32_000;
const DOCKER_LOG_MAX_BUFFER_BYTES = 64 * 1024;

// ============================================================================
// Helpers
// ============================================================================

/** Poll a URL until it responds with 200 or timeout expires. */
async function waitForHealthy(url: string, timeoutMs = 120_000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
interface ContainerState {
Status?: string;
Running?: boolean;
ExitCode?: number;
Error?: string;
}

interface HealthProbe {
fetch: typeof fetch;
getContainerState: () => ContainerState | null;
getContainerLogs: () => string;
timeoutSignal: (milliseconds: number) => AbortSignal;
now: () => number;
sleep: (milliseconds: number) => Promise<void>;
}

interface DockerLogResult {
stdout: string;
stderr: string;
error?: Error;
}

function getContainerState(): ContainerState | null {
try {
const output = execSync(
`docker inspect --format='{{json .State}}' ${CONTAINER_NAME}`,
{ encoding: 'utf8' },
);
return JSON.parse(output) as ContainerState;
} catch {
return null;
}
}

function runContainerLogs(): DockerLogResult {
const result = spawnSync(
'docker',
['logs', '--tail', String(DOCKER_LOG_TAIL_LINES), CONTAINER_NAME],
{
encoding: 'utf8',
maxBuffer: DOCKER_LOG_MAX_BUFFER_BYTES,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
error: result.error,
};
}

function mergeBoundedDockerLogs(stdout: string, stderr: string): string {
const logs = [stdout.trim(), stderr.trim()].filter(Boolean).join('\n');
if (logs.length <= MAX_DOCKER_LOG_CHARS) return logs;

const prefix = `[truncated to last ${MAX_DOCKER_LOG_CHARS} characters]\n`;
return prefix + logs.slice(-(MAX_DOCKER_LOG_CHARS - prefix.length));
}

function getContainerLogs(runLogs: () => DockerLogResult = runContainerLogs): string {
const result = runLogs();
const logs = mergeBoundedDockerLogs(result.stdout, result.stderr);
if (logs) return logs;
if (result.error) {
return `unavailable (${result.error.message})`;
}
return 'unavailable';
}

const DEFAULT_HEALTH_PROBE: HealthProbe = {
fetch,
getContainerState,
getContainerLogs,
timeoutSignal: (milliseconds) => AbortSignal.timeout(milliseconds),
now: Date.now,
sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
};

function startupFailure(
message: string,
state: ContainerState | null,
logs: string,
): Error {
const stateSummary = state
? `status=${state.Status ?? 'unknown'}, running=${state.Running ?? false}, exitCode=${state.ExitCode ?? 'unknown'}, error=${state.Error || 'none'}`
: 'unavailable';
return new Error(`${message}\nContainer state: ${stateSummary}\nContainer logs:\n${logs || '(empty)'}`);
}

/** Poll the dashboard while failing immediately if its container exits. */
async function waitForHealthy(
url: string,
timeoutMs = HEALTH_TIMEOUT_MS,
probe: HealthProbe = DEFAULT_HEALTH_PROBE,
): Promise<void> {
const deadline = probe.now() + timeoutMs;
while (probe.now() < deadline) {
const remainingBeforeRequestMs = deadline - probe.now();
if (remainingBeforeRequestMs <= 0) break;
const requestTimeoutMs = Math.min(
HEALTH_REQUEST_TIMEOUT_MS,
remainingBeforeRequestMs,
);
try {
const res = await fetch(url);
const res = await probe.fetch(url, {
signal: probe.timeoutSignal(requestTimeoutMs),
});
if (res.ok) return;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 1_000));
}
// Surface container diagnostics so a slow/crashed start is distinguishable
// from a genuinely unreachable dashboard without needing to re-run in CI.
let diagnostics = '';
try {
const state = execSync(
`docker inspect --format="{{.State.Status}} (exitCode={{.State.ExitCode}})" ${CONTAINER_NAME}`,
{ encoding: 'utf8' },
).trim();
// `docker logs` relays container stdout/stderr as two separate OS
// streams; execSync only returns stdout by default, so a crash reported
// via stderr (the common case for unhandled .NET exceptions) would be
// silently dropped. Redirect stderr into stdout so it's captured too.
const logs = execSync(`docker logs --tail 50 ${CONTAINER_NAME} 2>&1`, { encoding: 'utf8' });
diagnostics = `\nContainer state: ${state}\nLast logs:\n${logs}`;
} catch (err) {
diagnostics = `\n(failed to collect container diagnostics: ${(err as Error).message})`;

const state = probe.getContainerState();
if (state && state.Running === false && state.Status !== 'created') {
throw startupFailure(
`Dashboard container exited before ${url} became healthy`,
state,
probe.getContainerLogs(),
);
}

const remainingMs = deadline - probe.now();
if (remainingMs <= 0) break;
await probe.sleep(Math.min(1_000, remainingMs));
}
throw new Error(`Dashboard at ${url} did not become healthy within ${timeoutMs}ms${diagnostics}`);

throw startupFailure(
`Dashboard at ${url} did not become healthy within ${timeoutMs}ms`,
probe.getContainerState(),
probe.getContainerLogs(),
);
}

/** Force-remove the test container (ignore errors). */
Expand Down Expand Up @@ -134,6 +235,101 @@ async function shutdownOTel(): Promise<void> {
// Test suite
// ============================================================================

describe('Aspire dashboard startup diagnostics', () => {
it('uses an immutable known-good dashboard image', () => {
expect(DASHBOARD_IMAGE).toMatch(
/^mcr\.microsoft\.com\/dotnet\/aspire-dashboard:\d+\.\d+\.\d+@sha256:[a-f0-9]{64}$/,
);
expect(DASHBOARD_IMAGE).not.toContain(':latest');
});

it('fails immediately with container state and logs when startup exits', async () => {
const sleep = vi.fn(async () => {});
const probe: HealthProbe = {
fetch: vi.fn(async () => { throw new Error('connection refused'); }),
getContainerState: () => ({
Status: 'exited',
Running: false,
ExitCode: 145,
}),
getContainerLogs: () => "The application '/app/Aspire.Dashboard.dll' does not exist.",
timeoutSignal: () => new AbortController().signal,
now: () => 0,
sleep,
};

await expect(waitForHealthy(DASHBOARD_URL, HEALTH_TIMEOUT_MS, probe)).rejects.toThrow(
/container exited[\s\S]*exitCode=145[\s\S]*Aspire\.Dashboard\.dll/,
);
expect(sleep).not.toHaveBeenCalled();
});

it('retains bounded Docker stdout and stderr when logs succeeds', () => {
const logs = getContainerLogs(() => ({
stdout: 'Dashboard starting',
stderr: "The application '/app/Aspire.Dashboard.dll' does not exist.",
}));

expect(logs).toContain('Dashboard starting');
expect(logs).toContain('Aspire.Dashboard.dll');

const boundedLogs = getContainerLogs(() => ({
stdout: 'x'.repeat(MAX_DOCKER_LOG_CHARS),
stderr: "The application '/app/Aspire.Dashboard.dll' does not exist.",
}));
expect(boundedLogs).toContain('Aspire.Dashboard.dll');
expect(boundedLogs.length).toBeLessThanOrEqual(MAX_DOCKER_LOG_CHARS);
});

it('aborts a health request that never returns headers at the deadline', async () => {
const timeoutMs = HEALTH_REQUEST_TIMEOUT_MS + 1_000;
const requestedTimeouts: number[] = [];
let now = 0;
const sleep = vi.fn(async (milliseconds: number) => {
now += milliseconds;
});
const hangingFetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (!signal) {
reject(new Error('missing abort signal'));
return;
}
const abort = () => {
reject(new Error('request aborted'));
};
if (signal.aborted) abort();
else signal.addEventListener('abort', abort, { once: true });
}));
const probe: HealthProbe = {
fetch: hangingFetch as typeof fetch,
getContainerState: () => ({ Status: 'running', Running: true }),
getContainerLogs: () => 'still running',
timeoutSignal: (milliseconds) => {
requestedTimeouts.push(milliseconds);
const controller = new AbortController();
queueMicrotask(() => {
now += milliseconds;
controller.abort();
});
return controller.signal;
},
now: () => now,
sleep,
};

await expect(waitForHealthy(DASHBOARD_URL, timeoutMs, probe)).rejects.toThrow(
`did not become healthy within ${timeoutMs}ms`,
);
expect(requestedTimeouts).toEqual([HEALTH_REQUEST_TIMEOUT_MS]);
expect(hangingFetch).toHaveBeenCalledWith(
DASHBOARD_URL,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(sleep).toHaveBeenCalledExactlyOnceWith(1_000);
});
});

describe.skipIf(SKIP_REASON !== null)(
`Aspire dashboard integration (${SKIP_REASON ?? 'enabled'})`,
() => {
Expand All @@ -146,40 +342,34 @@ describe.skipIf(SKIP_REASON !== null)(
// Clean up any leftover container from a prior run
removeContainer();

// Pull the pinned Aspire dashboard image (see DASHBOARD_IMAGE above)
// Pull the immutable dashboard image validated by this fixture
execSync(
`docker pull ${DASHBOARD_IMAGE}`,
{ stdio: 'inherit', timeout: 120_000 },
);

// Start the Aspire dashboard container.
// Deliberately NOT using --rm: if the container exits/crashes instead
// of merely starting slowly, --rm would auto-delete it before
// waitForHealthy's failure-path diagnostics (docker inspect/logs) can
// run, masking the real cause as a generic timeout. removeContainer()
// (called above and in afterAll) already guarantees explicit cleanup.
// Start the Aspire dashboard container
execSync(
[
'docker run -d',
`-p 18888:18888 -p 4317:18889`,
'-e ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true',
'-e DASHBOARD__FRONTEND__AUTHMODE=Unsecured',
'-e DASHBOARD__OTLP__AUTHMODE=Unsecured',
`--name ${CONTAINER_NAME}`,
DASHBOARD_IMAGE,
].join(' '),
{ stdio: 'inherit' },
);

// 120s gives generous headroom over the dashboard's typical
// few-second cold start, as defense-in-depth against CI runner
// contention (independent of the image-pinning fix above).
await waitForHealthy(DASHBOARD_URL, 120_000);
// Wait for dashboard UI to respond
await waitForHealthy(DASHBOARD_URL);

// Initialize OTel gRPC exporters targeting the dashboard
initOTelForAspire();

// Launch Playwright browser
browser = await chromium.launch({ headless: true });
}, 300_000); // 5 min timeout: 120s pull + 120s health-check budget, plus ~60s margin for container startup, diagnostics, and browser launch
}, SETUP_TIMEOUT_MS); // 5 min timeout for pull + health check under CI contention

// ------------------------------------------------------------------
// Teardown: shutdown OTel, close browser, remove container
Expand Down
Loading