Skip to content
Merged
Show file tree
Hide file tree
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
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: CI

on:
pull_request:
branches: [ "main" ]
push:
branches: [ "main" ]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
cli:
name: CLI build and test
runs-on: ubuntu-latest

defaults:
run:
working-directory: cli

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: npm
cache-dependency-path: cli/package-lock.json

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

- name: Test
run: npm test

- name: Smoke test CLI commands with cached fixture
run: npm run smoke:fixture

- name: Smoke test live catalog
if: github.event_name != 'pull_request'
run: npm run smoke:live
Comment thread
TianqiZhang marked this conversation as resolved.
2 changes: 2 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
],
"scripts": {
"build": "tsc -p tsconfig.json",
"smoke:fixture": "node scripts/smoke-fixture.mjs",
"smoke:live": "node scripts/smoke-live.mjs",
"test": "vitest run"
},
"keywords": [
Expand Down
78 changes: 78 additions & 0 deletions cli/scripts/smoke-fixture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { normalizeCatalog } from '../dist/data/normalize.js';

const execFileAsync = promisify(execFile);
const cliRoot = fileURLToPath(new URL('..', import.meta.url));
const commandTimeoutMs = 60_000;
const eventId = 'build-2025';

function assert(condition, message) {
if (!condition) throw new Error(message);
}

async function runCli(args, cacheDir) {
return execFileAsync(process.execPath, ['dist/index.js', ...args], {
cwd: cliRoot,
env: { ...process.env, MSEVENTS_CACHE_DIR: cacheDir },
timeout: commandTimeoutMs,
});
Comment thread
TianqiZhang marked this conversation as resolved.
}

const cacheDir = await mkdtemp(join(tmpdir(), 'msevents-fixture-smoke-'));

try {
const raw = JSON.parse(await readFile('test/fixtures/build-2025-sample.json', 'utf8'));
const sessions = normalizeCatalog(raw, eventId);
assert(sessions.length > 0, 'Expected fixture to contain sessions');

await mkdir(cacheDir, { recursive: true });
await writeFile(join(cacheDir, `${eventId}-sessions.json`), JSON.stringify(sessions));
await writeFile(join(cacheDir, `${eventId}-meta.json`), JSON.stringify({
eventId,
fetchedAt: '2026-01-01T00:00:00.000Z',
checkedAt: '2026-01-01T00:00:00.000Z',
nextCheckAt: '2099-01-01T00:00:00.000Z',
sessionCount: sessions.length,
lastCheckStatus: 'updated',
consecutiveFailures: 0,
}, null, 2));

await runCli(['--help'], cacheDir);

const { stdout: searchStdout } = await runCli([
'sessions',
'--query',
'Foundry',
'--event',
eventId,
'--limit',
'1',
'--json',
], cacheDir);
const results = JSON.parse(searchStdout);
assert(Array.isArray(results), 'Expected search output to be an array');
assert(results.length === 1, `Expected one search result, got ${results.length}`);
assert(results[0].event === eventId, `Expected ${eventId} search result, got ${results[0].event}`);

const sessionCode = sessions.find((session) => session.sessionCode)?.sessionCode;
assert(sessionCode, 'No cached session code found');

const { stdout: sessionStdout } = await runCli([
'session',
sessionCode,
'--event',
eventId,
'--json',
], cacheDir);
const session = JSON.parse(sessionStdout);
assert(!Array.isArray(session), `Expected one session for ${sessionCode}`);
assert(session.sessionCode === sessionCode, `Expected session ${sessionCode}, got ${session.sessionCode}`);
assert(session.event === eventId, `Expected ${eventId} session, got ${session.event}`);
} finally {
await rm(cacheDir, { recursive: true, force: true });
}
106 changes: 106 additions & 0 deletions cli/scripts/smoke-live.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);
const cliRoot = fileURLToPath(new URL('..', import.meta.url));
const commandTimeoutMs = 60_000;
const liveRefreshAttempts = 3;
const liveRefreshRetryDelayMs = 5_000;
const eventId = 'build-2026';

function assert(condition, message) {
if (!condition) throw new Error(message);
}

async function runCli(args, cacheDir) {
return execFileAsync(process.execPath, ['dist/index.js', ...args], {
cwd: cliRoot,
env: { ...process.env, MSEVENTS_CACHE_DIR: cacheDir },
timeout: commandTimeoutMs,
});
Comment thread
TianqiZhang marked this conversation as resolved.
}

function formatError(error) {
if (error && typeof error === 'object') {
const message = error.message ?? String(error);
const stderr = error.stderr ? `\n${error.stderr}` : '';
return `${message}${stderr}`;
}
return String(error);
}

