Skip to content

Commit 43c14f5

Browse files
authored
Merge pull request #26 from microsoft/ci/pr-validation-smoke-tests
Add PR validation workflow
2 parents 9ff42eb + 8f0ae34 commit 43c14f5

8 files changed

Lines changed: 338 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
branches: [ "main" ]
6+
push:
7+
branches: [ "main" ]
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.ref }}
15+
cancel-in-progress: true
16+
17+
jobs:
18+
cli:
19+
name: CLI build and test
20+
runs-on: ubuntu-latest
21+
22+
defaults:
23+
run:
24+
working-directory: cli
25+
26+
steps:
27+
- name: Checkout
28+
uses: actions/checkout@v4
29+
30+
- name: Setup Node.js
31+
uses: actions/setup-node@v4
32+
with:
33+
node-version: 22.x
34+
cache: npm
35+
cache-dependency-path: cli/package-lock.json
36+
37+
- name: Install dependencies
38+
run: npm ci
39+
40+
- name: Build
41+
run: npm run build
42+
43+
- name: Test
44+
run: npm test
45+
46+
- name: Smoke test CLI commands with cached fixture
47+
run: npm run smoke:fixture
48+
49+
- name: Smoke test live catalog
50+
if: github.event_name != 'pull_request'
51+
run: npm run smoke:live

cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
],
1313
"scripts": {
1414
"build": "tsc -p tsconfig.json",
15+
"smoke:fixture": "node scripts/smoke-fixture.mjs",
16+
"smoke:live": "node scripts/smoke-live.mjs",
1517
"test": "vitest run"
1618
},
1719
"keywords": [

cli/scripts/smoke-fixture.mjs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { execFile } from 'node:child_process';
2+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
import { promisify } from 'node:util';
7+
import { normalizeCatalog } from '../dist/data/normalize.js';
8+
9+
const execFileAsync = promisify(execFile);
10+
const cliRoot = fileURLToPath(new URL('..', import.meta.url));
11+
const commandTimeoutMs = 60_000;
12+
const eventId = 'build-2025';
13+
14+
function assert(condition, message) {
15+
if (!condition) throw new Error(message);
16+
}
17+
18+
async function runCli(args, cacheDir) {
19+
return execFileAsync(process.execPath, ['dist/index.js', ...args], {
20+
cwd: cliRoot,
21+
env: { ...process.env, MSEVENTS_CACHE_DIR: cacheDir },
22+
timeout: commandTimeoutMs,
23+
});
24+
}
25+
26+
const cacheDir = await mkdtemp(join(tmpdir(), 'msevents-fixture-smoke-'));
27+
28+
try {
29+
const raw = JSON.parse(await readFile('test/fixtures/build-2025-sample.json', 'utf8'));
30+
const sessions = normalizeCatalog(raw, eventId);
31+
assert(sessions.length > 0, 'Expected fixture to contain sessions');
32+
33+
await mkdir(cacheDir, { recursive: true });
34+
await writeFile(join(cacheDir, `${eventId}-sessions.json`), JSON.stringify(sessions));
35+
await writeFile(join(cacheDir, `${eventId}-meta.json`), JSON.stringify({
36+
eventId,
37+
fetchedAt: '2026-01-01T00:00:00.000Z',
38+
checkedAt: '2026-01-01T00:00:00.000Z',
39+
nextCheckAt: '2099-01-01T00:00:00.000Z',
40+
sessionCount: sessions.length,
41+
lastCheckStatus: 'updated',
42+
consecutiveFailures: 0,
43+
}, null, 2));
44+
45+
await runCli(['--help'], cacheDir);
46+
47+
const { stdout: searchStdout } = await runCli([
48+
'sessions',
49+
'--query',
50+
'Foundry',
51+
'--event',
52+
eventId,
53+
'--limit',
54+
'1',
55+
'--json',
56+
], cacheDir);
57+
const results = JSON.parse(searchStdout);
58+
assert(Array.isArray(results), 'Expected search output to be an array');
59+
assert(results.length === 1, `Expected one search result, got ${results.length}`);
60+
assert(results[0].event === eventId, `Expected ${eventId} search result, got ${results[0].event}`);
61+
62+
const sessionCode = sessions.find((session) => session.sessionCode)?.sessionCode;
63+
assert(sessionCode, 'No cached session code found');
64+
65+
const { stdout: sessionStdout } = await runCli([
66+
'session',
67+
sessionCode,
68+
'--event',
69+
eventId,
70+
'--json',
71+
], cacheDir);
72+
const session = JSON.parse(sessionStdout);
73+
assert(!Array.isArray(session), `Expected one session for ${sessionCode}`);
74+
assert(session.sessionCode === sessionCode, `Expected session ${sessionCode}, got ${session.sessionCode}`);
75+
assert(session.event === eventId, `Expected ${eventId} session, got ${session.event}`);
76+
} finally {
77+
await rm(cacheDir, { recursive: true, force: true });
78+
}

cli/scripts/smoke-live.mjs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { execFile } from 'node:child_process';
2+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
import { promisify } from 'node:util';
7+
8+
const execFileAsync = promisify(execFile);
9+
const cliRoot = fileURLToPath(new URL('..', import.meta.url));
10+
const commandTimeoutMs = 60_000;
11+
const liveRefreshAttempts = 3;
12+
const liveRefreshRetryDelayMs = 5_000;
13+
const eventId = 'build-2026';
14+
15+
function assert(condition, message) {
16+
if (!condition) throw new Error(message);
17+
}
18+
19+
async function runCli(args, cacheDir) {
20+
return execFileAsync(process.execPath, ['dist/index.js', ...args], {
21+
cwd: cliRoot,
22+
env: { ...process.env, MSEVENTS_CACHE_DIR: cacheDir },
23+
timeout: commandTimeoutMs,
24+
});
25+
}
26+
27+
function formatError(error) {
28+
if (error && typeof error === 'object') {
29+
const message = error.message ?? String(error);
30+
const stderr = error.stderr ? `\n${error.stderr}` : '';
31+
return `${message}${stderr}`;
32+
}
33+
return String(error);
34+
}
35+
36+
async function delay(ms) {
37+
await new Promise((resolve) => {
38+
setTimeout(resolve, ms);
39+
});
40+
}
41+
42+
async function retryLiveRefresh(cacheDir) {
43+
let lastError;
44+
for (let attempt = 1; attempt <= liveRefreshAttempts; attempt += 1) {
45+
try {
46+
return await runCli(['refresh', '--event', eventId, '--force'], cacheDir);
47+
} catch (error) {
48+
lastError = error;
49+
if (attempt === liveRefreshAttempts) break;
50+
process.stderr.write(
51+
`Live catalog refresh failed on attempt ${attempt}/${liveRefreshAttempts}: ${formatError(error)}\n` +
52+
`Retrying in ${liveRefreshRetryDelayMs / 1000}s...\n`,
53+
);
54+
await delay(liveRefreshRetryDelayMs);
55+
}
56+
}
57+
58+
throw lastError;
59+
}
60+
61+
const cacheDir = await mkdtemp(join(tmpdir(), 'msevents-live-smoke-'));
62+
63+
try {
64+
const refresh = await retryLiveRefresh(cacheDir);
65+
process.stderr.write(refresh.stderr);
66+
67+
const { stdout: statusStdout } = await runCli(['status', '--json'], cacheDir);
68+
const statuses = JSON.parse(statusStdout);
69+
const status = statuses.find((item) => item.eventId === eventId);
70+
assert(status?.meta?.sessionCount > 0, `Expected ${eventId} live catalog cache with sessions`);
71+
72+
const sessions = JSON.parse(await readFile(join(cacheDir, `${eventId}-sessions.json`), 'utf8'));
73+
const sessionCode = sessions.find((session) => session.sessionCode)?.sessionCode;
74+
assert(sessionCode, 'No live session code found');
75+
76+
const { stdout: searchStdout } = await runCli([
77+
'sessions',
78+
'--query',
79+
sessionCode,
80+
'--event',
81+
eventId,
82+
'--limit',
83+
'1',
84+
'--json',
85+
], cacheDir);
86+
const results = JSON.parse(searchStdout);
87+
assert(
88+
Array.isArray(results)
89+
&& results.some((session) => session.sessionCode === sessionCode && session.event === eventId),
90+
`Expected ${eventId} search result for ${sessionCode}`,
91+
);
92+
93+
const { stdout: sessionStdout } = await runCli([
94+
'session',
95+
sessionCode,
96+
'--event',
97+
eventId,
98+
'--json',
99+
], cacheDir);
100+
const session = JSON.parse(sessionStdout);
101+
assert(!Array.isArray(session), `Expected one session for ${sessionCode}`);
102+
assert(session.sessionCode === sessionCode, `Expected session ${sessionCode}, got ${session.sessionCode}`);
103+
assert(session.event === eventId, `Expected ${eventId} session, got ${session.event}`);
104+
} finally {
105+
await rm(cacheDir, { recursive: true, force: true });
106+
}

cli/src/commands/common.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@ export function validateEventId(eventId: string): boolean {
1717
return false;
1818
}
1919

20-
export async function ensureCache(): Promise<Session[]> {
20+
export async function ensureCache(eventFilter?: string): Promise<Session[]> {
2121
let missingCacheHeaderPrinted = false;
2222
const availableSessions: Session[] = [];
23+
const events = eventFilter
24+
? KNOWN_EVENTS.filter((event) => event.id === eventFilter)
25+
: KNOWN_EVENTS;
2326

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

6568
return availableSessions.length > 0
6669
? availableSessions
67-
: getAllCachedSessions();
70+
: eventFilter
71+
? readSessions(eventFilter)
72+
: getAllCachedSessions();
6873
}

cli/src/commands/session.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { buildIndex, findSession } from '../search/index.js';
22
import { formatSessionDetail } from '../output/format.js';
3-
import { ensureCache } from './common.js';
3+
import { ensureCache, validateEventId } from './common.js';
44

55
export async function session(
66
code: string,
77
opts: { event?: string; json?: boolean },
88
): Promise<void> {
9-
const all = await ensureCache();
9+
if (opts.event && !validateEventId(opts.event)) return;
10+
const all = await ensureCache(opts.event);
1011
buildIndex(all);
1112

1213
const matches = findSession(code, opts.event);

cli/src/commands/sessions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { buildIndex, searchSessions, type SearchOptions } from '../search/index.js';
22
import { formatSearchResults } from '../output/format.js';
3-
import { ensureCache } from './common.js';
3+
import { ensureCache, validateEventId } from './common.js';
44

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

910
const results = searchSessions(opts);

0 commit comments

Comments
 (0)