@caperjs/core 0.5.2 → 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/build/assetpack.mjs +6 -0
- package/build/plugins/assetTypes.mjs +7 -2
- package/build/plugins/caperConfig.mjs +3 -0
- package/cli/agent.mjs +10 -4
- package/cli/doctor.mjs +193 -0
- package/cli/doctor.test.mjs +142 -0
- package/cli/probe.mjs +368 -0
- package/cli/probe.test.mjs +74 -0
- package/cli/types.mjs +101 -0
- package/cli/types.test.mjs +58 -0
- package/cli.mjs +8 -0
- package/extras/llms.txt +21 -7
- package/extras/skills/caper/SKILL.md +14 -10
- package/lib/caper.mjs +1 -1
- package/lib/caper.mjs.map +1 -1
- package/package.json +1 -1
- package/src/version.ts +1 -1
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
|
@@ -9,7 +9,9 @@ import { agent } from './cli/agent.mjs';
|
|
|
9
9
|
import { generateCaptions } from './cli/audio/cc.mjs';
|
|
10
10
|
import { compress } from './cli/audio/index.mjs';
|
|
11
11
|
import { create } from './cli/create.mjs';
|
|
12
|
+
import { doctor } from './cli/doctor.mjs';
|
|
12
13
|
import { installPeerDeps } from './cli/install-peerdeps.mjs';
|
|
14
|
+
import { types } from './cli/types.mjs';
|
|
13
15
|
import { update } from './cli/update.mjs';
|
|
14
16
|
import { generateVoiceoverCSV } from './cli/voiceover/index.mjs';
|
|
15
17
|
|
|
@@ -68,6 +70,12 @@ switch (args[0]) {
|
|
|
68
70
|
case 'agent':
|
|
69
71
|
await agent(args.slice(1));
|
|
70
72
|
break;
|
|
73
|
+
case 'types':
|
|
74
|
+
await types(args.slice(1));
|
|
75
|
+
break;
|
|
76
|
+
case 'doctor':
|
|
77
|
+
await doctor(args.slice(1));
|
|
78
|
+
break;
|
|
71
79
|
case 'create': {
|
|
72
80
|
let packageManager = 'npm'; // Default to npm
|
|
73
81
|
let projectPath = '.'; // Default to current directory
|
package/extras/llms.txt
CHANGED
|
@@ -214,6 +214,10 @@ subcommands run from the app root.
|
|
|
214
214
|
| `vite` | Start the dev server. Caper is wired in through `caper()` in the app's own `vite.config.ts`. |
|
|
215
215
|
| `vite build` | Production build. |
|
|
216
216
|
| `vite build && vite preview` | Build then preview locally. |
|
|
217
|
+
| `caper types [--no-assets]` | Regenerate `src/types/caper-app.d.ts` + `caper-assets.d.ts` **without a dev server** (runs AssetPack once unless `--no-assets`). Use after a fresh clone or a rename. |
|
|
218
|
+
| `caper doctor [--offline] [--json]` | One-screen health check: installed vs latest caper, npm vs linked engine (and whether the linked build is stale), generated types present/fresh, asset manifest, agent pointers, peer deps, caches. Exit 1 on any ✗. |
|
|
219
|
+
| `caper agent init [--dir <skillsDir>]` | Install the shipped `caper` agent skill (default `.claude/skills/caper/SKILL.md`) and upsert a pointer block into `AGENTS.md`/`CLAUDE.md`. Re-run after upgrading caper. |
|
|
220
|
+
| `caper agent probe <url> [--action n[=json]]… [--until "<js predicate>"] [--wait ms] [--screenshot p] [--headed] [--json]` | Drive a **running** app headlessly through the automation bridge (§17) in a Chromium you own: boots, sends actions, waits on state, returns context/state/log/errors (+ screenshot). Needs `playwright` in the app's devDependencies. |
|
|
217
221
|
| `caper update` | Update Caper to latest. |
|
|
218
222
|
| `caper install` | Install peer deps (rarely needed; normally automatic). |
|
|
219
223
|
| `caper vo generate [inputDir] [outputDir]` | Voiceover CSV generation from locales. Defaults: `./src/locales` → `./src/assets/audio/vo/csv`. |
|
|
@@ -1283,9 +1287,9 @@ implementation of the auto-wiring. Know they exist for debugging.
|
|
|
1283
1287
|
|
|
1284
1288
|
### 14.4 Regenerating
|
|
1285
1289
|
|
|
1286
|
-
Types regen on dev-server restart
|
|
1287
|
-
|
|
1288
|
-
|
|
1290
|
+
Types regen on dev-server restart, or on demand with `npx caper types`
|
|
1291
|
+
(no server; runs AssetPack once unless `--no-assets`). After renaming a scene /
|
|
1292
|
+
plugin / popup / entity, or on a fresh clone, run it before typechecking.
|
|
1289
1293
|
|
|
1290
1294
|
**Source references:** [build/plugins/assetTypes.mjs](../build/plugins/assetTypes.mjs).
|
|
1291
1295
|
|
|
@@ -1506,10 +1510,11 @@ Do not start the dev server yourself — it runs forever and blocks.
|
|
|
1506
1510
|
|
|
1507
1511
|
### 16.3 Regenerate types after renames
|
|
1508
1512
|
|
|
1509
|
-
After renaming a scene/plugin/popup/entity
|
|
1510
|
-
`src/types/caper-app.d.ts` and `
|
|
1511
|
-
|
|
1512
|
-
downstream type errors.
|
|
1513
|
+
After renaming a scene/plugin/popup/entity run `npx caper types` (regenerates
|
|
1514
|
+
`src/types/caper-app.d.ts` and `caper-assets.d.ts` from scratch, no dev server
|
|
1515
|
+
needed). Same fix for a fresh clone where the generated files are gitignored. Old
|
|
1516
|
+
IDs stuck in the union cause weird downstream type errors. `npx caper doctor`
|
|
1517
|
+
tells you when they are stale.
|
|
1513
1518
|
|
|
1514
1519
|
### 16.4 Fail loud in plugins (see §1.2)
|
|
1515
1520
|
|
|
@@ -1592,6 +1597,15 @@ Use a browser you own. Launch a separate Chromium with Playwright (isolated,
|
|
|
1592
1597
|
throwaway profile) and point it at the dev server; do **not** attach to or drive the
|
|
1593
1598
|
human's own browser session unless they ask you to look at one of their tabs.
|
|
1594
1599
|
|
|
1600
|
+
The shipped driver does the common case with no code:
|
|
1601
|
+
|
|
1602
|
+
```bash
|
|
1603
|
+
npx caper agent probe http://localhost:3000/ --action fire --until "s => s && s.phase === 'resolved'" --screenshot scratch/after-fire.png --json
|
|
1604
|
+
# → { appId, automation, context, state, actions, log, pageErrors, consoleErrors, screenshot, durationMs }; exit 1 on boot/until timeout or page errors
|
|
1605
|
+
```
|
|
1606
|
+
|
|
1607
|
+
Write your own only when you need more than actions + one predicate:
|
|
1608
|
+
|
|
1595
1609
|
```ts
|
|
1596
1610
|
import { chromium } from 'playwright';
|
|
1597
1611
|
const browser = await chromium.launch(); // headless; { headless: false } only when a human watches
|