async function delay(ms) {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

async function retryLiveRefresh(cacheDir) {
let lastError;
for (let attempt = 1; attempt <= liveRefreshAttempts; attempt += 1) {
try {
return await runCli(['refresh', '--event', eventId, '--force'], cacheDir);
} catch (error) {
lastError = error;
if (attempt === liveRefreshAttempts) break;
process.stderr.write(
`Live catalog refresh failed on attempt ${attempt}/${liveRefreshAttempts}: ${formatError(error)}\n` +
`Retrying in ${liveRefreshRetryDelayMs / 1000}s...\n`,
);
await delay(liveRefreshRetryDelayMs);
}
}

throw lastError;
}

const cacheDir = await mkdtemp(join(tmpdir(), 'msevents-live-smoke-'));

try {
const refresh = await retryLiveRefresh(cacheDir);
process.stderr.write(refresh.stderr);

const { stdout: statusStdout } = await runCli(['status', '--json'], cacheDir);
const statuses = JSON.parse(statusStdout);
const status = statuses.find((item) => item.eventId === eventId);
assert(status?.meta?.sessionCount > 0, `Expected ${eventId} live catalog cache with sessions`);

const sessions = JSON.parse(await readFile(join(cacheDir, `${eventId}-sessions.json`), 'utf8'));
const sessionCode = sessions.find((session) => session.sessionCode)?.sessionCode;
assert(sessionCode, 'No live session code found');

const { stdout: searchStdout } = await runCli([
'sessions',
'--query',
sessionCode,
'--event',
eventId,
'--limit',
'1',
'--json',
], cacheDir);
const results = JSON.parse(searchStdout);
assert(
Array.isArray(results)
&& results.some((session) => session.sessionCode === sessionCode && session.event === eventId),
`Expected ${eventId} search result for ${sessionCode}`,
);

const { stdout: sessionStdout } = await runCli([
'session',
sessionCode,
'--event',
eventId,
'--json',
], cacheDir);
const session = JSON.parse(sessionStdout);
assert(!Array.isArray(session), `Expected one session for ${sessionCode}`);
assert(session.sessionCode === sessionCode, `Expected session ${sessionCode}, got ${session.sessionCode}`);
assert(session.event === eventId, `Expected ${eventId} session, got ${session.event}`);
} finally {
await rm(cacheDir, { recursive: true, force: true });
}
11 changes: 8 additions & 3 deletions cli/src/commands/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ export function validateEventId(eventId: string): boolean {
return false;
}

export async function ensureCache(): Promise<Session[]> {
export async function ensureCache(eventFilter?: string): Promise<Session[]> {
let missingCacheHeaderPrinted = false;
const availableSessions: Session[] = [];
const events = eventFilter
? KNOWN_EVENTS.filter((event) => event.id === eventFilter)
: KNOWN_EVENTS;

for (const event of KNOWN_EVENTS) {
for (const event of events) {
const cachedSessions = await readSessions(event.id);
const meta = await readMeta(event.id);
const isMissingCache = cachedSessions.length === 0;
Expand Down Expand Up @@ -64,5 +67,7 @@ export async function ensureCache(): Promise<Session[]> {

return availableSessions.length > 0
? availableSessions
: getAllCachedSessions();
: eventFilter
? readSessions(eventFilter)
: getAllCachedSessions();
}
5 changes: 3 additions & 2 deletions cli/src/commands/session.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { buildIndex, findSession } from '../search/index.js';
import { formatSessionDetail } from '../output/format.js';
import { ensureCache } from './common.js';
import { ensureCache, validateEventId } from './common.js';

export async function session(
code: string,
opts: { event?: string; json?: boolean },
): Promise<void> {
const all = await ensureCache();
if (opts.event && !validateEventId(opts.event)) return;
const all = await ensureCache(opts.event);
buildIndex(all);

const matches = findSession(code, opts.event);
Expand Down
5 changes: 3 additions & 2 deletions cli/src/commands/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { buildIndex, searchSessions, type SearchOptions } from '../search/index.js';
import { formatSearchResults } from '../output/format.js';
import { ensureCache } from './common.js';
import { ensureCache, validateEventId } from './common.js';

export async function sessions(opts: SearchOptions & { json?: boolean }): Promise<void> {
const all = await ensureCache();
if (opts.event && !validateEventId(opts.event)) return;
const all = await ensureCache(opts.event);
buildIndex(all);

const results = searchSessions(opts);
Expand Down
Loading
Loading