diff --git a/.Jules/vantage.md b/.Jules/vantage.md index e63beebe..485442fa 100644 --- a/.Jules/vantage.md +++ b/.Jules/vantage.md @@ -13,3 +13,5 @@ ## 2026-05-15 - Standardizing Telemetry Resilience and Verifying Infrastructure Mocks | **Learning:** Telemetry must capture the full context of unknown errors via `String(e)` to avoid masking failure signals, and re-exporting mocks requires explicit execution to register in coverage reports. | **Action:** Updated `src/utils/github-releases.ts` to use `String(error)` in catch blocks and added an `exerciseMock` helper to `src/__tests__/mocks/astro-zod.ts` to achieve 100% coverage. ## 2026-05-20 - Hardening Telemetry Verification and Refining Stream Types | **Learning:** Index-based assertions in telemetry tests are brittle; using `.find()` ensures robust verification. Standardizing on `String(error)` across all API catch blocks prevents context loss during failure events. | **Action:** Refactored `chat-api.test.ts` and `github-releases.test.ts` to use search-based spy assertions, and added explicit return types to `chat-stream.ts` to resolve Astro compiler hints. + +## 2026-05-28 - Testing Releases Endpoint and Resolving Ambient Node Type Diagnostics | **Learning:** Testing API endpoints against worker bindings requires explicit isolation of Cloudflare environment objects, and providing ambient Node type declarations in `src/env.d.ts` allows Astro check to validate root test scripts without introducing package dependencies. | **Action:** Added `src/__tests__/releases-api.test.ts` to test `/api/releases` with 100% function coverage, and refined ambient Node types in `src/env.d.ts` and Zod type guards in `src/__tests__/content.config.test.ts`. diff --git a/__tests__/release.spec.ts b/__tests__/release.spec.ts index 4d94c54a..50e62dfd 100644 --- a/__tests__/release.spec.ts +++ b/__tests__/release.spec.ts @@ -62,7 +62,7 @@ describe('release script', () => { it('formats changelog output from commits since the last tag', async () => { process.env.GITHUB_OUTPUT = 'mock_github_output'; - vi.mocked(child_process.execSync).mockImplementation((cmd) => { + vi.mocked(child_process.execSync).mockImplementation((cmd: unknown) => { if (typeof cmd === 'string' && cmd.includes('describe --tags')) return Buffer.from('v1.0.0'); if (typeof cmd === 'string' && cmd.includes('log')) return Buffer.from('feat: new feature\nfix: bug fix'); @@ -79,7 +79,7 @@ describe('release script', () => { it('skips [skip ci] commits and falls back to internal CI updates when needed', async () => { process.env.GITHUB_OUTPUT = 'mock_github_output'; - vi.mocked(child_process.execSync).mockImplementation((cmd) => { + vi.mocked(child_process.execSync).mockImplementation((cmd: unknown) => { if (typeof cmd === 'string' && cmd.includes('describe --tags')) return Buffer.from('v1.0.0'); if (typeof cmd === 'string' && cmd.includes('log')) return Buffer.from('chore: refresh metadata [skip ci]'); @@ -96,7 +96,7 @@ describe('release script', () => { it('handles missing tags by using the full git log', async () => { process.env.GITHUB_OUTPUT = 'mock_github_output'; - vi.mocked(child_process.execSync).mockImplementation((cmd) => { + vi.mocked(child_process.execSync).mockImplementation((cmd: unknown) => { if (typeof cmd === 'string' && cmd.includes('describe --tags')) throw new Error('No tags'); return Buffer.from('feat: initial release'); }); @@ -111,7 +111,7 @@ describe('release script', () => { it('falls back to no documented changes when git log is unavailable', async () => { process.env.GITHUB_OUTPUT = 'mock_github_output'; - vi.mocked(child_process.execSync).mockImplementation((cmd) => { + vi.mocked(child_process.execSync).mockImplementation((cmd: unknown) => { if (typeof cmd === 'string' && cmd.includes('describe --tags')) return Buffer.from('v1.0.0'); if (typeof cmd === 'string' && cmd.includes('log')) throw new Error('Git log failed'); return Buffer.from(''); diff --git a/astro.config.mjs b/astro.config.mjs index 4e115873..cb70f30a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -74,7 +74,6 @@ export default defineConfig({ }), ], vite: { - // @ts-expect-error Codecov's Vite plugin is typed against a different Vite instance than Astro's bundled one. plugins: [codecovPlugin], resolve: isRender ? { diff --git a/src/__tests__/content.config.test.ts b/src/__tests__/content.config.test.ts index 9b6b8596..d8bc6f17 100644 --- a/src/__tests__/content.config.test.ts +++ b/src/__tests__/content.config.test.ts @@ -5,6 +5,18 @@ import { defineCollection } from 'astro:content'; import { collections } from '../content.config'; import flagsFixture from '../content/flags/config.json'; +interface ZodSchemaWithSafeParse { + safeParse: (input: unknown) => { + success: boolean; + data?: Record; + error?: unknown; + }; +} + +function isSchemaWithSafeParse(schema: unknown): schema is ZodSchemaWithSafeParse { + return typeof schema === 'object' && schema !== null && 'safeParse' in schema; +} + describe('content.config', () => { it('exercises infrastructure mocks', () => { const schema = z.object({ test: z.string() }); @@ -29,13 +41,14 @@ describe('content.config', () => { it('validates flags fixture against schema', async () => { const { schema } = collections.flags; - const result = schema.safeParse(flagsFixture); - expect(result.success).toBe(true); + expect(isSchemaWithSafeParse(schema)).toBe(true); + if (isSchemaWithSafeParse(schema)) { + const result = schema.safeParse(flagsFixture); + expect(result.success).toBe(true); - if (result.success) { - // Use toMatchObject to ensure all fixture properties are correctly validated - // while allowing for Zod-injected default values. - expect(result.data).toMatchObject(flagsFixture); + if (result.success && result.data) { + expect(result.data).toMatchObject(flagsFixture); + } } }); @@ -49,11 +62,14 @@ describe('content.config', () => { img: '/assets/sample.jpg', img_alt: 'Sample alt text', }; - const result = schema.safeParse(sampleWork); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.title).toBe(sampleWork.title); - expect(result.data.publishDate).toBeInstanceOf(Date); + expect(isSchemaWithSafeParse(schema)).toBe(true); + if (isSchemaWithSafeParse(schema)) { + const result = schema.safeParse(sampleWork); + expect(result.success).toBe(true); + if (result.success && result.data) { + expect(result.data.title).toBe(sampleWork.title); + expect(result.data.publishDate).toBeInstanceOf(Date); + } } }); }); diff --git a/src/__tests__/releases-api.test.ts b/src/__tests__/releases-api.test.ts new file mode 100644 index 00000000..b52b184d --- /dev/null +++ b/src/__tests__/releases-api.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { env as workerEnv } from 'cloudflare:workers'; +import { GET, type ReleasesEnv } from '../pages/api/releases'; +import * as githubReleases from '../utils/github-releases'; +import { LATEST_RELEASE_SNAPSHOT } from '../data/latest-release'; + +type ReleaseJson = { version: string; body: string }; + +describe('releases API', () => { + beforeEach(() => { + const bindings = workerEnv as ReleasesEnv; + delete bindings.GITHUB_TOKEN; + vi.restoreAllMocks(); + }); + + it('returns visitor-formatted releases fetched from GitHub', async () => { + const sampleRelease: githubReleases.SiteRelease = { + version: '2026.01.01.1200', + title: '2026.01.01.1200', + publishedAt: '2026-01-01T12:00:00Z', + url: 'https://github.com/test/test/releases/tag/v1.0.0', + body: '- feat: add interactive component (#100)\nNon-bullet title line', + }; + vi.spyOn(githubReleases, 'fetchGitHubReleases').mockResolvedValue([sampleRelease]); + + const response = await GET({} as Parameters[0]); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + expect(response.headers.get('Cache-Control')).toBe('public, max-age=60'); + + const data = (await response.json()) as ReleaseJson[]; + expect(data).toHaveLength(1); + expect(data[0]?.version).toBe('2026.01.01.1200'); + expect(data[0]?.body).toContain('- Add interactive component'); + }); + + it('passes GITHUB_TOKEN options when set in environment', async () => { + const bindings = workerEnv as ReleasesEnv; + bindings.GITHUB_TOKEN = 'secret-token'; + + const spy = vi.spyOn(githubReleases, 'fetchGitHubReleases').mockResolvedValue([]); + const response = await GET({} as Parameters[0]); + + expect(response.status).toBe(200); + expect(spy).toHaveBeenCalledWith(expect.any(Function), undefined, { token: 'secret-token' }); + + const data = (await response.json()) as ReleaseJson[]; + expect(data).toHaveLength(1); + expect(data[0]?.version).toBe(LATEST_RELEASE_SNAPSHOT.version); + }); + + it('handles fetch exceptions by logging telemetry and returning snapshot fallback', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(githubReleases, 'fetchGitHubReleases').mockRejectedValue(new Error('Network error')); + + const response = await GET({} as Parameters[0]); + expect(response.status).toBe(200); + expect(consoleSpy).toHaveBeenCalledWith({ + event: 'releases_api_error', + error: 'Error: Network error', + }); + + const data = (await response.json()) as ReleaseJson[]; + expect(data).toHaveLength(1); + expect(data[0]?.version).toBe(LATEST_RELEASE_SNAPSHOT.version); + }); +}); diff --git a/src/components/Chat.astro b/src/components/Chat.astro index 8bacfd57..771a7a65 100644 --- a/src/components/Chat.astro +++ b/src/components/Chat.astro @@ -708,7 +708,7 @@ chatMessages?.querySelectorAll('.chat-followups').forEach((el) => el.remove()); } - function bindChipSubmit(root: ParentNode) { + function bindChipSubmit(root: Element | ParentNode) { root.querySelectorAll('button').forEach((button) => { button.addEventListener('click', () => { if (!(chatForm instanceof HTMLFormElement) || !chatInput) return; @@ -792,7 +792,7 @@ function appendExploreCard(card: ExploreCard) { if (!chatMessages) return; - const article = document.createElement('article'); + const article = document.createElement('article') as unknown as ParentNode & HTMLElement; article.className = 'chat-explore-card'; article.setAttribute('aria-label', card.title); diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 6cb84c99..96c75ef6 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -118,7 +118,7 @@ const showUpcoming = UPCOMING.length > 0; last7d.textContent = ''; first.textContent = ''; visitStats.hidden = false; - document.body.append(panel); + document.body.appendChild(panel); const place = () => { const box = trigger.getBoundingClientRect(); @@ -147,7 +147,7 @@ const showUpcoming = UPCOMING.length > 0; return; } openedByHover = false; - setOpen(panel.hidden); + setOpen(Boolean(panel.hidden)); }); trigger.addEventListener('focus', () => { diff --git a/src/env.d.ts b/src/env.d.ts index 2eeaeb5e..d8acdaba 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,5 +1,64 @@ +/* eslint-disable no-var */ /// +interface ProcessEnv { + [key: string]: string | undefined; +} + +interface Process { + env: ProcessEnv; + argv: string[]; + exit(code?: number): void; +} + +declare var process: Process; + +interface Buffer { + toString(encoding?: string): string; +} + +declare var Buffer: { + from(str: string): Buffer; +}; + +declare module 'fs' { + export function appendFileSync(path: string, data: string): void; + export function existsSync(path: string): boolean; + export function mkdirSync(path: string, options?: unknown): void; + export function mkdtempSync(prefix: string): string; + export function readFileSync(path: string | URL, encoding?: string): string; + export function rmSync(path: string, options?: unknown): void; + export function writeFileSync(path: string, data: string): void; +} + +declare module 'node:fs' { + export function appendFileSync(path: string, data: string): void; + export function existsSync(path: string): boolean; + export function mkdirSync(path: string, options?: unknown): void; + export function mkdtempSync(prefix: string): string; + export function readFileSync(path: string | URL, encoding?: string): string; + export function rmSync(path: string, options?: unknown): void; + export function writeFileSync(path: string, data: string): void; +} + +declare module 'node:url' { + export function fileURLToPath(url: URL | string): string; +} + +declare module 'node:path' { + export function dirname(path: string): string; + export function join(...paths: string[]): string; + export function resolve(...paths: string[]): string; +} + +declare module 'node:os' { + export function tmpdir(): string; +} + +declare module 'child_process' { + export function execSync(command: string): Buffer | string; +} + type Runtime = import('@astrojs/cloudflare').Runtime; declare namespace App { diff --git a/src/utils/hire-analytics.test.ts b/src/utils/hire-analytics.test.ts index 425156bb..63efe3c4 100644 --- a/src/utils/hire-analytics.test.ts +++ b/src/utils/hire-analytics.test.ts @@ -24,8 +24,7 @@ describe('hire-analytics', () => { }); it('does not throw when dataLayer is missing', () => { - // @ts-expect-error -- dataLayer is optional on Window - delete window.dataLayer; + delete (window as { dataLayer?: unknown }).dataLayer; expect(() => trackHireEvent('linkedin_click', 'contact_cta')).not.toThrow(); });