Skip to content

Commit dd281bf

Browse files
authored
Merge pull request #436 from objectstack-ai/copilot/add-e2e-testing-for-console
2 parents 6e9a947 + 5d5fa4a commit dd281bf

8 files changed

Lines changed: 255 additions & 38 deletions

File tree

.github/workflows/ci.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,51 @@ jobs:
114114
fi
115115
echo "All packages built successfully"
116116
117+
e2e:
118+
name: E2E Tests
119+
runs-on: ubuntu-latest
120+
needs: build
121+
permissions:
122+
contents: read
123+
124+
steps:
125+
- name: Checkout code
126+
uses: actions/checkout@v6
127+
128+
- name: Setup pnpm
129+
uses: pnpm/action-setup@v4
130+
131+
- name: Setup Node.js
132+
uses: actions/setup-node@v6
133+
with:
134+
node-version: '20.x'
135+
cache: 'pnpm'
136+
137+
- name: Turbo Cache
138+
uses: actions/cache@v5
139+
with:
140+
path: node_modules/.cache/turbo
141+
key: turbo-${{ runner.os }}-${{ github.sha }}
142+
restore-keys: |
143+
turbo-${{ runner.os }}-
144+
145+
- name: Install dependencies
146+
run: pnpm install --frozen-lockfile
147+
148+
- name: Install Playwright browsers
149+
run: pnpm exec playwright install --with-deps chromium
150+
151+
- name: Run E2E tests
152+
run: pnpm test:e2e --project=chromium
153+
154+
- name: Upload Playwright report
155+
uses: actions/upload-artifact@v4
156+
if: ${{ !cancelled() }}
157+
with:
158+
name: playwright-report
159+
path: playwright-report/
160+
retention-days: 14
161+
117162
docs:
118163
name: Build Docs
119164
runs-on: ubuntu-latest

apps/console/vite.config.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { defineConfig } from 'vite';
22
import react from '@vitejs/plugin-react';
33
import path from 'path';
4+
import { viteCryptoStub } from '../../scripts/vite-crypto-stub';
45

