Skip to content

Commit 0c73e55

Browse files
DeDuckProjectclaude
andcommitted
fix: polish action entry point and recorder robustness
- Create packages/action/src/index.ts (was missing after hook blocked initial write) - Use execFileSync with args array in action (no shell injection risk) - Replace fragile regex type-stripping with esbuild transpilation in playwright-runner - Fix video file resolution to sort by mtime instead of filename - Export uploadArtifact/uploadToGitHubAssets from core's main index - Add @actions/artifact to action deps, esbuild to core deps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 464be5a commit 0c73e55

10 files changed

Lines changed: 1325 additions & 22 deletions

File tree

.idea/.gitignore

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/git-glimpse.iml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/modules.xml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/vcs.xml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/action/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"test": "vitest run"
1212
},
1313
"dependencies": {
14+
"@actions/artifact": "^2.1.11",
1415
"@actions/core": "^1.10.1",
1516
"@actions/github": "^6.0.0",
1617
"@git-glimpse/core": "workspace:*"

packages/action/src/index.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import * as core from '@actions/core';
2+
import * as github from '@actions/github';
3+
import { execFileSync, spawn } from 'node:child_process';
4+
import {
5+
loadConfig,
6+
runPipeline,
7+
postPRComment,
8+
uploadArtifact,
9+
type GitGlimpseConfig,
10+
} from '@git-glimpse/core';
11+
12+
async function run(): Promise<void> {
13+
const context = github.context;
14+
const token = process.env['GITHUB_TOKEN'];
15+
16+
if (!token) {
17+
core.setFailed('GITHUB_TOKEN is required');
18+
return;
19+
}
20+
21+
if (context.eventName !== 'pull_request') {
22+
core.info('git-glimpse only runs on pull_request events. Skipping.');
23+
return;
24+
}
25+
26+
const pullNumber = context.payload.pull_request?.number;
27+
if (!pullNumber) {
28+
core.setFailed('Could not determine PR number');
29+
return;
30+
}
31+
32+
const configPath = core.getInput('config-path') || undefined;
33+
const previewUrlInput = core.getInput('preview-url') || undefined;
34+
const startCommandInput = core.getInput('start-command') || undefined;
35+
36+
let config = await loadConfig(configPath);
37+
if (previewUrlInput) {
38+
config = { ...config, app: { ...config.app, previewUrl: previewUrlInput } };
39+
}
40+
if (startCommandInput) {
41+
config = { ...config, app: { ...config.app, startCommand: startCommandInput } };
42+
}
43+
44+
const baseSha = context.payload.pull_request?.base?.sha;
45+
const headSha = context.payload.pull_request?.head?.sha;
46+
if (!baseSha || !headSha) {
47+
core.setFailed('Could not determine PR base/head SHA');
48+
return;
49+
}
50+
51+
core.info(`Computing diff: ${baseSha}..${headSha}`);
52+
// Use execFileSync with args array — no shell, no injection risk
53+
const diff = execFileSync('git', ['diff', `${baseSha}..${headSha}`], { encoding: 'utf-8' });
54+
55+
const baseUrl = resolveBaseUrl(config, previewUrlInput);
56+
if (!baseUrl) {
57+
core.setFailed(
58+
'No base URL available. Set app.previewUrl or app.startCommand + app.readyWhen in config.'
59+
);
60+
return;
61+
}
62+
63+
if (config.setup) {
64+
core.info(`Running setup: ${config.setup}`);
65+
const parts = config.setup.split(' ');
66+
execFileSync(parts[0]!, parts.slice(1), { stdio: 'inherit' });
67+
}
68+
69+
let appProcess: ReturnType<typeof spawn> | null = null;
70+
if (config.app.startCommand && !config.app.previewUrl) {
71+
appProcess = await startApp(config.app.startCommand, config.app.readyWhen?.url ?? baseUrl);
72+
}
73+
74+
try {
75+
core.info('Running git-glimpse pipeline...');
76+
const result = await runPipeline({ diff, baseUrl, outputDir: './recordings', config });
77+
78+
if (result.errors.length > 0) {
79+
core.warning(`Pipeline completed with errors:\n${result.errors.join('\n')}`);
80+
}
81+
82+
let recordingUrl: string | undefined;
83+
if (result.recording) {
84+
core.info(
85+
`Recording created: ${result.recording.path} (${result.recording.sizeMB.toFixed(1)} MB)`
86+
);
87+
const upload = await uploadArtifact(result.recording.path);
88+
recordingUrl = upload.url;
89+
core.setOutput('recording-url', recordingUrl);
90+
}
91+
92+
const { owner, repo } = context.repo;
93+
const comment = await postPRComment(token, {
94+
owner,
95+
repo,
96+
pullNumber,
97+
analysis: result.analysis,
98+
recordingUrl,
99+
screenshots: result.screenshots,
100+
script: result.script,
101+
});
102+
103+
core.info(`Demo comment posted: ${comment.url}`);
104+
core.setOutput('comment-url', comment.url);
105+
core.setOutput('success', String(result.success));
106+
} finally {
107+
appProcess?.kill();
108+
}
109+
}
110+
111+
function resolveBaseUrl(config: GitGlimpseConfig, previewUrlOverride?: string): string | null {
112+
const previewUrl = previewUrlOverride ?? config.app.previewUrl;
113+
if (previewUrl) {
114+
const resolved = process.env[previewUrl] ?? previewUrl;
115+
return resolved.startsWith('http') ? resolved : null;
116+
}
117+
if (config.app.readyWhen?.url) {
118+
const u = new URL(config.app.readyWhen.url);
119+
return u.origin;
120+
}
121+
return 'http://localhost:3000';
122+
}
123+
124+
async function startApp(
125+
startCommand: string,
126+
readyUrl: string
127+
): Promise<ReturnType<typeof spawn>> {
128+
const parts = startCommand.split(' ');
129+
core.info(`Starting app: ${startCommand}`);
130+
const proc = spawn(parts[0]!, parts.slice(1), { stdio: 'inherit', shell: false });
131+
132+
await waitForUrl(readyUrl, 30000);
133+
core.info('App is ready');
134+
return proc;
135+
}
136+
137+
async function waitForUrl(url: string, timeout: number): Promise<void> {
138+
const deadline = Date.now() + timeout;
139+
while (Date.now() < deadline) {
140+
try {
141+
const res = await fetch(url);
142+
if (res.ok) return;
143+
} catch {
144+
// not ready yet
145+
}
146+
await new Promise((r) => setTimeout(r, 1000));
147+
}
148+
throw new Error(`App did not become ready at ${url} within ${timeout / 1000}s`);
149+
}
150+
151+
run().catch((err) => core.setFailed(err instanceof Error ? err.message : String(err)));

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@anthropic-ai/sdk": "^0.24.3",
2424
"@playwright/test": "^1.44.0",
2525
"@octokit/rest": "^20.1.1",
26+
"esbuild": "^0.21.5",
2627
"minimatch": "^10.0.1",
2728
"zod": "^3.23.4"
2829
},

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ export type { ChangeAnalysis } from './analyzer/change-summarizer.js';
1515

