Skip to content

Commit 15b5ece

Browse files
TianqiZhangCopilot
andcommitted
Add PR validation workflow
Add GitHub Actions checks for CLI install, build, tests, and smoke validation. Scope event cache loading so event-specific CLI smoke tests do not fetch unrelated catalogs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8757d62 commit 15b5ece

8 files changed

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

cli/scripts/smoke-live.mjs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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 { promisify } from 'node:util';
6+
7+
const execFileAsync = promisify(execFile);
8+
const eventId = 'build-2026';
9+
10+
function assert(condition, message) {
11+
if (!condition) throw new Error(message);
12+
}
13+
14+
async function runCli(args, cacheDir) {
15+
return execFileAsync(process.execPath, ['dist/index.js', ...args], {
16+
cwd: new URL('..', import.meta.url),
17+
env: { ...process.env, MSEVENTS_CACHE_DIR: cacheDir },
18+
});
19+
}
20+
21+
const cacheDir = await mkdtemp(join(tmpdir(), 'msevents-live-smoke-'));
22+
23+
try {
24+
const refresh = await runCli(['refresh', '--event', eventId, '--force'], cacheDir);
25+
process.stderr.write(refresh.stderr);
26+
27+
const { stdout: statusStdout } = await runCli(['status', '--json'], cacheDir);
28+
const statuses = JSON.parse(statusStdout);
29+
const status = statuses.find((item) => item.eventId === eventId);
30+
assert(status?.meta?.sessionCount > 0, `Expected ${eventId} live catalog cache with sessions`);
31+
32+
const sessions = JSON.parse(await readFile(join(cacheDir, `${eventId}-sessions.json`), 'utf8'));
33+
const sessionCode = sessions.find((session) => session.sessionCode)?.sessionCode;
34+
assert(sessionCode, 'No live session code found');
35+
36+
const { stdout: searchStdout } = await runCli([
37+
'sessions',
38+
'--query',
39+
sessionCode,
40+
'--event',
41+
eventId,
42+
'--limit',
43+
'1',
44+
'--json',
45+
], cacheDir);
46+
const results = JSON.parse(searchStdout);
47+
assert(
48+
Array.isArray(results)
49+
&& results.some((session) => session.sessionCode === sessionCode && session.event === eventId),
50+
`Expected ${eventId} search result for ${sessionCode}`,
51+
);
52+
53+
const { stdout: sessionStdout } = await runCli([
54+
'session',
55+
sessionCode,
56+
'--event',
57+
eventId,
58+
'--json',
59+
], cacheDir);
60+
const session = JSON.parse(sessionStdout);
61+
assert(!Array.isArray(session), `Expected one session for ${sessionCode}`);
62+
assert(session.sessionCode === sessionCode, `Expected session ${sessionCode}, got ${session.sessionCode}`);
63+
assert(session.event === eventId, `Expected ${eventId} session, got ${session.event}`);
64+
} finally {
65+
await rm(cacheDir, { recursive: true, force: true });
66+
}

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

cli/test/cache.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { existsSync } from 'node:fs';
23
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
34
import { join } from 'node:path';
45
import { tmpdir } from 'node:os';
@@ -234,6 +235,92 @@ describe('automatic cache revalidation', () => {
234235
expect(cachedJson[0]?.event).toBe('build-2026');
235236
});
236237

