diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7174bdbc7..13433f522 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -187,6 +187,44 @@ jobs: echo "::endgroup::" done + smoke: + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '24.x' + + - uses: actions/cache@v4 + with: + path: | + node_modules + ~/.npm + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-modules- + + - run: yarn + + - uses: actions/download-artifact@v4 + with: + name: build-output + path: packages/ + + # build-output carries the tsc/vite outputs; the rolldown dist/package + # bundles the examples load come from build:bundle. + - run: npx nx run-many -t build:bundle --projects=dockview-core,dockview,dockview-enterprise,dockview-vue,dockview-react,dockview-angular + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Smoke-test the docs live examples + working-directory: packages/docs + run: npm run test:smoke + test: runs-on: ubuntu-latest needs: build diff --git a/packages/docs/package.json b/packages/docs/package.json index 4489856fb..5047ff1f8 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -12,7 +12,8 @@ "typecheck": "tsc", "build-templates": "node scripts/buildTemplates.mjs", "build-templates:local": "node scripts/buildTemplates.mjs --local", - "record-clips": "node scripts/record/recordClips.mjs" + "record-clips": "node scripts/record/recordClips.mjs", + "test:smoke": "npm run build-templates:local && playwright test --config playwright.smoke.config.ts" }, "browserslist": { "production": [ diff --git a/packages/docs/playwright.smoke.config.ts b/packages/docs/playwright.smoke.config.ts new file mode 100644 index 000000000..7896cd8e3 --- /dev/null +++ b/packages/docs/playwright.smoke.config.ts @@ -0,0 +1,59 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Smoke suite for the generated docs live examples. + * + * Loads every generated `static/templates/**\/index.html` in a real browser and + * asserts each example actually renders (visible, non-zero size after ancestor + * clipping) with no module-resolution errors. This is the layer that catches + * the two failure modes unit tests miss: a boilerplate pointing at a build + * output that no longer exists (404s), and a component that mounts but paints + * blank (e.g. a collapsed dock). + * + * Prerequisite: the package `dist/` bundles must exist, since `test:smoke` + * regenerates the templates against them first via `build-templates:local`: + * yarn nx run-many -t build,build:bundle -p dockview-core dockview \ + * dockview-react dockview-vue dockview-angular dockview-enterprise + * + * Two servers back the run, mirroring `npm start`: + * - the ESM package server on :1111 (serves the built dist/ bundles); + * - a static server over `static/` so /templates, /example-runner and /img + * resolve exactly as they do under Docusaurus. + */ + +const STATIC_PORT = 4330; + +export default defineConfig({ + testDir: './tests-smoke', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 4 : undefined, + reporter: 'list', + timeout: 45_000, + use: { + baseURL: `http://localhost:${STATIC_PORT}`, + headless: true, + // Let a CI image supply its own Chromium (opt-in; no effect unset). + launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE + ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE } + : undefined, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: [ + { + // The same ESM package server `npm start` runs. Readiness is gated + // on a built bundle so a missing dist/ fails fast and clearly. + command: 'node web-server/index.mjs', + url: 'http://localhost:1111/dockview-core/dist/package/main.cjs.js', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, + { + command: `python3 -m http.server ${STATIC_PORT} --directory static`, + url: `http://localhost:${STATIC_PORT}/example-runner/dockview-vue-boilerplate/systemjs.config.js`, + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, + ], +}); diff --git a/packages/docs/tests-smoke/README.md b/packages/docs/tests-smoke/README.md new file mode 100644 index 000000000..b23560090 --- /dev/null +++ b/packages/docs/tests-smoke/README.md @@ -0,0 +1,56 @@ +# Docs live-example smoke tests + +Loads every generated live example (`static/templates/**/index.html`) in a real +headless browser, across all four frameworks (React, TypeScript, Vue, Angular), +and asserts each one actually renders. + +## Why + +Two docs regressions slipped through because nothing exercised the generated +examples end to end: + +1. A SystemJS boilerplate pointed at a build output the packages no longer ship, + so every example 404'd its dockview import at runtime. +2. A `dockview-vue` change made the component render two root nodes, so the theme + and layout class no longer reached the dock and it painted blank. + +Neither is caught by unit tests, and neither is caught by a plain "did the root +element mount" check: in case 2 the root is in the DOM with a non-zero bounding +box, but clipped to zero height by a collapsed parent. So the suite asserts the +signals that actually distinguish a working example: + +- the component root mounts, and +- its **visible size after ancestor clipping** is non-zero (catches blank / + collapsed renders), and +- there are **no module-resolution errors** — a `>= 400` on a package/app module, + or `is not a function` / `Cannot find module` / `is not defined` / + `failed to fetch dynamically imported` in the console (catches stale mappings). + +## Run + +```sh +# build the package bundles the examples load (once) +yarn nx run-many -t build,build:bundle \ + -p dockview-core dockview dockview-react dockview-vue dockview-angular dockview-enterprise + +# from packages/docs — regenerates templates against the local bundles, then runs +npm run test:smoke +``` + +`test:smoke` starts two servers itself (the ESM package server on :1111 and a +static server over `static/`), so no separate `npm start` is needed. + +The external framework libraries (SystemJS, TypeScript, React, Vue, Angular) load +from jsdelivr. They are fetched through Node with an on-disk cache +(`cdn-mirror.ts`), so each asset is downloaded once per run and the suite also +works behind an HTTPS proxy where the browser cannot reach the CDN directly. Set +`DOCKVIEW_SMOKE_CDN_CACHE` to pin the cache directory. + +## Known-skipped examples + +A few examples are skipped with a reason (see `KNOWN_ISSUES` in `smoke.spec.ts`): +pre-existing example-source issues (a namespace React import that breaks the UMD +interop; an Angular `NG0202` decorator-metadata loss in the in-browser +transpiler) that are unrelated to what this suite guards and are tracked +separately. The `demo-dockview` template is the special `/demo` example and is +covered elsewhere. diff --git a/packages/docs/tests-smoke/cdn-mirror.ts b/packages/docs/tests-smoke/cdn-mirror.ts new file mode 100644 index 000000000..cbb8af723 --- /dev/null +++ b/packages/docs/tests-smoke/cdn-mirror.ts @@ -0,0 +1,81 @@ +import type { BrowserContext } from '@playwright/test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import crypto from 'crypto'; + +const CACHE_DIR = + process.env.DOCKVIEW_SMOKE_CDN_CACHE || + path.join(os.tmpdir(), 'dockview-smoke-cdn-cache'); + +function cachePathFor(url: string): string { + const hash = crypto.createHash('sha1').update(url).digest('hex'); + const ext = path.extname(new URL(url).pathname) || '.bin'; + return path.join(CACHE_DIR, hash + ext); +} + +async function fetchThroughNode(url: string): Promise<{ + body: Buffer; + status: number; + contentType: string; +}> { + const file = cachePathFor(url); + const meta = `${file}.meta`; + if (fs.existsSync(file) && fs.existsSync(meta)) { + return { + body: fs.readFileSync(file), + ...(JSON.parse(fs.readFileSync(meta, 'utf8')) as { + status: number; + contentType: string; + }), + }; + } + const res = await fetch(url, { redirect: 'follow' }); + const body = Buffer.from(await res.arrayBuffer()); + const contentType = + res.headers.get('content-type') || 'application/octet-stream'; + fs.mkdirSync(CACHE_DIR, { recursive: true }); + // Write atomically so parallel workers never read a half-written file. + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, body); + fs.renameSync(tmp, file); + fs.writeFileSync(meta, JSON.stringify({ status: res.status, contentType })); + return { body, status: res.status, contentType }; +} + +/** + * Serve the external CDN dependencies (SystemJS, TypeScript, React, Vue, + * Angular) through Node's `fetch` with an on-disk cache instead of letting each + * page hit the network directly. Two reasons: + * - every unique asset is fetched once per run, not once per example page, so + * a full sweep stays fast and does not hammer the CDN; + * - it works behind an HTTPS proxy or on a restricted network where the + * browser cannot reach the CDN but Node (which honours HTTPS_PROXY) can. + * + * Requests to our own localhost servers pass straight through. If Node cannot + * reach the CDN either, the request falls back to the browser's own network so + * behaviour on an unrestricted machine is unchanged. + */ +export async function installCdnMirror(context: BrowserContext): Promise { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + await context.route('**/*', async (route) => { + const url = route.request().url(); + if (url.includes('localhost') || url.includes('127.0.0.1')) { + return route.continue(); + } + // Google Fonts is cosmetic and not always reachable; stub it out. + if (/fonts\.googleapis\.com|fonts\.gstatic\.com/.test(url)) { + return route.fulfill({ + status: 200, + contentType: 'text/css', + body: '', + }); + } + try { + const { body, status, contentType } = await fetchThroughNode(url); + await route.fulfill({ status, contentType, body }); + } catch { + await route.continue(); + } + }); +} diff --git a/packages/docs/tests-smoke/smoke.spec.ts b/packages/docs/tests-smoke/smoke.spec.ts new file mode 100644 index 000000000..34c64755b --- /dev/null +++ b/packages/docs/tests-smoke/smoke.spec.ts @@ -0,0 +1,172 @@ +import { test, expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { installCdnMirror } from './cdn-mirror'; + +const TEMPLATES_ROOT = path.join(__dirname, '..', 'static', 'templates'); + +// The mounted root element for each component kind. +const ROOT_SELECTOR: Record = { + dockview: '.dv-dockview', + splitview: '.dv-split-view-container', + gridview: '.dv-grid-view', + paneview: '.dv-pane-container', +}; + +const FRAMEWORKS = ['react', 'typescript', 'vue', 'angular']; + +// Pre-existing example-source issues, unrelated to module resolution or the +// blank-render class this suite guards. Skipped with a reason so the run stays +// honest instead of silently green. Each is tracked as its own follow-up. +const KNOWN_ISSUES: Record = { + 'dockview/full-width-tab/react': + 'example uses `import * as React`; the React UMD interop under SystemJS drops createElement', + 'dockview/iframe/react': + 'example uses `import * as React`; same interop as full-width-tab', + 'dockview/layout-history/angular': + 'Angular NG0202: decorator metadata is dropped by the in-browser TypeScript transpiler', +}; + +// The two failure classes we assert on. Framework dev-mode chatter (e.g. Vue's +// production-build hint) is deliberately not one of them. +const RESOLUTION_ERROR = [ + /is not a function/i, + /cannot find module/i, + /is not defined/i, + /failed to fetch dynamically imported/i, + /unexpected token/i, +]; + +type Example = { + component: string; + id: string; + framework: string; + key: string; + urlPath: string; +}; + +function discover(): Example[] { + const out: Example[] = []; + for (const component of Object.keys(ROOT_SELECTOR)) { + const dir = path.join(TEMPLATES_ROOT, component); + if (!fs.existsSync(dir)) { + continue; + } + for (const id of fs.readdirSync(dir)) { + // demo-dockview is the special react+angular /demo example, not a + // standard CodeRunner template; it is exercised elsewhere. + if (id === 'demo-dockview') { + continue; + } + for (const framework of FRAMEWORKS) { + const index = path.join(dir, id, framework, 'index.html'); + if (!fs.existsSync(index)) { + continue; + } + out.push({ + component, + id, + framework, + key: `${component}/${id}/${framework}`, + urlPath: `/templates/${component}/${id}/${framework}/index.html`, + }); + } + } + } + return out.sort((a, b) => a.key.localeCompare(b.key)); +} + +const examples = discover(); + +if (examples.length === 0) { + throw new Error( + 'No generated templates found under static/templates. Run `npm run build-templates:local` first.' + ); +} + +test.beforeEach(async ({ context }) => { + await installCdnMirror(context); +}); + +test.describe('docs live examples render', () => { + for (const ex of examples) { + test(ex.key, async ({ page }) => { + const issue = KNOWN_ISSUES[ex.key]; + test.skip(!!issue, issue); + + const resolutionErrors: string[] = []; + const flag = (text: string) => { + if (RESOLUTION_ERROR.some((re) => re.test(text))) { + resolutionErrors.push(text); + } + }; + page.on('console', (m) => { + if (m.type() === 'error') flag(m.text()); + }); + page.on('pageerror', (e) => flag(e.message)); + page.on('response', (r) => { + if (r.status() >= 400) { + const u = r.url(); + // Ignore cosmetic 404s (fonts / favicons / brand svg); flag + // package or app module 404s, the SystemJS-staleness class. + if (!/googleapis|gstatic|favicon|\.svg(\?|$)/i.test(u)) { + resolutionErrors.push(`HTTP ${r.status()} ${u}`); + } + } + }); + + await page.goto(ex.urlPath, { waitUntil: 'load' }); + + const selector = ROOT_SELECTOR[ex.component]; + // (1) the component mounts into the DOM + await page + .locator(selector) + .first() + .waitFor({ state: 'attached', timeout: 15_000 }); + + // (2) and paints with real size once ancestor clipping is applied. + // A collapsed/blank example (e.g. the Vue fallthrough bug) leaves + // the root in the DOM but clipped to zero height by a parent, which + // a plain `toBeVisible` / bounding-box check does not catch. + const visible = await page.evaluate((sel) => { + const el = document.querySelector(sel); + if (!el) { + return { w: 0, h: 0 }; + } + const r = el.getBoundingClientRect(); + let x1 = r.left; + let y1 = r.top; + let x2 = r.right; + let y2 = r.bottom; + let p = el.parentElement; + while (p) { + const s = getComputedStyle(p); + if ( + s.overflowX !== 'visible' || + s.overflowY !== 'visible' + ) { + const pr = p.getBoundingClientRect(); + x1 = Math.max(x1, pr.left); + y1 = Math.max(y1, pr.top); + x2 = Math.min(x2, pr.right); + y2 = Math.min(y2, pr.bottom); + } + p = p.parentElement; + } + return { w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1) }; + }, selector); + + expect( + visible.w > 10 && visible.h > 10, + `${ex.key} rendered but is not visible (clipped size ${visible.w}x${visible.h})` + ).toBe(true); + + // (3) no module-resolution failures — a boilerplate pointing at a + // path the build no longer produces, or an unresolved import. + expect( + resolutionErrors, + `${ex.key} module-resolution errors:\n${resolutionErrors.join('\n')}` + ).toEqual([]); + }); + } +});