1616
export { generateDemoScript } from './generator/script-generator.js';
1717
export { postPRComment } from './publisher/github-comment.js';
18+
export { uploadArtifact, uploadToGitHubAssets } from './publisher/storage.js';
19+
export type { UploadResult } from './publisher/storage.js';

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

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,21 @@ async function createContext(
6666
});
6767
}
6868

69-
async function executeScript(script: string, page: Page, baseUrl: string): Promise<void> {
70-
// Write script to a temp module and import it
69+
async function executeScript(script: string, page: Page, _baseUrl: string): Promise<void> {
7170
const { writeFileSync, unlinkSync } = await import('node:fs');
7271
const { tmpdir } = await import('node:os');
7372
const { pathToFileURL } = await import('node:url');
73+
const { transformSync } = await import('esbuild');
7474

75-
const tmpPath = join(tmpdir(), `git-glimpse-script-${Date.now()}.mjs`);
76-
77-
// Wrap the TypeScript-style script in runnable ESM
78-
// Strip type annotations for direct execution
79-
const runnableScript = stripTypeAnnotations(script);
75+
// Transpile TypeScript → ESM JavaScript via esbuild
76+
const { code } = transformSync(script, {
77+
loader: 'ts',
78+
format: 'esm',
79+
target: 'node20',
80+
});
8081

81-
writeFileSync(tmpPath, runnableScript, 'utf-8');
82+
const tmpPath = join(tmpdir(), `git-glimpse-script-${Date.now()}.mjs`);
83+
writeFileSync(tmpPath, code, 'utf-8');
8284

8385
try {
8486
const mod = await import(pathToFileURL(tmpPath).href);
@@ -91,22 +93,15 @@ async function executeScript(script: string, page: Page, baseUrl: string): Promi
9193
}
9294
}
9395

94-
function stripTypeAnnotations(script: string): string {
95-
// Remove TypeScript import and type annotations for direct Node.js execution
96-
return script
97-
.replace(/^import type.*$/gm, '') // remove type-only imports
98-
.replace(/: Page/g, '') // remove : Page annotation
99-
.replace(/: Promise<void>/g, '') // remove return type
100-
.replace(/^import \{ .* \} from '@playwright\/test';$/gm, ''); // remove playwright import (page is passed in)
101-
}
102-
10396
async function resolveVideoPath(outputDir: string): Promise<string> {
104-
const { readdirSync } = await import('node:fs');
105-
const files = readdirSync(outputDir).filter((f) => f.endsWith('.webm'));
106-
files.sort(); // latest by name
97+
const { readdirSync, statSync } = await import('node:fs');
98+
const files = readdirSync(outputDir)
99+
.filter((f) => f.endsWith('.webm'))
100+
.map((f) => ({ name: f, mtime: statSync(join(outputDir, f)).mtimeMs }))
101+
.sort((a, b) => b.mtime - a.mtime); // newest first
107102

108-
const latest = files[files.length - 1];
103+
const latest = files[0];
109104
if (!latest) throw new Error(`No video file found in ${outputDir}`);
110105

111-
return join(outputDir, latest);
106+
return join(outputDir, latest.name);
112107
}

0 commit comments

Comments
 (0)