Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ pnpm test # all packages
pnpm test --watch # watch mode
```

Features and bug fixes should include a unit test. Keep tests focused and avoid over-mocking —
test the behaviour that matters, not implementation details.

Integration tests require a running app. See `tests/integration/`.

## External-First Design Principle
Expand Down
205 changes: 134 additions & 71 deletions packages/action/dist/index.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions packages/action/dist/index.js.map

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions packages/core/src/recorder/ensure-playwright.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';

/**
* Resolves @playwright/test, installing it automatically if the consumer project
* does not have it as a dependency. This allows git-glimpse to work in projects
* that have no existing Playwright setup.
*
* Resolution order:
* 1. Consumer's own node_modules (process.cwd()) — respects their pinned version
* 2. ~/.cache/git-glimpse/playwright — auto-installed fallback
*/
export async function ensurePlaywright(): Promise<typeof import('@playwright/test')> {
// 1. Try resolving from the consumer's project first
try {
const req = createRequire(join(process.cwd(), 'package.json'));
return req('@playwright/test') as typeof import('@playwright/test');
} catch {
// Consumer doesn't have @playwright/test — fall through to auto-install
}

// 2. Determine a stable install directory
const installDir = resolveInstallDir();
const playwrightPkgPath = join(installDir, 'node_modules', '@playwright', 'test', 'package.json');

if (!existsSync(playwrightPkgPath)) {
console.info('[git-glimpse] @playwright/test not found in consumer project. Installing...');
console.info(`[git-glimpse] Install directory: ${installDir}`);

execFileSync('npm', ['install', '--prefix', installDir, '--no-save', '@playwright/test'], {
stdio: 'inherit',
});

console.info('[git-glimpse] @playwright/test installed successfully.');
}

// 3. Resolve the module from our install dir
const req = createRequire(join(installDir, 'package.json'));
const pw = req('@playwright/test') as typeof import('@playwright/test');

// 4. Ensure Chromium browser binaries are installed
await ensureChromium(installDir);

return pw;
}

function resolveInstallDir(): string {
try {
const dir = join(homedir(), '.cache', 'git-glimpse', 'playwright');
// Quick writable check by resolving the path (actual write happens in npm install)
return dir;
} catch {
return join(tmpdir(), 'git-glimpse-playwright');
}
}

async function ensureChromium(installDir: string): Promise<void> {
// Check if Chromium is already installed by looking for the ms-playwright cache
const msPlaywrightCache = join(homedir(), '.cache', 'ms-playwright');
if (existsSync(msPlaywrightCache)) {
const entries = await import('node:fs').then((fs) =>
fs.readdirSync(msPlaywrightCache).filter((e) => e.startsWith('chromium'))
);
if (entries.length > 0) {
return; // Chromium already cached
}
}

console.info('[git-glimpse] Installing Playwright Chromium browser...');

// Use the playwright CLI from our install dir to install chromium
const playwrightCli = join(installDir, 'node_modules', '.bin', 'playwright');
execFileSync(playwrightCli, ['install', 'chromium', '--with-deps'], {
stdio: 'inherit',
});

console.info('[git-glimpse] Chromium installed.');
}
4 changes: 2 additions & 2 deletions packages/core/src/recorder/fallback.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import type { RecordingConfig } from '../config/schema.js';
import type { RouteMapping } from '../analyzer/route-detector.js';
import { ensurePlaywright } from './ensure-playwright.js';

export interface FallbackResult {
screenshots: string[];
Expand All @@ -18,7 +18,7 @@ export async function takeScreenshots(
mkdirSync(outputDir, { recursive: true });
}

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

Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/recorder/playwright-runner.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Browser, BrowserContext, Page } from '@playwright/test';
import { existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import type { RecordingConfig } from '../config/schema.js';
import { ensurePlaywright } from './ensure-playwright.js';

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

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

Expand Down
141 changes: 141 additions & 0 deletions tests/unit/ensure-playwright.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { join } from 'node:path';

// Mock node:child_process
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
}));

// Mock node:fs — keep real join/path but control existsSync and readdirSync
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
...actual,
existsSync: vi.fn(),
readdirSync: vi.fn(),
};
});

// Mock node:module — control createRequire
vi.mock('node:module', () => ({
createRequire: vi.fn(),
}));

// Mock node:os
vi.mock('node:os', () => ({
homedir: () => '/mock-home',
tmpdir: () => '/mock-tmp',
}));

import { existsSync, readdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { ensurePlaywright } from '../../packages/core/src/recorder/ensure-playwright.js';

const mockExistsSync = vi.mocked(existsSync);
const mockReaddirSync = vi.mocked(readdirSync);
const mockExecFileSync = vi.mocked(execFileSync);
const mockCreateRequire = vi.mocked(createRequire);

const fakePw = { chromium: { launch: vi.fn() } };

beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(console, 'info').mockImplementation(() => {});
});

describe('ensurePlaywright', () => {
it('returns playwright from consumer node_modules when available', async () => {
const fakeRequire = vi.fn().mockReturnValue(fakePw);
mockCreateRequire.mockReturnValue(fakeRequire as any);

const result = await ensurePlaywright();

expect(result).toBe(fakePw);
expect(mockCreateRequire).toHaveBeenCalledTimes(1);
expect(String(mockCreateRequire.mock.calls[0][0])).toContain('package.json');
// Should NOT run npm install
expect(mockExecFileSync).not.toHaveBeenCalled();
});

it('auto-installs when consumer does not have playwright', async () => {
const consumerRequire = vi.fn().mockImplementation(() => {
throw new Error('MODULE_NOT_FOUND');
});
const cacheRequire = vi.fn().mockReturnValue(fakePw);

mockCreateRequire
.mockReturnValueOnce(consumerRequire as any)
.mockReturnValueOnce(cacheRequire as any);

// Playwright package.json not yet in cache; Chromium is already present
mockExistsSync.mockImplementation((p) => {
const path = String(p);
if (path.includes('@playwright')) return false;
if (path.includes('ms-playwright')) return true;
return false;
});
mockReaddirSync.mockReturnValue(['chromium-1234'] as any);

const result = await ensurePlaywright();

expect(result).toBe(fakePw);
expect(mockExecFileSync).toHaveBeenCalledWith(
'npm',
expect.arrayContaining(['install', '@playwright/test']),
expect.objectContaining({ stdio: 'inherit' }),
);
});

it('skips npm install when playwright is already cached', async () => {
const consumerRequire = vi.fn().mockImplementation(() => {
throw new Error('MODULE_NOT_FOUND');
});
const cacheRequire = vi.fn().mockReturnValue(fakePw);

mockCreateRequire
.mockReturnValueOnce(consumerRequire as any)
.mockReturnValueOnce(cacheRequire as any);

// Everything already exists
mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue(['chromium-1234'] as any);

const result = await ensurePlaywright();

expect(result).toBe(fakePw);
// No npm install, no playwright install
expect(mockExecFileSync).not.toHaveBeenCalled();
});

it('installs chromium when browser cache is missing', async () => {
const consumerRequire = vi.fn().mockImplementation(() => {
throw new Error('MODULE_NOT_FOUND');
});
const cacheRequire = vi.fn().mockReturnValue(fakePw);

mockCreateRequire
.mockReturnValueOnce(consumerRequire as any)
.mockReturnValueOnce(cacheRequire as any);

const cacheDir = join('/mock-home', '.cache', 'git-glimpse', 'playwright');

mockExistsSync.mockImplementation((p) => {
const path = String(p);
// Package is installed but chromium cache doesn't exist
if (path.includes('@playwright')) return true;
if (path.includes('ms-playwright')) return false;
return false;
});

const result = await ensurePlaywright();

expect(result).toBe(fakePw);
// Should have called playwright install chromium
expect(mockExecFileSync).toHaveBeenCalledWith(
join(cacheDir, 'node_modules', '.bin', 'playwright'),
['install', 'chromium', '--with-deps'],
expect.objectContaining({ stdio: 'inherit' }),
);
});
});
Loading