56
// https://vitejs.dev/config/
67
export default defineConfig({
@@ -11,7 +12,10 @@ export default defineConfig({
1112
'process.version': '"0.0.0"',
1213
},
1314

14-
plugins: [react()],
15+
plugins: [
16+
viteCryptoStub(),
17+
react(),
18+
],
1519
resolve: {
1620
extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'],
1721
alias: {
@@ -73,14 +77,6 @@ export default defineConfig({
7377
transformMixedEsModules: true
7478
},
7579
rollupOptions: {
76-
// @objectstack/core@2.0.4 statically imports Node.js crypto (for plugin hashing).
77-
// The code already has a browser fallback, so we treat it as external in the browser build.
78-
external: ['crypto'],
79-
output: {
80-
globals: {
81-
crypto: '{}',
82-
},
83-
},
8480
onwarn(warning, warn) {
8581
if (
8682
warning.code === 'UNRESOLVED_IMPORT' &&

e2e/console-rendering.spec.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { test, expect } from '@playwright/test';
2+
import { waitForReactMount } from './helpers';
3+
4+
/**
5+
* Console rendering & navigation E2E tests.
6+
*
7+
* These tests validate that the production build renders correctly and
8+
* that client-side routing works — the two main failure modes that cause
9+
* the "blank page on Vercel" issue.
10+
*/
11+
12+
test.describe('Console Rendering', () => {
13+
test('should not have critical console errors during bootstrap', async ({ page }) => {
14+
const criticalErrors: string[] = [];
15+
16+
// Capture console.error calls that indicate fatal issues
17+
page.on('console', (msg) => {
18+
if (msg.type() === 'error') {
19+
const text = msg.text();
20+
// Ignore benign errors (e.g. favicon 404, service-worker registration)
21+
if (
22+
text.includes('favicon') ||
23+
text.includes('service-worker') ||
24+
text.includes('mockServiceWorker')
25+
) {
26+
return;
27+
}
28+
criticalErrors.push(text);
29+
}
30+
});
31+
32+
await page.goto('/');
33+
await waitForReactMount(page);
34+
35+
expect(
36+
criticalErrors,
37+
`Critical console errors detected:\n${criticalErrors.join('\n')}`,
38+
).toEqual([]);
39+
});
40+
41+
test('should resolve client-side routes without blank content', async ({ page }) => {
42+
await page.goto('/');
43+
await waitForReactMount(page);
44+
45+
// After routing, the page should have meaningful DOM content
46+
const rootHTML = await page.locator('#root').innerHTML();
47+
expect(rootHTML.length, 'React root innerHTML is empty').toBeGreaterThan(50);
48+
});
49+
50+
test('should serve index.html for SPA fallback routes', async ({ page }) => {
51+
// Vercel blank-page issues often stem from missing SPA rewrites.
52+
// Navigate to a deep route — the server must return index.html (not 404).
53+
const response = await page.goto('/apps/default/some-object');
54+
expect(response?.status(), 'Deep route returned non-200 status').toBeLessThan(400);
55+
56+
await waitForReactMount(page);
57+
58+
// React should still mount
59+
const root = page.locator('#root');
60+
const childCount = await root.evaluate((el) => el.children.length);
61+
expect(childCount, 'React did not mount on deep route').toBeGreaterThan(0);
62+
});
63+
64+
test('should include the MSW service worker in the build output', async ({ page }) => {
65+
// The mock server requires mockServiceWorker.js to be served from /public.
66+
// If it's missing, the app may hang during bootstrap.
67+
const response = await page.request.get('/mockServiceWorker.js');
68+
69+
// In production builds without MSW, 404 is acceptable.
70+
// But if the build includes it, it must be valid JS.
71+
if (response.ok()) {
72+
const contentType = response.headers()['content-type'] || '';
73+
expect(contentType).toContain('javascript');
74+
}
75+
});
76+
});

e2e/helpers/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Page } from '@playwright/test';
2+
3+
/** Wait for React to mount (at least one child inside #root). */
4+
export async function waitForReactMount(page: Page) {
5+
await page.waitForFunction(
6+
() => (document.getElementById('root')?.children.length ?? 0) > 0,
7+
{ timeout: 30_000 },
8+
);
9+
}

e2e/smoke.spec.ts

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,84 @@
11
import { test, expect } from '@playwright/test';
2+
import { waitForReactMount } from './helpers';
23

34
/**
4-
* Smoke test to verify the console app loads correctly.
5-
* This is a foundational E2E test that validates the basic app shell.
5+
* Smoke tests for the console production build.
6+
*
7+
* These tests run against `vite preview` (the same artefact deployed to Vercel)
8+
* and are designed to catch **blank-page** regressions caused by:
9+
* - Broken imports or missing modules in the production bundle
10+
* - Missing polyfills (e.g. `process`, `crypto`)
11+
* - Uncaught JavaScript exceptions during bootstrap
12+
* - Failed network requests for critical assets (JS/CSS bundles)
13+
* - React failing to mount into #root
614
*/
7-
test.describe('Console App', () => {
8-
test('should load the home page', async ({ page }) => {
15+
16+
test.describe('Console App – Smoke', () => {
17+
test('should load the page without JavaScript errors', async ({ page }) => {
18+
const errors: string[] = [];
19+
page.on('pageerror', (err) => errors.push(err.message));
20+
921
await page.goto('/');
10-
// Wait for the app to render
11-
await page.waitForLoadState('networkidle');
12-
// The page should have rendered something (not blank)
13-
const body = page.locator('body');
14-
await expect(body).not.toBeEmpty();
22+
await waitForReactMount(page);
23+
24+
// The page must not have thrown any uncaught exceptions
25+
expect(errors, 'Uncaught JS errors during page load').toEqual([]);
26+
});
27+
28+
test('should render React content inside #root', async ({ page }) => {
29+
await page.goto('/');
30+
await waitForReactMount(page);
31+
32+
// #root must exist and have child elements (React mounted successfully)
33+
const root = page.locator('#root');
34+
await expect(root).toBeAttached();
35+
const childCount = await root.evaluate((el) => el.children.length);
36+
expect(childCount, '#root has no children – blank page detected').toBeGreaterThan(0);
37+
});
38+
39+
test('should not show a blank page (meaningful text rendered)', async ({ page }) => {
40+
await page.goto('/');
41+
await waitForReactMount(page);
42+
43+
// The visible page text must not be empty
44+
const bodyText = await page.locator('body').innerText();
45+
expect(bodyText.trim().length, 'Page body has no visible text').toBeGreaterThan(0);
1546
});
1647

17-
test('should display the navigation sidebar', async ({ page }) => {
48+
test('should load all JavaScript bundles without 404s', async ({ page }) => {
49+
const failedAssets: string[] = [];
50+
51+
page.on('response', (response) => {
52+
const url = response.url();
53+
if (
54+
(url.endsWith('.js') || url.endsWith('.css')) &&
55+
response.status() >= 400
56+
) {
57+
failedAssets.push(`${response.status()} ${url}`);
58+
}
59+
});
60+
1861
await page.goto('/');
1962
await page.waitForLoadState('networkidle');
20-
// The app shell should contain a navigation area
21-
const nav = page.locator('nav').first();
22-
await expect(nav).toBeVisible();
63+
64+
expect(failedAssets, 'Critical assets returned HTTP errors').toEqual([]);
2365
});
2466

2567
test('should have correct page title', async ({ page }) => {
2668
await page.goto('/');
27-
await expect(page).toHaveTitle(/.+/);
69+
await expect(page).toHaveTitle(/ObjectStack|ObjectUI|Console/i);
70+
});
71+
72+
test('should show the app shell or loading screen', async ({ page }) => {
73+
await page.goto('/');
74+
75+
// Either the app shell (nav / sidebar) or the loading screen should appear
76+
// within a reasonable time. Both are acceptable initial states.
77+
const appShell = page.locator('nav').first();
78+
const loadingScreen = page.getByText(/Initializing|Loading|Connecting/i).first();
79+
80+
await expect(
81+
appShell.or(loadingScreen),
82+
).toBeVisible({ timeout: 30_000 });
2883
});
2984
});

examples/msw-todo/vite.config.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { defineConfig } from 'vite';
22
import react from '@vitejs/plugin-react';
3+
import { viteCryptoStub } from '../../scripts/vite-crypto-stub';
34

45
// https://vitejs.dev/config/
56
export default defineConfig({
6-
plugins: [react()],
7+
plugins: [
8+
viteCryptoStub(),
9+
react(),
10+
],
711
server: {
812
port: 3000,
913
},
@@ -36,14 +40,6 @@ export default defineConfig({
3640
}
3741
warn(warning);
3842
},
39-
// @objectstack/core@2.0.4 statically imports Node.js crypto (for plugin hashing).
40-
// The code already has a browser fallback, so we treat it as external in the browser build.
41-
external: ['crypto'],
42-
output: {
43-
globals: {
44-
crypto: '{}',
45-
},
46-
},
4743
}
4844
}
4945
});

playwright.config.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { defineConfig, devices } from '@playwright/test';
22

33
/**
44
* Playwright E2E test configuration for Object UI
5+
*
6+
* Tests run against the **production build** of the console app so that
7+
* deployment-time issues (blank pages, broken imports, missing polyfills)
8+
* are caught before they reach Vercel / other hosting platforms.
9+
*
510
* @see https://playwright.dev/docs/test-configuration
611
*/
712
export default defineConfig({
@@ -18,8 +23,8 @@ export default defineConfig({
1823
reporter: process.env.CI ? 'github' : 'html',
1924
/* Shared settings for all projects */
2025
use: {
21-
/* Base URL to use in actions like `await page.goto('/')` */
22-
baseURL: 'http://localhost:5173',
26+
/* Base URL – vite preview defaults to port 4173 */
27+
baseURL: 'http://localhost:4173',
2328
/* Collect trace when retrying the failed test */
2429
trace: 'on-first-retry',
2530
/* Screenshot on failure */
@@ -51,11 +56,15 @@ export default defineConfig({
5156
},
5257
],
5358

54-
/* Run your local dev server before starting the tests */
59+
/**
60+
* Build the console app and serve the production bundle via `vite preview`.
61+
* This mirrors the Vercel deployment pipeline and catches blank-page issues
62+
* caused by build-time errors (broken imports, missing polyfills, etc.).
63+
*/
5564
webServer: {
56-
command: 'pnpm run dev:console',
57-
url: 'http://localhost:5173',
65+
command: 'pnpm --filter @object-ui/console build && pnpm --filter @object-ui/console preview --port 4173',
66+
url: 'http://localhost:4173',
5867
reuseExistingServer: !process.env.CI,
59-
timeout: 120 * 1000,
68+
timeout: 180 * 1000,
6069
},
6170
});

scripts/vite-crypto-stub.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { Plugin } from 'vite';
2+
3+
/**
4+
* Vite plugin that stubs the Node.js `crypto` module for browser builds.
5+
*
6+
* @objectstack/core statically imports `createHash` from `crypto` for plugin
7+
* hashing. The code already has a browser fallback, so we provide a no-op stub
8+
* instead of marking it as external (which emits a bare `import 'crypto'` that
9+
* browsers reject, causing a blank page).
10+
*
11+
* Must use `enforce: 'pre'` so it runs before Vite's built-in
12+
* browser-external resolve.
13+
*/
14+
export function viteCryptoStub(): Plugin {
15+
return {
16+
name: 'stub-crypto',
17+
enforce: 'pre',
18+
resolveId(id: string) {
19+
if (id === 'crypto') return '\0crypto-stub';
20+
},
21+
load(id: string) {
22+
if (id === '\0crypto-stub') {
23+
return [
24+
'export function createHash() { return { update() { return this; }, digest() { return ""; } }; }',
25+
'export function createVerify() { return { update() { return this; }, end() {}, verify() { return false; } }; }',
26+
'export default {};',
27+
].join('\n');
28+
}
29+
},
30+
};
31+
}

0 commit comments

Comments
 (0)