Skip to content

Commit 85c0d2c

Browse files
Copilothotlong
andcommitted
fix: resolve blank page on Vercel + add comprehensive Playwright e2e tests
- Fix crypto module externalization causing blank page in production build (replaced `external: ['crypto']` with inline Vite plugin that stubs `createHash`/`createVerify` before Vite's browser-external resolve) - Update playwright.config.ts to test production build via `vite preview` - Enhance e2e/smoke.spec.ts with blank-page detection tests - Add e2e/console-rendering.spec.ts for rendering & navigation validation - Add e2e job to CI workflow (.github/workflows/ci.yml) - Apply same crypto fix to examples/msw-todo/vite.config.ts Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 9cd64cc commit 85c0d2c

6 files changed

Lines changed: 263 additions & 38 deletions

File tree

.github/workflows/ci.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,49 @@ 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+
122+
steps:
123+
- name: Checkout code
124+
uses: actions/checkout@v6
125+
126+
- name: Setup pnpm
127+
uses: pnpm/action-setup@v4
128+
129+
- name: Setup Node.js
130+
uses: actions/setup-node@v6
131+
with:
132+
node-version: '20.x'
133+
cache: 'pnpm'
134+
135+
- name: Turbo Cache
136+
uses: actions/cache@v5
137+
with:
138+
path: node_modules/.cache/turbo
139+
key: turbo-${{ runner.os }}-${{ github.sha }}
140+
restore-keys: |
141+
turbo-${{ runner.os }}-
142+
143+
- name: Install dependencies
144+
run: pnpm install --frozen-lockfile
145+
146+
- name: Install Playwright browsers
147+
run: pnpm exec playwright install --with-deps chromium
148+
149+
- name: Run E2E tests
150+
run: pnpm test:e2e --project=chromium
151+
152+
- name: Upload Playwright report
153+
uses: actions/upload-artifact@v4
154+
if: ${{ !cancelled() }}
155+
with:
156+
name: playwright-report
157+
path: playwright-report/
158+
retention-days: 14
159+
117160
docs:
118161
name: Build Docs
119162
runs-on: ubuntu-latest

apps/console/vite.config.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,29 @@ export default defineConfig({
1111
'process.version': '"0.0.0"',
1212
},
1313

14-
plugins: [react()],
14+
// @objectstack/core@2.0.4 statically imports Node.js crypto (for plugin hashing).
15+
// The code already has a browser fallback, so we provide an empty stub instead of
16+
// marking it as external (which emits a bare `import 'crypto'` that browsers reject).
17+
// enforce: 'pre' ensures this runs before Vite's built-in browser-external resolve.
18+
plugins: [
19+
{
20+
name: 'stub-crypto',
21+
enforce: 'pre',
22+
resolveId(id: string) {
23+
if (id === 'crypto') return '\0crypto-stub';
24+
},
25+
load(id: string) {
26+
if (id === '\0crypto-stub') {
27+
return [
28+
'export function createHash() { return { update() { return this; }, digest() { return ""; } }; }',
29+
'export function createVerify() { return { update() { return this; }, end() {}, verify() { return false; } }; }',
30+
'export default {};',
31+
].join('\n');
32+
}
33+
},
34+
},
35+
react(),
36+
],
1537
resolve: {
1638
extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'],
1739
alias: {
@@ -73,14 +95,6 @@ export default defineConfig({
7395
transformMixedEsModules: true
7496
},
7597
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-
},
8498
onwarn(warning, warn) {
8599
if (
86100
warning.code === 'UNRESOLVED_IMPORT' &&

e2e/console-rendering.spec.ts

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

e2e/smoke.spec.ts

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,91 @@
11
import { test, expect } from '@playwright/test';
22

33
/**
4-
* Smoke test to verify the console app loads correctly.
5-
* This is a foundational E2E test that validates the basic app shell.
4+
* Smoke tests for the console production build.
5+
*
6+
* These tests run against `vite preview` (the same artefact deployed to Vercel)
7+
* and are designed to catch **blank-page** regressions caused by:
8+
* - Broken imports or missing modules in the production bundle
9+
* - Missing polyfills (e.g. `process`, `crypto`)
10+
* - Uncaught JavaScript exceptions during bootstrap
11+
* - Failed network requests for critical assets (JS/CSS bundles)
12+
* - React failing to mount into #root
613
*/
7-
test.describe('Console App', () => {
8-
test('should load the home page', async ({ page }) => {
14+
15+
/** Wait for React to mount (at least one child inside #root). */
16+
async function waitForReactMount(page: import('@playwright/test').Page) {
17+
await page.waitForFunction(
18+
() => (document.getElementById('root')?.children.length ?? 0) > 0,
19+
{ timeout: 30_000 },
20+
);
21+
}
22+
23+
test.describe('Console App – Smoke', () => {
24+
test('should load the page without JavaScript errors', async ({ page }) => {
25+
const errors: string[] = [];
26+
page.on('pageerror', (err) => errors.push(err.message));
27+
928
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();
29+
await waitForReactMount(page);
30+
31+
// The page must not have thrown any uncaught exceptions
32+
expect(errors, 'Uncaught JS errors during page load').toEqual([]);
1533
});
1634

17-
test('should display the navigation sidebar', async ({ page }) => {
35+
test('should render React content inside #root', async ({ page }) => {
36+
await page.goto('/');
37+
await waitForReactMount(page);
38+
39+
// #root must exist and have child elements (React mounted successfully)
40+
const root = page.locator('#root');
41+
await expect(root).toBeAttached();
42+
const childCount = await root.evaluate((el) => el.children.length);
43+
expect(childCount, '#root has no children – blank page detected').toBeGreaterThan(0);
44+
});
45+
46+
test('should not show a blank page (meaningful text rendered)', async ({ page }) => {
47+
await page.goto('/');
48+
await waitForReactMount(page);
49+
50+
// The visible page text must not be empty
51+
const bodyText = await page.locator('body').innerText();
52+
expect(bodyText.trim().length, 'Page body has no visible text').toBeGreaterThan(0);
53+
});
54+
55+
test('should load all JavaScript bundles without 404s', async ({ page }) => {
56+
const failedAssets: string[] = [];
57+
58+
page.on('response', (response) => {
59+
const url = response.url();
60+
if (
61+
(url.endsWith('.js') || url.endsWith('.css')) &&
62+
response.status() >= 400
63+
) {
64+
failedAssets.push(`${response.status()} ${url}`);
65+
}
66+
});
67+
1868
await page.goto('/');
1969
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();
70+
71+
expect(failedAssets, 'Critical assets returned HTTP errors').toEqual([]);
2372
});
2473

2574
test('should have correct page title', async ({ page }) => {
2675
await page.goto('/');
27-
await expect(page).toHaveTitle(/.+/);
76+
await expect(page).toHaveTitle(/ObjectStack|ObjectUI|Console/i);
77+
});
78+
79+
test('should show the app shell or loading screen', async ({ page }) => {
80+
await page.goto('/');
81+
82+
// Either the app shell (nav / sidebar) or the loading screen should appear
83+
// within a reasonable time. Both are acceptable initial states.
84+
const appShell = page.locator('nav').first();
85+
const loadingScreen = page.getByText(/Initializing|Loading|Connecting/i).first();
86+
87+
await expect(
88+
appShell.or(loadingScreen),
89+
).toBeVisible({ timeout: 30_000 });
2890
});
2991
});

examples/msw-todo/vite.config.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,29 @@ import react from '@vitejs/plugin-react';
33

44
// https://vitejs.dev/config/
55
export default defineConfig({
6-
plugins: [react()],
6+
// @objectstack/core@2.0.4 statically imports Node.js crypto (for plugin hashing).
7+
// The code already has a browser fallback, so we provide an empty stub instead of
8+
// marking it as external (which emits a bare `import 'crypto'` that browsers reject).
9+
// enforce: 'pre' ensures this runs before Vite's built-in browser-external resolve.
10+
plugins: [
11+
{
12+
name: 'stub-crypto',
13+
enforce: 'pre',
14+
resolveId(id: string) {
15+
if (id === 'crypto') return '\0crypto-stub';
16+
},
17+
load(id: string) {
18+
if (id === '\0crypto-stub') {
19+
return [
20+
'export function createHash() { return { update() { return this; }, digest() { return ""; } }; }',
21+
'export function createVerify() { return { update() { return this; }, end() {}, verify() { return false; } }; }',
22+
'export default {};',
23+
].join('\n');
24+
}
25+
},
26+
},
27+
react(),
28+
],
729
server: {
830
port: 3000,
931
},
@@ -36,14 +58,6 @@ export default defineConfig({
3658
}
3759
warn(warning);
3860
},
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-
},
4761
}
4862
}
4963
});

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
});

0 commit comments

Comments
 (0)