diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44f2d99..bc7359d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,8 @@ jobs: - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' + # A stalled download otherwise holds the job until its 40-minute timeout (seen on PR #15). + timeout-minutes: 10 run: npx playwright install --with-deps chromium firefox webkit - name: Install Playwright system dependencies diff --git a/CLAUDE.md b/CLAUDE.md index 3c3b280..0cfc41d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,7 @@ Full doc: `docs/architecture/grid-row-meta.md` - Anything that depends on layout, `ResizeObserver`, or scrolling (virtualization, container height, drag) must be a `*.browser.test.tsx` — jsdom has no layout engine and reports a 0px viewport (the grid falls back to 600px), so such bugs are invisible there - Browser tests run in Chromium, Firefox and WebKit: `npx playwright install chromium firefox webkit` once. One engine only: `npx vitest run --project browser --browser.name=firefox` - Browser test files run one at a time (`fileParallelism: false`): several test pages open at once in one Firefox steal focus and pointer input from each other +- `lib/publicApi.test.ts` fails when a type declared in `lib/types/*` is not exported from `lib/index.ts` (`GridAi*` / `GridAgent*` from `/ai`). A type that is internal on purpose goes in `INTERNAL_TYPES` with a comment - **Vitest vs Playwright Test:** render `` with props and assert → Vitest (`*.test.tsx`, or `*.browser.test.tsx` for layout). Needs the built package, a real app, a dev/preview server or a file download → the e2e package smoke suite (`e2e/`, `npm run test:smoke`). Vitest aliases the package to `lib/`, so only the smoke suite catches packaging bugs (bundled `react/jsx-runtime`, missing CSS in `dist/`, `.d.ts` that fails `skipLibCheck: false`) - Smoke suite: `scripts/smoke.mjs` builds + packs to `.pack/`, `npm ci`s each fixture in `e2e/fixtures/{react19,react18,nopeers,nextjs}` and unpacks the tarball there, builds them (strict tsc + vite), then runs `e2e/smoke.spec.ts` in Chromium/Firefox/WebKit × both fixtures against `vite preview`. Scenarios via `?scenario=` (`basic`, `flex`, `grouping`, `editing`; React 19 also `pinned`, `dark`). Any console error/warning fails a test. `nopeers` is a webpack 5 app without `exceljs`/`jspdf` (`nopeers.spec.ts`, port 4321): every optional-peer `import()` must stay inside a `try` block, which webpack and Turbopack treat as optional (Vite tolerates a missing peer anyway, so the Vite fixtures cannot catch this). `nextjs` is a Next.js 16 App Router app (Turbopack, `next build` + `next start`, port 4323, `e2e/nextjs.spec.ts`): Server Component → `'use client'` grid, stylesheet in the layout, no optional peers, no aliases; its `next.config.ts` only pins `turbopack.root` to the fixture so the repo's own `exceljs` is not found. A specifier hidden in a variable (`import(name)`) builds but breaks apps that do install the peers: browsers cannot resolve bare specifiers, and Vite's lib build strips `webpackIgnore` / `@vite-ignore` comments. Fixtures are outside root lint/typecheck/vitest; their lockfiles must not carry `integrity` for the tarball entry. Guide: `docs/contributing/testing.md` - Keep browser tests engine-neutral: no CDP (`cdp()` is Chromium-only; use a Playwright-backed command such as `commands.setColorScheme` from `vitest.config.ts`), Tab-order anchors are text inputs (WebKit on macOS skips buttons on Tab), compare scroll positions with what the browser actually took (Firefox snaps them to device pixels), and wait for CSS animations to finish before measuring. Skip an engine only with `it.skipIf(server.browser === '...')` (`server` from `vitest/browser`) and a comment saying why diff --git a/lib/publicApi.test.ts b/lib/publicApi.test.ts index 2e51646..23b494e 100644 --- a/lib/publicApi.test.ts +++ b/lib/publicApi.test.ts @@ -146,6 +146,25 @@ function readEntryExports(program: ts.Program, file: string = LIB_INDEX): Set { + const found = new Map(); + for (const file of fs.readdirSync(TYPES_DIR)) { + if (!file.endsWith('.ts') || file === 'index.ts' || file.includes('.test.')) continue; + for (const name of readEntryExports(program, path.join(TYPES_DIR, file))) found.set(name, `lib/types/${file}`); + } + return found; +} + const readDoc = (file: string) => fs.readFileSync(file, 'utf8'); const componentDocs = fs.readdirSync(COMPONENT_DOCS_DIR) .filter((f) => f.endsWith('.md')) @@ -224,6 +243,17 @@ describe('public API matches the reference docs', () => { expect(missing).toEqual([]); }); + it('exports every type declared in lib/types from its entry point, unless it is internal', () => { + const declared = declaredTypeNames(program); + expect(declared.size).toBeGreaterThan(50); + const missing = [...declared] + .filter(([name]) => !INTERNAL_TYPES.has(name)) + .filter(([name]) => !(AI_TYPE_NAME.test(name) ? aiExports : rootExports).has(name)) + .map(([name, file]) => `${name} (${file})`); + expect(missing).toEqual([]); + expect([...INTERNAL_TYPES].filter((name) => !declared.has(name))).toEqual([]); + }); + it('exports every name the docs import from the ai entry point, and the root does not', () => { const imports = documentedImports('/ai'); expect(imports.size).toBeGreaterThan(1);