From 15b5ece05a4a3e0d325037278368499e60501284 Mon Sep 17 00:00:00 2001 From: Tianqi Zhang Date: Wed, 13 May 2026 10:12:47 +0800 Subject: [PATCH 1/2] 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> --- .github/workflows/ci.yml | 51 ++++++++++++++++++++ cli/package.json | 2 + cli/scripts/smoke-fixture.mjs | 74 +++++++++++++++++++++++++++++ cli/scripts/smoke-live.mjs | 66 ++++++++++++++++++++++++++ cli/src/commands/common.ts | 11 +++-- cli/src/commands/session.ts | 5 +- cli/src/commands/sessions.ts | 5 +- cli/test/cache.test.ts | 87 +++++++++++++++++++++++++++++++++++ 8 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 cli/scripts/smoke-fixture.mjs create mode 100644 cli/scripts/smoke-live.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f080f4e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/cli/package.json b/cli/package.json index 9cf9a9a..0370cd0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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": [ diff --git a/cli/scripts/smoke-fixture.mjs b/cli/scripts/smoke-fixture.mjs new file mode 100644 index 0000000..bed1d21 --- /dev/null +++ b/cli/scripts/smoke-fixture.mjs @@ -0,0 +1,74 @@ +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 { promisify } from 'node:util'; +import { normalizeCatalog } from '../dist/data/normalize.js'; + +const execFileAsync = promisify(execFile); +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: new URL('..', import.meta.url), + env: { ...process.env, MSEVENTS_CACHE_DIR: cacheDir }, + }); +} + +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 }); +} diff --git a/cli/scripts/smoke-live.mjs b/cli/scripts/smoke-live.mjs new file mode 100644 index 0000000..1c42376 --- /dev/null +++ b/cli/scripts/smoke-live.mjs @@ -0,0 +1,66 @@ +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 { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +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: new URL('..', import.meta.url), + env: { ...process.env, MSEVENTS_CACHE_DIR: cacheDir }, + }); +} + +const cacheDir = await mkdtemp(join(tmpdir(), 'msevents-live-smoke-')); + +try { + const refresh = await runCli(['refresh', '--event', eventId, '--force'], 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 }); +} diff --git a/cli/src/commands/common.ts b/cli/src/commands/common.ts index 995def4..5b6ebf9 100644 --- a/cli/src/commands/common.ts +++ b/cli/src/commands/common.ts @@ -17,11 +17,14 @@ export function validateEventId(eventId: string): boolean { return false; } -export async function ensureCache(): Promise { +export async function ensureCache(eventFilter?: string): Promise { 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; @@ -64,5 +67,7 @@ export async function ensureCache(): Promise { return availableSessions.length > 0 ? availableSessions - : getAllCachedSessions(); + : eventFilter + ? readSessions(eventFilter) + : getAllCachedSessions(); } diff --git a/cli/src/commands/session.ts b/cli/src/commands/session.ts index da57657..a8f107d 100644 --- a/cli/src/commands/session.ts +++ b/cli/src/commands/session.ts @@ -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 { - 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); diff --git a/cli/src/commands/sessions.ts b/cli/src/commands/sessions.ts index e1087f7..287b084 100644 --- a/cli/src/commands/sessions.ts +++ b/cli/src/commands/sessions.ts @@ -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 { - const all = await ensureCache(); + if (opts.event && !validateEventId(opts.event)) return; + const all = await ensureCache(opts.event); buildIndex(all); const results = searchSessions(opts); diff --git a/cli/test/cache.test.ts b/cli/test/cache.test.ts index f4522ed..09101da 100644 --- a/cli/test/cache.test.ts +++ b/cli/test/cache.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync } from 'node:fs'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -234,6 +235,92 @@ describe('automatic cache revalidation', () => { expect(cachedJson[0]?.event).toBe('build-2026'); }); + it('uses an existing scoped cache without checking other events', async () => { + await writeCachedEvent('build-2026', {}, 'BRK202'); + const originalMeta = await readMeta('build-2026'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const sessions = await ensureCache('build-2026'); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(sessions.map((s) => s.sessionCode)).toEqual(['BRK202']); + expect(await readMeta('build-2026')).toEqual(originalMeta); + expect(existsSync(join(cacheDir, 'build-2025-sessions.json'))).toBe(false); + expect(existsSync(join(cacheDir, 'ignite-2025-sessions.json'))).toBe(false); + }); + + it('fetches a different scoped event without refreshing an existing scoped cache', async () => { + await writeCachedEvent('build-2026', {}, 'BRK202'); + const originalBuild2026Meta = await readMeta('build-2026'); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse( + [{ sessionCode: 'BRK101', title: 'Build 2025 session' }], + { etag: '"2025"', 'last-modified': 'Thu, 07 May 2026 02:55:00 GMT' }, + )); + vi.stubGlobal('fetch', fetchMock); + + const sessions = await ensureCache('build-2025'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://aka.ms/build2025-session-info'); + expect(sessions.map((s) => s.sessionCode)).toEqual(['BRK101']); + expect(await readMeta('build-2026')).toEqual(originalBuild2026Meta); + expect(existsSync(join(cacheDir, 'ignite-2025-sessions.json'))).toBe(false); + }); + + it('reuses existing caches and fetches missing caches when loading all events', async () => { + await writeCachedEvent('build-2026', {}, 'BRK202'); + const originalBuild2026Meta = await readMeta('build-2026'); + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse( + [{ sessionCode: 'BRK101', title: 'Build 2025 session' }], + { etag: '"2025"', 'last-modified': 'Thu, 07 May 2026 02:55:00 GMT' }, + )) + .mockResolvedValueOnce(jsonResponse( + [{ sessionCode: 'IGN301', title: 'Ignite 2025 session' }], + { etag: '"ign2025"', 'last-modified': 'Thu, 07 May 2026 02:55:30 GMT' }, + )); + vi.stubGlobal('fetch', fetchMock); + + const sessions = await ensureCache(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'https://aka.ms/build2025-session-info', + 'https://aka.ms/ignite2025-session-info', + ]); + expect(sessions.map((s) => s.sessionCode).sort()).toEqual(['BRK101', 'BRK202', 'IGN301']); + expect(await readMeta('build-2026')).toEqual(originalBuild2026Meta); + }); + + it('fetches only the requested event when cache loading is scoped', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse( + [{ sessionCode: 'BRK202', title: 'Build 2026 session' }], + { etag: '"2026"', 'last-modified': 'Thu, 07 May 2026 02:56:00 GMT' }, + )); + vi.stubGlobal('fetch', fetchMock); + + const sessions = await ensureCache('build-2026'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://aka.ms/build2026-session-info'); + expect(sessions.map((s) => s.event)).toEqual(['build-2026']); + expect(existsSync(join(cacheDir, 'build-2026-sessions.json'))).toBe(true); + expect(existsSync(join(cacheDir, 'build-2025-sessions.json'))).toBe(false); + expect(existsSync(join(cacheDir, 'ignite-2025-sessions.json'))).toBe(false); + }); + + it('does not fall back to unrelated cached events when scoped cache loading fails', async () => { + await writeCachedEvent('build-2025'); + const fetchMock = vi.fn().mockRejectedValue(new TypeError('network down')); + vi.stubGlobal('fetch', fetchMock); + + const sessions = await ensureCache('build-2026'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(sessions).toEqual([]); + }); + it('reports unchanged refreshes when the remote catalog returns 304', async () => { await writeCachedEvent('build-2026'); const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 304 })); From 8f0ae346427f3605e7149890d299fb2f738ea4c9 Mon Sep 17 00:00:00 2001 From: Tianqi Zhang Date: Wed, 13 May 2026 11:31:27 +0800 Subject: [PATCH 2/2] Harden smoke test scripts Convert smoke script working directories to filesystem paths and add timeout/retry handling for live catalog refreshes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/scripts/smoke-fixture.mjs | 6 ++++- cli/scripts/smoke-live.mjs | 44 +++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/cli/scripts/smoke-fixture.mjs b/cli/scripts/smoke-fixture.mjs index bed1d21..a52d30d 100644 --- a/cli/scripts/smoke-fixture.mjs +++ b/cli/scripts/smoke-fixture.mjs @@ -2,10 +2,13 @@ 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) { @@ -14,8 +17,9 @@ function assert(condition, message) { async function runCli(args, cacheDir) { return execFileAsync(process.execPath, ['dist/index.js', ...args], { - cwd: new URL('..', import.meta.url), + cwd: cliRoot, env: { ...process.env, MSEVENTS_CACHE_DIR: cacheDir }, + timeout: commandTimeoutMs, }); } diff --git a/cli/scripts/smoke-live.mjs b/cli/scripts/smoke-live.mjs index 1c42376..f31bde1 100644 --- a/cli/scripts/smoke-live.mjs +++ b/cli/scripts/smoke-live.mjs @@ -2,9 +2,14 @@ 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) { @@ -13,15 +18,50 @@ function assert(condition, message) { async function runCli(args, cacheDir) { return execFileAsync(process.execPath, ['dist/index.js', ...args], { - cwd: new URL('..', import.meta.url), + cwd: cliRoot, env: { ...process.env, MSEVENTS_CACHE_DIR: cacheDir }, + timeout: commandTimeoutMs, }); } +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 runCli(['refresh', '--event', eventId, '--force'], cacheDir); + const refresh = await retryLiveRefresh(cacheDir); process.stderr.write(refresh.stderr); const { stdout: statusStdout } = await runCli(['status', '--json'], cacheDir);