Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
59 changes: 59 additions & 0 deletions packages/docs/playwright.smoke.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
],
});
56 changes: 56 additions & 0 deletions packages/docs/tests-smoke/README.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions packages/docs/tests-smoke/cdn-mirror.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
}
});
}
Loading
Loading