@caperjs/core 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/probe.mjs ADDED
@@ -0,0 +1,368 @@
1
+ import { bgRed, bold, cyan, green, red, white, yellow } from 'kleur/colors';
2
+
3
+ import { createRequire } from 'node:module';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ /**
9
+ * `caper agent probe <url>` — drive a running Caper app through the
10
+ * `window.Caper.automation` Playwright bridge. Boot detection, action
11
+ * dispatch, state/context reads, and optional screenshot, all in one shot.
12
+ */
13
+
14
+ const DEFAULT_TIMEOUT_MS = 15_000;
15
+ const DEFAULT_VIEWPORT_W = 1280;
16
+ const DEFAULT_VIEWPORT_H = 720;
17
+ const DEFAULT_LOG_TAIL = 50;
18
+
19
+ /**
20
+ * Parse the raw CLI arguments for `caper agent probe`.
21
+ *
22
+ * @param {string[]} args
23
+ * @returns {{
24
+ * url: string,
25
+ * actions: { name: string, data?: unknown }[],
26
+ * until?: string,
27
+ * waitMs: number,
28
+ * timeoutMs: number,
29
+ * appId?: string,
30
+ * screenshot?: string,
31
+ * headed: boolean,
32
+ * json: boolean,
33
+ * viewport: { width: number, height: number }
34
+ * }}
35
+ */
36
+ export function parseProbeArgs(args) {
37
+ const result = {
38
+ url: '',
39
+ actions: [],
40
+ until: undefined,
41
+ waitMs: 0,
42
+ timeoutMs: DEFAULT_TIMEOUT_MS,
43
+ appId: undefined,
44
+ screenshot: undefined,
45
+ headed: false,
46
+ json: false,
47
+ viewport: { width: DEFAULT_VIEWPORT_W, height: DEFAULT_VIEWPORT_H },
48
+ };
49
+
50
+ let i = 0;
51
+ while (i < args.length) {
52
+ const arg = args[i];
53
+
54
+ if (arg === '--action') {
55
+ const next = args[++i];
56
+ if (!next) throw new Error('Missing value for --action');
57
+ const eq = next.indexOf('=');
58
+ const name = eq >= 0 ? next.slice(0, eq) : next;
59
+ const rawData = eq >= 0 ? next.slice(eq + 1) : undefined;
60
+ let data;
61
+ if (rawData !== undefined) {
62
+ try {
63
+ data = JSON.parse(rawData);
64
+ } catch {
65
+ throw new Error(`Invalid JSON data in --action ${JSON.stringify(next)}`);
66
+ }
67
+ }
68
+ result.actions.push(data !== undefined ? { name, data } : { name });
69
+ } else if (arg === '--until') {
70
+ const next = args[++i];
71
+ if (!next) throw new Error('Missing value for --until');
72
+ result.until = next;
73
+ } else if (arg === '--wait') {
74
+ const next = args[++i];
75
+ if (!next) throw new Error('Missing value for --wait');
76
+ const ms = Number(next);
77
+ if (!Number.isFinite(ms) || ms < 0) throw new Error(`Invalid --wait value: ${next}`);
78
+ result.waitMs = ms;
79
+ } else if (arg === '--timeout') {
80
+ const next = args[++i];
81
+ if (!next) throw new Error('Missing value for --timeout');
82
+ const ms = Number(next);
83
+ if (!Number.isFinite(ms) || ms < 0) throw new Error(`Invalid --timeout value: ${next}`);
84
+ result.timeoutMs = ms;
85
+ } else if (arg === '--app') {
86
+ const next = args[++i];
87
+ if (!next) throw new Error('Missing value for --app');
88
+ result.appId = next;
89
+ } else if (arg === '--screenshot') {
90
+ const next = args[++i];
91
+ if (!next) throw new Error('Missing value for --screenshot');
92
+ result.screenshot = next;
93
+ } else if (arg === '--headed') {
94
+ result.headed = true;
95
+ } else if (arg === '--json') {
96
+ result.json = true;
97
+ } else if (arg === '--viewport') {
98
+ const next = args[++i];
99
+ if (!next) throw new Error('Missing value for --viewport');
100
+ const match = next.match(/^(\d+)x(\d+)$/i);
101
+ if (!match) throw new Error(`Invalid --viewport value: ${next} (expected WxH)`);
102
+ result.viewport = { width: Number(match[1]), height: Number(match[2]) };
103
+ } else if (arg.startsWith('--')) {
104
+ throw new Error(`Unknown option: ${arg}`);
105
+ } else if (!result.url) {
106
+ result.url = arg;
107
+ } else {
108
+ throw new Error(`Unexpected positional argument: ${arg}`);
109
+ }
110
+
111
+ i++;
112
+ }
113
+
114
+ if (!result.url) {
115
+ throw new Error('Missing required <url> argument');
116
+ }
117
+
118
+ return result;
119
+ }
120
+
121
+ function printUsage() {
122
+ console.error(red('Usage: caper agent probe <url> [options]'));
123
+ console.error('');
124
+ console.error('Options:');
125
+ console.error(' --action <name>[=<json>] dispatch action(s) in order');
126
+ console.error(' --until <js-predicate> wait for state predicate to return true');
127
+ console.error(' --wait <ms> sleep after actions (default 0)');
128
+ console.error(' --timeout <ms> boot and until timeout (default 15000)');
129
+ console.error(' --app <id> target a specific app');
130
+ console.error(' --screenshot <path> save a PNG screenshot');
131
+ console.error(' --headed run in headed mode');
132
+ console.error(' --json print JSON result only');
133
+ console.error(' --viewport WxH viewport size (default 1280x720)');
134
+ }
135
+
136
+ async function resolvePlaywright() {
137
+ const pkgJson = path.join(process.cwd(), 'package.json');
138
+ const require = createRequire(pkgJson);
139
+ const resolved = require.resolve('playwright');
140
+ const mod = await import(pathToFileURL(resolved).href);
141
+ // Playwright's CJS entry re-exports via default when dynamically imported.
142
+ return mod.default ?? mod;
143
+ }
144
+
145
+ /**
146
+ * Run the `caper agent probe` command.
147
+ *
148
+ * @param {string[]} args
149
+ */
150
+ export async function probe(args) {
151
+ let parsed;
152
+ try {
153
+ parsed = parseProbeArgs(args);
154
+ } catch (err) {
155
+ console.error(bold(bgRed(white(` ${err.message} `))));
156
+ printUsage();
157
+ process.exit(1);
158
+ }
159
+
160
+ const {
161
+ url,
162
+ actions,
163
+ until,
164
+ waitMs,
165
+ timeoutMs,
166
+ appId,
167
+ screenshot,
168
+ headed,
169
+ json,
170
+ viewport,
171
+ } = parsed;
172
+
173
+ let playwright;
174
+ try {
175
+ playwright = await resolvePlaywright();
176
+ } catch {
177
+ console.error(
178
+ red('playwright is not installed in the current project.'),
179
+ 'Run:',
180
+ cyan('pnpm add -D playwright && npx playwright install chromium'),
181
+ );
182
+ process.exit(2);
183
+ }
184
+
185
+ const startTime = Date.now();
186
+ const pageErrors = [];
187
+ const consoleErrors = [];
188
+ const sentActions = [];
189
+ let bootTimedOut = false;
190
+ let browser;
191
+ let context;
192
+
193
+ try {
194
+ browser = await playwright.chromium.launch({ headless: !headed });
195
+ context = await browser.newContext({ viewport });
196
+ const page = await context.newPage();
197
+
198
+ page.on('pageerror', (err) => {
199
+ pageErrors.push(err.stack || err.message || String(err));
200
+ });
201
+ page.on('console', (msg) => {
202
+ if (msg.type() === 'error') {
203
+ consoleErrors.push(msg.text());
204
+ }
205
+ });
206
+
207
+ try {
208
+ await page.goto(url, { timeout: timeoutMs });
209
+ await page.waitForFunction(
210
+ () => window.Caper && window.Caper.__readyApps && window.Caper.__readyApps.size > 0,
211
+ null,
212
+ { timeout: timeoutMs },
213
+ );
214
+ } catch (err) {
215
+ bootTimedOut = true;
216
+ throw err;
217
+ }
218
+
219
+ const evalResult = await page.evaluate(
220
+ async ({ appId: requestedAppId, actions: actionsToSend, untilSrc, timeout, logTail }) => {
221
+ const caper = window.Caper;
222
+ const app = await caper.ready(requestedAppId || undefined);
223
+ const resolvedAppId = app.config?.id || app.id || 'unknown';
224
+ const automation = caper.automation?.[resolvedAppId];
225
+
226
+ if (!automation) {
227
+ return {
228
+ appId: resolvedAppId,
229
+ automation: false,
230
+ context: undefined,
231
+ state: undefined,
232
+ actions: [],
233
+ log: [],
234
+ };
235
+ }
236
+
237
+ const dispatched = [];
238
+ for (const { name, data } of actionsToSend) {
239
+ automation.action(name, data);
240
+ dispatched.push({ name, data });
241
+ }
242
+
243
+ if (untilSrc) {
244
+ const predicate = new Function('return (' + untilSrc + ')')();
245
+ await automation.waitFor(predicate, { timeoutMs: timeout });
246
+ }
247
+
248
+ const fullLog = automation.log || [];
249
+ const log = fullLog.slice(-logTail);
250
+
251
+ return {
252
+ appId: resolvedAppId,
253
+ automation: true,
254
+ context: automation.getContext(),
255
+ state: automation.getState(),
256
+ actions: dispatched,
257
+ log,
258
+ };
259
+ },
260
+ { appId, actions, untilSrc: until, timeout: timeoutMs, logTail: DEFAULT_LOG_TAIL },
261
+ );
262
+
263
+ sentActions.push(...evalResult.actions);
264
+
265
+ if (waitMs > 0) {
266
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
267
+ }
268
+
269
+ if (screenshot) {
270
+ fs.mkdirSync(path.dirname(path.resolve(screenshot)), { recursive: true });
271
+ await page.screenshot({ path: screenshot, type: 'png' });
272
+ }
273
+
274
+ const result = {
275
+ url,
276
+ appId: evalResult.appId,
277
+ automation: evalResult.automation,
278
+ context: evalResult.context,
279
+ state: evalResult.state,
280
+ actions: sentActions,
281
+ log: evalResult.log,
282
+ pageErrors,
283
+ consoleErrors,
284
+ screenshot,
285
+ durationMs: Date.now() - startTime,
286
+ };
287
+
288
+ if (!screenshot) {
289
+ delete result.screenshot;
290
+ }
291
+
292
+ let failed = false;
293
+ let reason = '';
294
+ if (pageErrors.length > 0) {
295
+ failed = true;
296
+ reason = `${pageErrors.length} page error(s)`;
297
+ }
298
+
299
+ if (json) {
300
+ console.log(JSON.stringify(result, null, 2));
301
+ } else {
302
+ console.log(green(bold('✓ Probed')) + ` ${cyan(url)}`);
303
+ console.log(` ${yellow('app:')} ${evalResult.appId}`);
304
+ console.log(` ${yellow('automation:')} ${evalResult.automation}`);
305
+ console.log(` ${yellow('actions:')} ${sentActions.length}`);
306
+ console.log(` ${yellow('log:')} ${evalResult.log.length} entries`);
307
+ console.log(` ${yellow('duration:')} ${result.durationMs}ms`);
308
+ if (pageErrors.length > 0) {
309
+ console.error(red(` ${pageErrors.length} page error(s)`));
310
+ }
311
+ if (consoleErrors.length > 0) {
312
+ console.error(red(` ${consoleErrors.length} console error(s)`));
313
+ }
314
+ if (screenshot) {
315
+ console.log(` ${yellow('screenshot:')} ${screenshot}`);
316
+ }
317
+ console.log('');
318
+ console.log(JSON.stringify(result, null, 2));
319
+ }
320
+
321
+ if (failed) {
322
+ console.error(red(`probe failed: ${reason}`));
323
+ process.exit(1);
324
+ }
325
+
326
+ return result;
327
+ } catch (err) {
328
+ const result = {
329
+ url,
330
+ appId: appId || undefined,
331
+ automation: false,
332
+ context: undefined,
333
+ state: undefined,
334
+ actions: sentActions,
335
+ log: [],
336
+ pageErrors,
337
+ consoleErrors,
338
+ durationMs: Date.now() - startTime,
339
+ };
340
+
341
+ if (bootTimedOut) {
342
+ result._reason = 'boot timed out';
343
+ } else if (until && err?.message?.includes('timed out')) {
344
+ result._reason = '--until predicate timed out';
345
+ } else {
346
+ result._reason = err?.message || String(err);
347
+ }
348
+
349
+ if (screenshot) {
350
+ result.screenshot = screenshot;
351
+ }
352
+
353
+ if (json) {
354
+ console.log(JSON.stringify(result, null, 2));
355
+ } else {
356
+ console.error(bold(bgRed(white(` probe failed: ${result._reason} `))));
357
+ console.error(red(err?.stack || err?.message || String(err)));
358
+ if (pageErrors.length > 0) {
359
+ console.error(red(` ${pageErrors.length} page error(s)`));
360
+ }
361
+ }
362
+
363
+ process.exit(1);
364
+ } finally {
365
+ if (context) await context.close().catch(() => {});
366
+ if (browser) await browser.close().catch(() => {});
367
+ }
368
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseProbeArgs } from './probe.mjs';
3
+
4
+ describe('parseProbeArgs', () => {
5
+ it('parses the url positional argument', () => {
6
+ const result = parseProbeArgs(['http://localhost:4179/']);
7
+ expect(result.url).toBe('http://localhost:4179/');
8
+ expect(result.actions).toEqual([]);
9
+ expect(result.headed).toBe(false);
10
+ expect(result.json).toBe(false);
11
+ });
12
+
13
+ it('parses repeated --action with and without JSON data', () => {
14
+ const result = parseProbeArgs([
15
+ 'http://localhost:4179/',
16
+ '--action',
17
+ 'start',
18
+ '--action',
19
+ 'submit={"value":42}',
20
+ ]);
21
+ expect(result.actions).toEqual([
22
+ { name: 'start' },
23
+ { name: 'submit', data: { value: 42 } },
24
+ ]);
25
+ });
26
+
27
+ it('parses --until predicate string', () => {
28
+ const result = parseProbeArgs(['http://localhost:4179/', '--until', "s => s && s.phase === 'resolved'"]);
29
+ expect(result.until).toBe("s => s && s.phase === 'resolved'");
30
+ });
31
+
32
+ it('parses numeric --wait and --timeout options', () => {
33
+ const result = parseProbeArgs(['http://localhost:4179/', '--wait', '500', '--timeout', '30000']);
34
+ expect(result.waitMs).toBe(500);
35
+ expect(result.timeoutMs).toBe(30000);
36
+ });
37
+
38
+ it('parses --viewport WxH', () => {
39
+ const result = parseProbeArgs(['http://localhost:4179/', '--viewport', '800x600']);
40
+ expect(result.viewport).toEqual({ width: 800, height: 600 });
41
+ });
42
+
43
+ it('parses boolean and string options', () => {
44
+ const result = parseProbeArgs([
45
+ 'http://localhost:4179/',
46
+ '--app',
47
+ 'demo',
48
+ '--screenshot',
49
+ '/tmp/out.png',
50
+ '--headed',
51
+ '--json',
52
+ ]);
53
+ expect(result.appId).toBe('demo');
54
+ expect(result.screenshot).toBe('/tmp/out.png');
55
+ expect(result.headed).toBe(true);
56
+ expect(result.json).toBe(true);
57
+ });
58
+
59
+ it('throws when url is missing', () => {
60
+ expect(() => parseProbeArgs(['--action', 'start'])).toThrow('Missing required <url> argument');
61
+ });
62
+
63
+ it('throws on invalid JSON in --action', () => {
64
+ expect(() => parseProbeArgs(['http://localhost:4179/', '--action', 'submit={not json}'])).toThrow(
65
+ 'Invalid JSON data',
66
+ );
67
+ });
68
+
69
+ it('throws on invalid --viewport', () => {
70
+ expect(() => parseProbeArgs(['http://localhost:4179/', '--viewport', '800'])).toThrow(
71
+ 'Invalid --viewport value',
72
+ );
73
+ });
74
+ });
package/cli/types.mjs ADDED
@@ -0,0 +1,101 @@
1
+ import { bold, cyan, green, red, yellow } from 'kleur/colors';
2
+
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+
6
+ /**
7
+ * `caper types` — regenerate the generated `.d.ts` files without a dev server.
8
+ *
9
+ * Reads the project's `vite.config.ts` (which contains `caper()`), runs the
10
+ * asset pipeline once if present, then writes `src/types/caper-app.d.ts` and
11
+ * `src/types/caper-assets.d.ts`. Use `--no-assets` to skip the AssetPack run
12
+ * and asset type generation.
13
+ */
14
+
15
+ const APP_DTS = 'caper-app.d.ts';
16
+ const ASSET_DTS = 'caper-assets.d.ts';
17
+
18
+ /**
19
+ * @param {string[]} args
20
+ * @returns {{ assets: boolean }}
21
+ */
22
+ function parseArgs(args) {
23
+ return {
24
+ assets: !args.includes('--no-assets'),
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Find a plugin by name in Vite's resolved (flat) plugin list.
30
+ *
31
+ * @param {import('vite').ResolvedConfig} config
32
+ * @param {string} name
33
+ */
34
+ function findPlugin(config, name) {
35
+ return config.plugins.find((p) => p && p.name === name);
36
+ }
37
+
38
+ /**
39
+ * Generate `caper-app.d.ts` and optionally `caper-assets.d.ts` for a project.
40
+ *
41
+ * @param {string} root - project root (where vite.config.ts / caper.config.ts live)
42
+ * @param {{ assets?: boolean }} [options]
43
+ * @returns {Promise<{ appTypes: string, assetTypes: string | null }>}
44
+ */
45
+ export async function generateTypes(root, { assets = true } = {}) {
46
+ const { resolveConfig } = await import('vite');
47
+ const config = await resolveConfig({ root, logLevel: 'warn' }, 'serve', 'development');
48
+
49
+ const caperConfigPlugin = findPlugin(config, 'vite-plugin-caper-config');
50
+ if (!caperConfigPlugin) {
51
+ throw new Error("caper() is not in this project's vite.config — nothing to generate");
52
+ }
53
+ if (!caperConfigPlugin.api?.generateTypes) {
54
+ throw new Error(
55
+ "the caper() preset in this project's vite.config predates `caper types` — the CLI and `@caperjs/core/vite` must come from the same @caperjs/core install (>= 0.6.0)",
56
+ );
57
+ }
58
+
59
+ const assetpackPlugin = findPlugin(config, 'vite-plugin-assetpack');
60
+ const assetTypesPlugin = findPlugin(config, 'vite-plugin-asset-types');
61
+
62
+ if (assets && assetpackPlugin) {
63
+ await assetpackPlugin.api.runOnce();
64
+ }
65
+
66
+ await caperConfigPlugin.api.generateTypes();
67
+
68
+ if (assets && assetTypesPlugin) {
69
+ await assetTypesPlugin.api.generateTypes();
70
+ }
71
+
72
+ const typesDir = path.resolve(root, 'src', 'types');
73
+ return {
74
+ appTypes: path.join(typesDir, APP_DTS),
75
+ assetTypes: assets && assetTypesPlugin ? path.join(typesDir, ASSET_DTS) : null,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * CLI entry for `caper types`.
81
+ *
82
+ * @param {string[]} args
83
+ */
84
+ export async function types(args) {
85
+ const { assets } = parseArgs(args);
86
+ const root = process.cwd();
87
+
88
+ try {
89
+ const { appTypes, assetTypes } = await generateTypes(root, { assets });
90
+
91
+ console.log(green(bold('✓ Generated types')) + ` ${cyan(path.relative(root, appTypes))}`);
92
+ if (assetTypes) {
93
+ console.log(green(bold('✓ Generated types')) + ` ${cyan(path.relative(root, assetTypes))}`);
94
+ } else if (!assets) {
95
+ console.log(` ${yellow('Skipped asset types generation')} (use without --no-assets to include them)`);
96
+ }
97
+ } catch (error) {
98
+ console.error(red(`caper types: ${error.message}`));
99
+ process.exit(1);
100
+ }
101
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `caper types` — command-line type generation without a dev server.
3
+ */
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
9
+ import { generateTypes } from './types.mjs';
10
+
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+ const fixtureRoot = path.resolve(here, '../test/fixtures/app');
13
+ const caperBuildUrl = pathToFileURL(path.resolve(here, '../build/index.mjs')).href;
14
+
15
+ let root;
16
+
17
+ beforeEach(() => {
18
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'caper-types-'));
19
+ });
20
+
21
+ afterEach(() => {
22
+ fs.rmSync(root, { recursive: true, force: true });
23
+ });
24
+
25
+ function scaffoldApp(options = { assets: false }) {
26
+ fs.cpSync(fixtureRoot, root, { recursive: true });
27
+ fs.writeFileSync(
28
+ path.join(root, 'vite.config.ts'),
29
+ `import { caper } from '${caperBuildUrl}';
30
+
31
+ export default {
32
+ plugins: [caper(${JSON.stringify(options)})],
33
+ };
34
+ `,
35
+ 'utf-8',
36
+ );
37
+ }
38
+
39
+ describe('generateTypes', () => {
40
+ it('writes caper-app.d.ts and returns null assetTypes when assets are disabled', async () => {
41
+ scaffoldApp({ assets: false });
42
+
43
+ const result = await generateTypes(root, { assets: false });
44
+
45
+ const dtsPath = path.join(root, 'src', 'types', 'caper-app.d.ts');
46
+ expect(fs.existsSync(dtsPath)).toBe(true);
47
+ const content = fs.readFileSync(dtsPath, 'utf-8');
48
+ expect(content).toContain("'main'");
49
+ expect(result.appTypes).toBe(dtsPath);
50
+ expect(result.assetTypes).toBeNull();
51
+ });
52
+
53
+ it('throws when caper() is not present in vite.config', async () => {
54
+ fs.writeFileSync(path.join(root, 'vite.config.ts'), 'export default { plugins: [] };', 'utf-8');
55
+
56
+ await expect(generateTypes(root)).rejects.toThrow("caper() is not in this project's vite.config");
57
+ });
58
+ });
package/cli.mjs CHANGED
@@ -5,10 +5,13 @@ import { bgRed, bold, green, red, white } from 'kleur/colors';
5
5
  import fs from 'node:fs';
6
6
  import process from 'node:process';
7
7
  import { add } from './cli/add.mjs';
8
+ import { agent } from './cli/agent.mjs';
8
9
  import { generateCaptions } from './cli/audio/cc.mjs';
9
10
  import { compress } from './cli/audio/index.mjs';
10
11
  import { create } from './cli/create.mjs';
12
+ import { doctor } from './cli/doctor.mjs';
11
13
  import { installPeerDeps } from './cli/install-peerdeps.mjs';
14
+ import { types } from './cli/types.mjs';
12
15
  import { update } from './cli/update.mjs';
13
16
  import { generateVoiceoverCSV } from './cli/voiceover/index.mjs';
14
17
 
@@ -64,6 +67,15 @@ switch (args[0]) {
64
67
  case 'add':
65
68
  await add(args.slice(1));
66
69
  break;
70
+ case 'agent':
71
+ await agent(args.slice(1));
72
+ break;
73
+ case 'types':
74
+ await types(args.slice(1));
75
+ break;
76
+ case 'doctor':
77
+ await doctor(args.slice(1));
78
+ break;
67
79
  case 'create': {
68
80
  let packageManager = 'npm'; // Default to npm
69
81
  let projectPath = '.'; // Default to current directory