Skip to content

Commit 81361f3

Browse files
Merge pull request #49 from DeDuckProject/claude/fix-playwright-install-S7SeK
2 parents edb0b3f + cb0e6e3 commit 81361f3

File tree

7 files changed

+368
-82
lines changed

7 files changed

+368
-82
lines changed

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ pnpm test # all packages
4747
pnpm test --watch # watch mode
4848
```
4949

50+
Features and bug fixes should include a unit test. Keep tests focused and avoid over-mocking —
51+
test the behaviour that matters, not implementation details.
52+
5053
Integration tests require a running app. See `tests/integration/`.
5154

5255
## External-First Design Principle

packages/action/dist/index.js

Lines changed: 134 additions & 71 deletions
Large diffs are not rendered by default.

packages/action/dist/index.js.map

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { existsSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { homedir, tmpdir } from 'node:os';
4+
import { execFileSync } from 'node:child_process';
5+
import { createRequire } from 'node:module';
6+
7+
/**
8+
* Resolves @playwright/test, installing it automatically if the consumer project
9+
* does not have it as a dependency. This allows git-glimpse to work in projects
10+
* that have no existing Playwright setup.
11+
*
12+
* Resolution order:
13+
* 1. Consumer's own node_modules (process.cwd()) — respects their pinned version
14+
* 2. ~/.cache/git-glimpse/playwright — auto-installed fallback
15+
*/
16+
export async function ensurePlaywright(): Promise<typeof import('@playwright/test')> {
17+
// 1. Try resolving from the consumer's project first
18+
try {
19+
const req = createRequire(join(process.cwd(), 'package.json'));
20+
return req('@playwright/test') as typeof import('@playwright/test');
21+
} catch {
22+
// Consumer doesn't have @playwright/test — fall through to auto-install
23+
}
24+
25+
// 2. Determine a stable install directory
26+
const installDir = resolveInstallDir();
27+
const playwrightPkgPath = join(installDir, 'node_modules', '@playwright', 'test', 'package.json');
28+
29+
if (!existsSync(playwrightPkgPath)) {
30+
console.info('[git-glimpse] @playwright/test not found in consumer project. Installing...');
31+
console.info(`[git-glimpse] Install directory: ${installDir}`);
32+
33+
execFileSync('npm', ['install', '--prefix', installDir, '--no-save', '@playwright/test'], {
34+
stdio: 'inherit',
35+
});
36+
37+
console.info('[git-glimpse] @playwright/test installed successfully.');
38+
}
39+
40+
// 3. Resolve the module from our install dir
41+
const req = createRequire(join(installDir, 'package.json'));
42+
const pw = req('@playwright/test') as typeof import('@playwright/test');
43+
44+
// 4. Ensure Chromium browser binaries are installed
45+
await ensureChromium(installDir);
46+
47+
return pw;
48+
}
49+
50+
function resolveInstallDir(): string {
51+
try {
52+
const dir = join(homedir(), '.cache', 'git-glimpse', 'playwright');
53+
// Quick writable check by resolving the path (actual write happens in npm install)
54+
return dir;
55+
} catch {
56+
return join(tmpdir(), 'git-glimpse-playwright');
57+
}
58+
}
59+
60+
async function ensureChromium(installDir: string): Promise<void> {
61+
// Check if Chromium is already installed by looking for the ms-playwright cache
62+
const msPlaywrightCache = join(homedir(), '.cache', 'ms-playwright');
63+
if (existsSync(msPlaywrightCache)) {
64+
const entries = await import('node:fs').then((fs) =>
65+
fs.readdirSync(msPlaywrightCache).filter((e) => e.startsWith('chromium'))
66+
);
67+
if (entries.length > 0) {
68+
return; // Chromium already cached
69+
}
70+
}
71+
72+
console.info('[git-glimpse] Installing Playwright Chromium browser...');
73+
74+
// Use the playwright CLI from our install dir to install chromium
75+
const playwrightCli = join(installDir, 'node_modules', '.bin', 'playwright');
76+
execFileSync(playwrightCli, ['install', 'chromium', '--with-deps'], {
77+
stdio: 'inherit',
78+
});
79+
80+
console.info('[git-glimpse] Chromium installed.');
81+
}

packages/core/src/recorder/fallback.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { mkdirSync, existsSync } from 'node:fs';
22
import { join } from 'node:path';
3-
import { createRequire } from 'node:module';
43
import type { RecordingConfig } from '../config/schema.js';
54
import type { RouteMapping } from '../analyzer/route-detector.js';
5+
import { ensurePlaywright } from './ensure-playwright.js';
66

77
export interface FallbackResult {
88
screenshots: string[];
@@ -18,7 +18,7 @@ export async function takeScreenshots(
1818
mkdirSync(outputDir, { recursive: true });
1919
}
2020

21-
const { chromium } = createRequire(join(process.cwd(), 'package.json'))('@playwright/test') as typeof import('@playwright/test');
21+
const { chromium } = await ensurePlaywright();
2222
const browser = await chromium.launch({ headless: true });
2323
const screenshots: string[] = [];
2424

packages/core/src/recorder/playwright-runner.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { Browser, BrowserContext, Page } from '@playwright/test';
22
import { existsSync, mkdirSync } from 'node:fs';
33
import { join } from 'node:path';
4-
import { createRequire } from 'node:module';
54
import type { RecordingConfig } from '../config/schema.js';
5+
import { ensurePlaywright } from './ensure-playwright.js';
66

77
export interface RecordingResult {
88
videoPath: string;
@@ -23,10 +23,8 @@ export async function runScriptAndRecord(options: RunScriptOptions): Promise<Rec
2323
mkdirSync(outputDir, { recursive: true });
2424
}
2525

26-
// Resolve @playwright/test from the user's project (process.cwd()), not from the
27-
// action's own dist directory. This is necessary when running as a GitHub Action,
28-
// where the action bundle lives in a separate directory from the user's node_modules.
29-
const { chromium } = createRequire(join(process.cwd(), 'package.json'))('@playwright/test') as typeof import('@playwright/test');
26+
// Resolve @playwright/test from the consumer's project, or auto-install it if missing.
27+
const { chromium } = await ensurePlaywright();
3028
const browser = await chromium.launch({ headless: true });
3129
const startTime = Date.now();
3230

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { join } from 'node:path';
3+
4+
// Mock node:child_process
5+
vi.mock('node:child_process', () => ({
6+
execFileSync: vi.fn(),
7+
}));
8+
9+
// Mock node:fs — keep real join/path but control existsSync and readdirSync
10+
vi.mock('node:fs', async () => {
11+
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
12+
return {
13+
...actual,
14+
existsSync: vi.fn(),
15+
readdirSync: vi.fn(),
16+
};
17+
});
18+
19+
// Mock node:module — control createRequire
20+
vi.mock('node:module', () => ({
21+
createRequire: vi.fn(),
22+
}));
23+
24+
// Mock node:os
25+
vi.mock('node:os', () => ({
26+
homedir: () => '/mock-home',
27+
tmpdir: () => '/mock-tmp',
28+
}));
29+
30+
import { existsSync, readdirSync } from 'node:fs';
31+
import { execFileSync } from 'node:child_process';
32+
import { createRequire } from 'node:module';
33+
import { ensurePlaywright } from '../../packages/core/src/recorder/ensure-playwright.js';
34+
35+
const mockExistsSync = vi.mocked(existsSync);
36+
const mockReaddirSync = vi.mocked(readdirSync);
37+
const mockExecFileSync = vi.mocked(execFileSync);
38+
const mockCreateRequire = vi.mocked(createRequire);
39+
40+
const fakePw = { chromium: { launch: vi.fn() } };
41+
42+
beforeEach(() => {
43+
vi.resetAllMocks();
44+
vi.spyOn(console, 'info').mockImplementation(() => {});
45+
});
46+
47+
describe('ensurePlaywright', () => {
48+
it('returns playwright from consumer node_modules when available', async () => {
49+
const fakeRequire = vi.fn().mockReturnValue(fakePw);
50+
mockCreateRequire.mockReturnValue(fakeRequire as any);
51+
52+
const result = await ensurePlaywright();
53+
54+
expect(result).toBe(fakePw);
55+
expect(mockCreateRequire).toHaveBeenCalledTimes(1);
56+
expect(String(mockCreateRequire.mock.calls[0][0])).toContain('package.json');
57+
// Should NOT run npm install
58+
expect(mockExecFileSync).not.toHaveBeenCalled();
59+
});
60+
61+
it('auto-installs when consumer does not have playwright', async () => {
62+
const consumerRequire = vi.fn().mockImplementation(() => {
63+
throw new Error('MODULE_NOT_FOUND');
64+
});
65+
const cacheRequire = vi.fn().mockReturnValue(fakePw);
66+
67+
mockCreateRequire
68+
.mockReturnValueOnce(consumerRequire as any)
69+
.mockReturnValueOnce(cacheRequire as any);
70+
71+
// Playwright package.json not yet in cache; Chromium is already present
72+
mockExistsSync.mockImplementation((p) => {
73+
const path = String(p);
74+
if (path.includes('@playwright')) return false;
75+
if (path.includes('ms-playwright')) return true;
76+
return false;
77+
});
78+
mockReaddirSync.mockReturnValue(['chromium-1234'] as any);
79+
80+
const result = await ensurePlaywright();
81+
82+
expect(result).toBe(fakePw);
83+
expect(mockExecFileSync).toHaveBeenCalledWith(
84+
'npm',
85+
expect.arrayContaining(['install', '@playwright/test']),
86+
expect.objectContaining({ stdio: 'inherit' }),
87+
);
88+
});
89+
90+
it('skips npm install when playwright is already cached', async () => {
91+
const consumerRequire = vi.fn().mockImplementation(() => {
92+
throw new Error('MODULE_NOT_FOUND');
93+
});
94+
const cacheRequire = vi.fn().mockReturnValue(fakePw);
95+
96+
mockCreateRequire
97+
.mockReturnValueOnce(consumerRequire as any)
98+
.mockReturnValueOnce(cacheRequire as any);
99+
100+
// Everything already exists
101+
mockExistsSync.mockReturnValue(true);
102+
mockReaddirSync.mockReturnValue(['chromium-1234'] as any);
103+
104+
const result = await ensurePlaywright();
105+
106+
expect(result).toBe(fakePw);
107+
// No npm install, no playwright install
108+
expect(mockExecFileSync).not.toHaveBeenCalled();
109+
});
110+
111+
it('installs chromium when browser cache is missing', async () => {
112+
const consumerRequire = vi.fn().mockImplementation(() => {
113+
throw new Error('MODULE_NOT_FOUND');
114+
});
115+
const cacheRequire = vi.fn().mockReturnValue(fakePw);
116+
117+
mockCreateRequire
118+
.mockReturnValueOnce(consumerRequire as any)
119+
.mockReturnValueOnce(cacheRequire as any);
120+
121+
const cacheDir = join('/mock-home', '.cache', 'git-glimpse', 'playwright');
122+
123+
mockExistsSync.mockImplementation((p) => {
124+
const path = String(p);
125+
// Package is installed but chromium cache doesn't exist
126+
if (path.includes('@playwright')) return true;
127+
if (path.includes('ms-playwright')) return false;
128+
return false;
129+
});
130+
131+
const result = await ensurePlaywright();
132+
133+
expect(result).toBe(fakePw);
134+
// Should have called playwright install chromium
135+
expect(mockExecFileSync).toHaveBeenCalledWith(
136+
join(cacheDir, 'node_modules', '.bin', 'playwright'),
137+
['install', 'chromium', '--with-deps'],
138+
expect.objectContaining({ stdio: 'inherit' }),
139+
);
140+
});
141+
});

0 commit comments

Comments
 (0)