238+
it('uses an existing scoped cache without checking other events', async () => {
239+
await writeCachedEvent('build-2026', {}, 'BRK202');
240+
const originalMeta = await readMeta('build-2026');
241+
const fetchMock = vi.fn();
242+
vi.stubGlobal('fetch', fetchMock);
243+
244+
const sessions = await ensureCache('build-2026');
245+
246+
expect(fetchMock).not.toHaveBeenCalled();
247+
expect(sessions.map((s) => s.sessionCode)).toEqual(['BRK202']);
248+
expect(await readMeta('build-2026')).toEqual(originalMeta);
249+
expect(existsSync(join(cacheDir, 'build-2025-sessions.json'))).toBe(false);
250+
expect(existsSync(join(cacheDir, 'ignite-2025-sessions.json'))).toBe(false);
251+
});
252+
253+
it('fetches a different scoped event without refreshing an existing scoped cache', async () => {
254+
await writeCachedEvent('build-2026', {}, 'BRK202');
255+
const originalBuild2026Meta = await readMeta('build-2026');
256+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(
257+
[{ sessionCode: 'BRK101', title: 'Build 2025 session' }],
258+
{ etag: '"2025"', 'last-modified': 'Thu, 07 May 2026 02:55:00 GMT' },
259+
));
260+
vi.stubGlobal('fetch', fetchMock);
261+
262+
const sessions = await ensureCache('build-2025');
263+
264+
expect(fetchMock).toHaveBeenCalledTimes(1);
265+
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://aka.ms/build2025-session-info');
266+
expect(sessions.map((s) => s.sessionCode)).toEqual(['BRK101']);
267+
expect(await readMeta('build-2026')).toEqual(originalBuild2026Meta);
268+
expect(existsSync(join(cacheDir, 'ignite-2025-sessions.json'))).toBe(false);
269+
});
270+
271+
it('reuses existing caches and fetches missing caches when loading all events', async () => {
272+
await writeCachedEvent('build-2026', {}, 'BRK202');
273+
const originalBuild2026Meta = await readMeta('build-2026');
274+
const fetchMock = vi.fn()
275+
.mockResolvedValueOnce(jsonResponse(
276+
[{ sessionCode: 'BRK101', title: 'Build 2025 session' }],
277+
{ etag: '"2025"', 'last-modified': 'Thu, 07 May 2026 02:55:00 GMT' },
278+
))
279+
.mockResolvedValueOnce(jsonResponse(
280+
[{ sessionCode: 'IGN301', title: 'Ignite 2025 session' }],
281+
{ etag: '"ign2025"', 'last-modified': 'Thu, 07 May 2026 02:55:30 GMT' },
282+
));
283+
vi.stubGlobal('fetch', fetchMock);
284+
285+
const sessions = await ensureCache();
286+
287+
expect(fetchMock).toHaveBeenCalledTimes(2);
288+
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
289+
'https://aka.ms/build2025-session-info',
290+
'https://aka.ms/ignite2025-session-info',
291+
]);
292+
expect(sessions.map((s) => s.sessionCode).sort()).toEqual(['BRK101', 'BRK202', 'IGN301']);
293+
expect(await readMeta('build-2026')).toEqual(originalBuild2026Meta);
294+
});
295+
296+
it('fetches only the requested event when cache loading is scoped', async () => {
297+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(
298+
[{ sessionCode: 'BRK202', title: 'Build 2026 session' }],
299+
{ etag: '"2026"', 'last-modified': 'Thu, 07 May 2026 02:56:00 GMT' },
300+
));
301+
vi.stubGlobal('fetch', fetchMock);
302+
303+
const sessions = await ensureCache('build-2026');
304+
305+
expect(fetchMock).toHaveBeenCalledTimes(1);
306+
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://aka.ms/build2026-session-info');
307+
expect(sessions.map((s) => s.event)).toEqual(['build-2026']);
308+
expect(existsSync(join(cacheDir, 'build-2026-sessions.json'))).toBe(true);
309+
expect(existsSync(join(cacheDir, 'build-2025-sessions.json'))).toBe(false);
310+
expect(existsSync(join(cacheDir, 'ignite-2025-sessions.json'))).toBe(false);
311+
});
312+
313+
it('does not fall back to unrelated cached events when scoped cache loading fails', async () => {
314+
await writeCachedEvent('build-2025');
315+
const fetchMock = vi.fn().mockRejectedValue(new TypeError('network down'));
316+
vi.stubGlobal('fetch', fetchMock);
317+
318+
const sessions = await ensureCache('build-2026');
319+
320+
expect(fetchMock).toHaveBeenCalledTimes(1);
321+
expect(sessions).toEqual([]);
322+
});
323+
237324
it('reports unchanged refreshes when the remote catalog returns 304', async () => {
238325
await writeCachedEvent('build-2026');
239326
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 304 }));

0 commit comments

Comments
 (0)