@ttmg/cli 0.4.6-vibe-beta.7 → 0.4.6-vibe-beta.8

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.
@@ -0,0 +1,260 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { spawn } = require('node:child_process');
6
+
7
+ const entry = process.argv[2] ? path.resolve(process.argv[2]) : path.resolve(__dirname, '../dist/index.js');
8
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ttmg-workflow-process-'));
9
+ const game = path.join(root, 'game');
10
+ fs.mkdirSync(game);
11
+ fs.writeFileSync(path.join(game, 'game.js'), 'console.log("round A");');
12
+ fs.writeFileSync(path.join(game, 'game.json'), '{}');
13
+ // Restarted projects contain previous evidence/builds; startup must exclude it.
14
+ fs.mkdirSync(path.join(game, '.ttmg', 'previous-build'), { recursive: true });
15
+ fs.writeFileSync(path.join(game, '.ttmg', 'previous-build', 'game.js'), 'throw new Error("old output must not be compiled");');
16
+ const configPath = path.join(root, 'ttmgrc.json');
17
+ const initialConfig = JSON.stringify({ clientKey: 'unchanged', lang: 'en-US' });
18
+ fs.writeFileSync(configPath, initialConfig);
19
+ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
20
+ function run(args, directory = game) {
21
+ return new Promise((resolve, reject) => {
22
+ const child = spawn(process.execPath, [entry, ...args], {
23
+ cwd: directory,
24
+ env: {
25
+ ...process.env,
26
+ NO_COLOR: '1',
27
+ FORCE_COLOR: '0',
28
+ TTMG_DEV_NO_OPEN: '1',
29
+ TTMGRC_PATH: configPath,
30
+ },
31
+ stdio: ['ignore', 'pipe', 'pipe'],
32
+ });
33
+ let stdout = '',
34
+ stderr = '';
35
+ const timer = setTimeout(() => {
36
+ child.kill('SIGKILL');
37
+ reject(new Error('CLI timeout'));
38
+ }, 75000);
39
+ child.stdout.on('data', data => {
40
+ stdout += data;
41
+ });
42
+ child.stderr.on('data', data => {
43
+ stderr += data;
44
+ });
45
+ child.once('error', reject);
46
+ child.once('close', code => {
47
+ clearTimeout(timer);
48
+ try {
49
+ const lines = stdout.trim().split(/\r?\n/);
50
+ assert.equal(
51
+ lines.length,
52
+ 1,
53
+ 'structured stdout must contain one object: ' + stderr,
54
+ );
55
+ resolve({ code, result: JSON.parse(lines[0]) });
56
+ } catch (error) {
57
+ reject(error);
58
+ }
59
+ });
60
+ });
61
+ }
62
+ let owner;
63
+ let secondOwner;
64
+ async function main() {
65
+ try {
66
+ const start = [
67
+ 'dev',
68
+ '--mode',
69
+ 'client-preview',
70
+ '--update-mode',
71
+ 'manual',
72
+ '--background',
73
+ '--client-key',
74
+ 'smoke-client',
75
+ '--record-logs',
76
+ '--timeout',
77
+ '90',
78
+ '--format',
79
+ 'json',
80
+ ];
81
+ const first = await run(start);
82
+ assert.equal(first.code, 0, JSON.stringify(first.result));
83
+ owner = first.result;
84
+ assert.equal(owner.updateMode, 'manual');
85
+ assert.equal(owner.reused, false);
86
+ assert.equal(fs.existsSync(owner.launch.qrPngPath), true);
87
+ assert.ok(owner.evidence.runtimeLogs);
88
+ assert.equal(fs.existsSync(path.join(owner.evidence.directory, 'build-output', '.ttmg')), false);
89
+ assert.equal(fs.existsSync(path.join(owner.evidence.directory, 'build-output', '__GAME__', '.ttmg')), false);
90
+ assert.ok(
91
+ fs.existsSync(path.join(owner.evidence.directory, 'build-output')),
92
+ );
93
+ const reused = await run(start);
94
+ assert.equal(reused.result.sessionId, owner.sessionId);
95
+ assert.equal(reused.result.reused, true);
96
+ const otherGame = path.join(root, 'other-game');
97
+ fs.mkdirSync(otherGame);
98
+ fs.writeFileSync(path.join(otherGame, 'game.js'), 'console.log("independent game");');
99
+ fs.writeFileSync(path.join(otherGame, 'game.json'), '{}');
100
+ const second = await run(start, otherGame);
101
+ assert.equal(second.code, 0, JSON.stringify(second.result));
102
+ secondOwner = second.result;
103
+ assert.notEqual(secondOwner.evidence.directory, owner.evidence.directory);
104
+ assert.ok(fs.existsSync(path.join(secondOwner.evidence.directory, 'build-output')));
105
+ fs.writeFileSync(configPath, JSON.stringify({ clientKey: 'different-global-key', lang: 'en-US' }));
106
+ for (const preview of [owner, secondOwner]) {
107
+ const response = await fetch(new URL('/game/config', preview.endpoints.page));
108
+ const config = await response.json();
109
+ assert.equal(config.data.clientKey, 'smoke-client');
110
+ }
111
+ fs.writeFileSync(configPath, initialConfig);
112
+ // Saving files alone must not start an intermediate build in manual mode.
113
+ const eventCount = fs
114
+ .readFileSync(owner.evidence.events, 'utf8')
115
+ .split('\n')
116
+ .filter(Boolean).length;
117
+ fs.writeFileSync(path.join(game, 'game.js'), 'console.log("round B");');
118
+ await pause(3500);
119
+ assert.equal(
120
+ fs.readFileSync(owner.evidence.events, 'utf8').split('\n').filter(Boolean)
121
+ .length,
122
+ eventCount,
123
+ );
124
+ const sync = await run([
125
+ 'dev',
126
+ 'sync',
127
+ '--request-id',
128
+ 'round-1',
129
+ '--format',
130
+ 'json',
131
+ ]);
132
+ assert.equal(sync.code, 0, JSON.stringify(sync.result));
133
+ assert.equal(sync.result.iterationId, 'round-1');
134
+ const observed = await run([
135
+ 'dev',
136
+ 'observe',
137
+ '--iteration',
138
+ 'round-1',
139
+ '--timeout',
140
+ '30',
141
+ '--format',
142
+ 'json',
143
+ ]);
144
+ assert.equal(
145
+ observed.result.status,
146
+ 'needs_action',
147
+ JSON.stringify(observed.result),
148
+ );
149
+ assert.equal(observed.result.nextAction, 'connect_device');
150
+ assert.equal(observed.result.runtimeBuildVerified, false);
151
+ assert.equal(observed.result.launch.qrPngPath, owner.launch.qrPngPath);
152
+ assert.ok(observed.result.sourceDigest);
153
+ fs.writeFileSync(path.join(game, 'game.js'), 'console.log("round C");');
154
+ const retry = await run([
155
+ 'dev',
156
+ 'sync',
157
+ '--request-id',
158
+ 'round-1',
159
+ '--format',
160
+ 'json',
161
+ ]);
162
+ assert.equal(retry.result.buildId, sync.result.buildId);
163
+ const next = await run([
164
+ 'dev',
165
+ 'sync',
166
+ '--request-id',
167
+ 'round-2',
168
+ '--format',
169
+ 'json',
170
+ ]);
171
+ const nextResult = await run([
172
+ 'dev',
173
+ 'observe',
174
+ '--iteration',
175
+ 'round-2',
176
+ '--timeout',
177
+ '30',
178
+ '--format',
179
+ 'json',
180
+ ]);
181
+ assert.equal(
182
+ nextResult.result.status,
183
+ 'needs_action',
184
+ JSON.stringify(nextResult.result),
185
+ );
186
+ assert.notEqual(next.result.buildId, sync.result.buildId);
187
+ assert.notEqual(
188
+ nextResult.result.sourceDigest,
189
+ observed.result.sourceDigest,
190
+ );
191
+ const stale = await run([
192
+ 'dev',
193
+ 'observe',
194
+ '--iteration',
195
+ 'round-1',
196
+ '--format',
197
+ 'json',
198
+ ]);
199
+ assert.equal(stale.result.errorCode, 'DEV_ITERATION_SUPERSEDED');
200
+ assert.equal(stale.code, 1);
201
+ fs.writeFileSync(path.join(game, 'game.js'), 'function {');
202
+ await run(['dev', 'sync', '--request-id', 'broken', '--format', 'json']);
203
+ const failed = await run([
204
+ 'dev',
205
+ 'observe',
206
+ '--iteration',
207
+ 'broken',
208
+ '--timeout',
209
+ '30',
210
+ '--format',
211
+ 'json',
212
+ ]);
213
+ assert.equal(failed.result.status, 'failed', JSON.stringify(failed.result));
214
+ fs.writeFileSync(path.join(game, 'game.js'), 'console.log("recovered");');
215
+ await run(['dev', 'sync', '--request-id', 'recovered', '--format', 'json']);
216
+ const recovery = await run([
217
+ 'dev',
218
+ 'observe',
219
+ '--iteration',
220
+ 'recovered',
221
+ '--timeout',
222
+ '30',
223
+ '--format',
224
+ 'json',
225
+ ]);
226
+ assert.equal(
227
+ recovery.result.status,
228
+ 'needs_action',
229
+ JSON.stringify(recovery.result),
230
+ );
231
+ assert.equal(
232
+ fs.existsSync(path.join(game, '.ttmg', 'preview-start.lock')),
233
+ false,
234
+ );
235
+ assert.equal(fs.readFileSync(configPath, 'utf8'), initialConfig);
236
+ console.log(
237
+ 'manual preview process smoke passed: start/reuse, two rounds, stale rejection, failure/recovery; no device connected',
238
+ );
239
+ } finally {
240
+ for (const preview of [owner, secondOwner].filter(Boolean)) {
241
+ const stopped = await run([
242
+ 'dev',
243
+ 'session',
244
+ 'stop',
245
+ preview.sessionId,
246
+ '--sessions-dir',
247
+ preview.evidence.directory,
248
+ '--format',
249
+ 'json',
250
+ ]);
251
+ assert.equal(stopped.code, 0, JSON.stringify(stopped.result));
252
+ assert.equal(fs.existsSync(preview.evidence.summary), true);
253
+ }
254
+ fs.rmSync(root, { recursive: true, force: true });
255
+ }
256
+ }
257
+ main().catch(error => {
258
+ console.error(error);
259
+ process.exitCode = 1;
260
+ });
@@ -0,0 +1,65 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const crypto = require('node:crypto');
4
+
5
+ function prepareFixture(output) {
6
+ if (!output) throw new Error('Provide a new output directory.');
7
+ const root = path.resolve(output);
8
+ fs.mkdirSync(root, { recursive: false });
9
+ const runId = crypto.randomUUID();
10
+ try {
11
+ for (const round of ['A', 'B']) {
12
+ const directory = path.join(root, `round-${round.toLowerCase()}`);
13
+ fs.mkdirSync(directory);
14
+ const marker = `TTMG_ACCEPTANCE:${runId}:${round}`;
15
+ const code = `// Generated acceptance fixture. This is not a commercial game.
16
+ const marker = ${JSON.stringify(marker)};
17
+ const canvas = tt.createCanvas();
18
+ const info = tt.getSystemInfoSync();
19
+ canvas.width = info.windowWidth;
20
+ canvas.height = info.windowHeight;
21
+ const context = canvas.getContext('2d');
22
+ function render() {
23
+ context.fillStyle = ${JSON.stringify(round === 'A' ? '#175cd3' : '#067647')};
24
+ context.fillRect(0, 0, canvas.width, canvas.height);
25
+ context.fillStyle = '#ffffff';
26
+ context.font = 'bold 36px sans-serif';
27
+ context.fillText('ROUND ${round}', 20, 70);
28
+ context.font = '16px monospace';
29
+ context.fillText(${JSON.stringify(runId.slice(0, 18))}, 20, 110);
30
+ context.fillText(${JSON.stringify(runId.slice(18))}, 20, 138);
31
+ console.info(marker + ':info');
32
+ console.warn(marker + ':warn');
33
+ console.error(marker + ':error:EXPECTED_FIXTURE_LOG');
34
+ }
35
+ render();
36
+ tt.onShow(render);
37
+ `;
38
+ fs.writeFileSync(path.join(directory, 'game.js'), code);
39
+ fs.writeFileSync(path.join(directory, 'game.json'), '{}\n');
40
+ }
41
+ fs.cpSync(path.join(root, 'round-a'), path.join(root, 'project'), { recursive: true });
42
+ const result = {
43
+ schemaVersion: '1', status: 'prepared', runId, project: path.join(root, 'project'),
44
+ rounds: ['A', 'B'].map(round => ({ round, source: path.join(root, `round-${round.toLowerCase()}`),
45
+ marker: `TTMG_ACCEPTANCE:${runId}:${round}`, severities: ['info', 'warn', 'error'],
46
+ expectedScreen: `ROUND ${round} and the complete runId` })),
47
+ clientStarted: false, deviceVerified: false, commercialAcceptance: 'not_evaluated',
48
+ };
49
+ fs.writeFileSync(path.join(root, 'acceptance.json'), JSON.stringify(result, null, 2) + '\n');
50
+ return result;
51
+ } catch (error) {
52
+ fs.rmSync(root, { recursive: true, force: true });
53
+ throw error;
54
+ }
55
+ }
56
+ module.exports = { prepareFixture };
57
+ if (require.main === module) {
58
+ try {
59
+ if (process.argv.length !== 3) throw new Error('Usage: node prepare-client-preview-acceptance.js <new-directory>');
60
+ console.log(JSON.stringify(prepareFixture(process.argv[2])));
61
+ } catch (error) {
62
+ console.log(JSON.stringify({ status: 'failed', error: error.message, deviceVerified: false }));
63
+ process.exitCode = 1;
64
+ }
65
+ }
@@ -10,7 +10,7 @@ const packageJson = JSON.parse(
10
10
  fs.readFileSync(path.join(cliRoot, 'package.json'), 'utf8'),
11
11
  );
12
12
 
13
- function runCli(args, environment = {}) {
13
+ function runCli(args, environment = {}, cwd = cliRoot) {
14
14
  const env = {
15
15
  ...process.env,
16
16
  NO_COLOR: '1',
@@ -20,7 +20,7 @@ function runCli(args, environment = {}) {
20
20
  delete env.TTMG_LOGIN_EMAIL;
21
21
  delete env.TTMG_LOGIN_PASSWORD;
22
22
  return spawnSync(process.execPath, [cliEntry, ...args], {
23
- cwd: cliRoot,
23
+ cwd,
24
24
  env,
25
25
  encoding: 'utf8',
26
26
  });
@@ -34,6 +34,30 @@ function assertExit(result, expected, label) {
34
34
  );
35
35
  }
36
36
 
37
+ // Exercise the shipped bundle with an unavailable native binding, regardless
38
+ // of the host OS. This catches bundler-hoisted imports as well as source imports.
39
+ function runWithoutWasmBinding(args) {
40
+ const bootstrap = `
41
+ const Module = require('node:module');
42
+ const originalLoad = Module._load;
43
+ Module._load = function (request, ...rest) {
44
+ if (request === '@anrans001/ttmg-wasmtool') {
45
+ throw new Error('WASM_BINDING_MUST_NOT_LOAD_FOR_THIS_COMMAND');
46
+ }
47
+ return originalLoad.call(this, request, ...rest);
48
+ };
49
+ const [entry, ...args] = process.argv.slice(1);
50
+ process.argv = [process.execPath, entry, ...args];
51
+ require(entry);
52
+ `;
53
+ return spawnSync(process.execPath, ['-e', bootstrap, cliEntry, ...args], {
54
+ cwd: cliRoot,
55
+ env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' },
56
+ encoding: 'utf8',
57
+ timeout: 10000,
58
+ });
59
+ }
60
+
37
61
  function parseSingleJson(result, label) {
38
62
  const output = result.stdout.trim();
39
63
  assert.ok(output, `${label} did not write a JSON result to stdout`);
@@ -70,6 +94,23 @@ function assertNoSecretKeys(value, label) {
70
94
  }
71
95
 
72
96
  assert.ok(fs.existsSync(cliEntry), 'dist/index.js is missing; run build first');
97
+ assertExit(runWithoutWasmBinding(['--help']), 0, 'help without Wasm binding');
98
+ const withoutWasmCapabilities = runWithoutWasmBinding([
99
+ 'capabilities', '--format', 'json',
100
+ ]);
101
+ assertExit(withoutWasmCapabilities, 0, 'capabilities without Wasm binding');
102
+ assert.equal(
103
+ parseSingleJson(withoutWasmCapabilities, 'capabilities without Wasm binding').command,
104
+ 'capabilities',
105
+ );
106
+ const withoutWasmBuild = runWithoutWasmBinding([
107
+ 'build', '--dir', __filename, '--format', 'json',
108
+ ]);
109
+ assertExit(withoutWasmBuild, 1, 'build validation without Wasm binding');
110
+ assert.equal(
111
+ parseSingleJson(withoutWasmBuild, 'build validation without Wasm binding').errorCode,
112
+ 'BUILD_DIRECTORY_NOT_FOUND',
113
+ );
73
114
  assert.match(
74
115
  packageJson.scripts.prepack,
75
116
  /verify:dist-version/,
@@ -104,6 +145,20 @@ assert.equal(
104
145
  );
105
146
  assert.equal(capabilities.commands.dev.outputModesMutuallyExclusive, true);
106
147
  assert.equal(capabilities.commands.dev.clientPreview.available, true);
148
+ assert.equal(capabilities.commands.dev.clientPreview.textOutput, true);
149
+ assert.equal(
150
+ capabilities.commands.dev.clientPreview.scanPage.urlField,
151
+ 'launch.scanPageUrl',
152
+ );
153
+ assert.equal(
154
+ capabilities.commands.dev.clientPreview.scanPage.structuredModeAutoOpen,
155
+ false,
156
+ );
157
+ assert.equal(capabilities.commands.dev.sessionLogs.shortcut, 'ttmg dev logs');
158
+ assert.equal(
159
+ capabilities.commands.dev.sessionScreenshot.shortcut,
160
+ 'ttmg dev screenshot',
161
+ );
107
162
  assert.equal(capabilities.commands.dev.clientPreview.protocolVersion, '1.0');
108
163
  assert.equal(capabilities.commands.dev.clientPreview.humanScanRequired, true);
109
164
  assert.equal(capabilities.commands.dev.clientPreview.deviceLaunch, false);
@@ -188,15 +243,133 @@ assert.match(
188
243
  const devHelp = runCli(['dev', '--help']);
189
244
  assertExit(devHelp, 0, 'dev help');
190
245
  assert.match(devHelp.stdout, /--mode <mode>/);
246
+ assert.match(devHelp.stdout, /--record-logs\b/);
247
+ for (const shortcut of ['logs', 'screenshot']) {
248
+ const help = runCli(['dev', shortcut, '--help']);
249
+ assertExit(help, 0, `${shortcut} shortcut help`);
250
+ assert.match(help.stdout, /--session <sessionId>/);
251
+ assert.match(help.stdout, /--sessions-dir <path>/);
252
+ assert.match(help.stdout, /--format <format>/);
253
+ }
191
254
 
192
255
  const logsHelp = runCli(['dev', 'session', 'logs', '--help']);
256
+ assert.equal(capabilities.commands.dev.iterationWorkflow.runtimeBuildVerified, false);
257
+ assert.match(runCli(['dev', '--help']).stdout, /--background\b/);
258
+ assert.match(runCli(['dev', '--help']).stdout, /--update-mode <mode>/);
259
+ for (const action of ['sync', 'observe']) {
260
+ const help = runCli(['dev', action, '--help']);
261
+ assertExit(help, 0, `dev ${action} help`);
262
+ const missing = runCli(['dev', action, '--format', 'json']);
263
+ assertExit(missing, 1, `dev ${action} missing identifier`);
264
+ assert.equal(parseSingleJson(missing, action).errorCode, 'DEV_ITERATION_INVALID_ID');
265
+ assert.notEqual(runCli(['dev', action, '--unknown-option']).status, 0);
266
+ }
267
+ const invalidBackground = runCli(['dev', '--background', '--format', 'json']);
268
+ assertExit(invalidBackground, 1, 'background mode validation');
269
+ assert.equal(parseSingleJson(invalidBackground, 'background').errorCode, 'DEV_ITERATION_INVALID_OPTIONS');
270
+ const invalidManual = runCli(['dev', '--update-mode', 'invalid', '--format', 'json']);
271
+ assertExit(invalidManual, 1, 'update mode validation');
272
+ assert.equal(parseSingleJson(invalidManual, 'manual').errorCode, 'DEV_ITERATION_INVALID_OPTIONS');
273
+ // Validation must happen before writing evidence or starting local services.
274
+ const unsafeEvidence = path.join(cliRoot, 'unsafe-evidence-' + process.pid);
275
+ const invalidEvidence = runCli(['dev', '--client-key', 'fixture', '--mode', 'client-preview', '--no-open', '--format', 'json', '--evidence-dir', unsafeEvidence]);
276
+ assertExit(invalidEvidence, 1, 'evidence directory isolation');
277
+ assert.equal(parseSingleJson(invalidEvidence, 'evidence directory').errorCode, 'DEV_EVIDENCE_DIRECTORY_UNSAFE');
278
+ assert.equal(fs.existsSync(unsafeEvidence), false);
279
+ assert.deepEqual(capabilities.commands.dev.sessionLogs.evidenceDirectoryLocations, ['outside-project', 'project-root-.ttmg']);
280
+ const packagingFixture = fs.mkdtempSync(path.join(os.tmpdir(), 'ttmg-installed-pack-gate-'));
281
+ try {
282
+ fs.writeFileSync(path.join(packagingFixture, 'game.js'), 'console.log("fixture");');
283
+ fs.writeFileSync(path.join(packagingFixture, 'game.json'), '{}');
284
+ fs.mkdirSync(path.join(packagingFixture, '.ttmg'));
285
+ fs.writeFileSync(path.join(packagingFixture, '.ttmg/logs.ndjson'), Buffer.alloc(5 * 1024 * 1024));
286
+ fs.writeFileSync(path.join(packagingFixture, '.ttmg/旧构建.js'), 'tt.login();');
287
+ const checked = runCli(['check', '--dir', packagingFixture, '--format', 'json']);
288
+ assertExit(checked, 0, 'installed packaging evidence exclusion');
289
+ const report = parseSingleJson(checked, 'installed packaging evidence exclusion');
290
+ assert.ok(!report.diagnostics.some(item => item.type === 'size' && !item.passed), 'installed ttmg-pack counted CLI evidence in package size');
291
+ assert.ok(report.diagnostics.some(item => item.name === 'login' && !item.passed), 'installed ttmg-pack scanned APIs from old debug builds');
292
+ } finally {
293
+ fs.rmSync(packagingFixture, { recursive: true, force: true });
294
+ }
295
+ if (process.platform !== 'win32') {
296
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ttmg-evidence-symlink-'));
297
+ try {
298
+ fs.mkdirSync(path.join(root, 'assets'));
299
+ fs.writeFileSync(path.join(root, 'game.js'), 'console.log("fixture");');
300
+ fs.symlinkSync(path.join(root, 'assets'), path.join(root, '.ttmg'));
301
+ for (const extra of [[], ['--background']]) {
302
+ const result = runCli(['dev', '--mode', 'client-preview', '--update-mode', 'manual', '--client-key', 'fixture', '--no-open', '--format', 'json', ...extra], {}, root);
303
+ assertExit(result, 1, 'symlinked evidence isolation');
304
+ assert.equal(parseSingleJson(result, 'symlinked evidence').errorCode, 'DEV_EVIDENCE_DIRECTORY_UNSAFE');
305
+ assert.deepEqual(fs.readdirSync(path.join(root, 'assets')), [], 'validation wrote into game assets');
306
+ }
307
+ } finally {
308
+ fs.rmSync(root, { recursive: true, force: true });
309
+ }
310
+ }
311
+ // Both formats perform the same local build validation; the former text placeholder is retired.
312
+ const missingBuildDirectory = path.join(os.tmpdir(), 'ttmg-build-dir-that-does-not-exist-' + process.pid);
313
+ for (const format of ['text', 'json']) {
314
+ const result = runCli(['build', '--dir', missingBuildDirectory, '--format', format]);
315
+ assertExit(result, 1, `build ${format} invalid directory`);
316
+ if (format === 'json') assert.equal(parseSingleJson(result, 'build').errorCode, 'BUILD_DIRECTORY_NOT_FOUND');
317
+ }
193
318
  assertExit(logsHelp, 0, 'dev session logs help');
194
319
  assert.match(logsHelp.stdout, /--follow\b/);
195
320
  assert.match(logsHelp.stdout, /--until <cursor>/);
321
+ assert.match(logsHelp.stdout, /--source <source>/);
322
+ assert.match(logsHelp.stdout, /--runtime-session <id>/);
323
+ assert.equal(
324
+ capabilities.commands.dev.sessionLogs.archive.defaultEnabled,
325
+ false,
326
+ );
327
+ assert.equal(capabilities.commands.dev.sessionLogs.nextQuery, true);
328
+ const invalidRecording = runCli([
329
+ 'dev',
330
+ '--record-logs',
331
+ '--format',
332
+ 'json',
333
+ '--no-open',
334
+ ]);
335
+ assertExit(invalidRecording, 1, 'log recording requires client-preview');
336
+ assert.equal(
337
+ parseSingleJson(invalidRecording, 'invalid log recording').errorCode,
338
+ 'DEV_LOG_INVALID_OPTIONS',
339
+ );
340
+ const invalidLogSource = runCli([
341
+ 'dev',
342
+ 'logs',
343
+ '--source',
344
+ 'unknown',
345
+ '--format',
346
+ 'json',
347
+ ]);
348
+ assertExit(invalidLogSource, 1, 'invalid log source');
349
+ assert.equal(
350
+ parseSingleJson(invalidLogSource, 'invalid log source').errorCode,
351
+ 'DEV_LOG_INVALID_OPTIONS',
352
+ );
196
353
 
197
354
  const screenshotHelp = runCli(['dev', 'session', 'screenshot', '--help']);
198
355
  assertExit(screenshotHelp, 0, 'dev session screenshot help');
199
356
  assert.match(screenshotHelp.stdout, /--image-format <format>/);
357
+ assert.match(screenshotHelp.stdout, /--runtime-session <id>/);
358
+ assert.match(
359
+ runCli(['dev', 'screenshot', '--help']).stdout,
360
+ /--runtime-session <id>/,
361
+ );
362
+ for (const command of [
363
+ ['dev', 'screenshot'],
364
+ ['dev', 'session', 'screenshot', 'missing-session'],
365
+ ]) {
366
+ const unknownOption = runCli([...command, '--runtime-sesion', 'unknown']);
367
+ assert.notEqual(
368
+ unknownOption.status,
369
+ 0,
370
+ 'unknown screenshot option must fail',
371
+ );
372
+ }
200
373
 
201
374
  const checkHelp = runCli(['check', '--help']);
202
375
  assertExit(checkHelp, 0, 'check help');
@@ -248,4 +421,28 @@ try {
248
421
  fs.rmSync(tempDirectory, { recursive: true, force: true });
249
422
  }
250
423
 
424
+ for (const operation of ['prepare', 'split']) {
425
+ assertExit(runWithoutWasmBinding(['wasm', operation, '--help']), 0, `wasm ${operation} help without binding`);
426
+ const invalid = runCli(['wasm', operation, '--format', 'json']);
427
+ assertExit(invalid, 1, `wasm ${operation} missing input`);
428
+ assert.equal(parseSingleJson(invalid, `wasm ${operation}`).errorCode, 'WASM_INVALID_OPTIONS');
429
+ }
430
+ const wasmSmoke = spawnSync(process.execPath, [path.join(cliRoot, 'scripts/wasm-smoke.js'), cliEntry], {
431
+ cwd: cliRoot, encoding: 'utf8', timeout: 60000,
432
+ });
433
+ assertExit(wasmSmoke, 0, 'Wasm built-artifact process smoke');
434
+ console.log(wasmSmoke.stdout.trim());
435
+ const captureHelp = runCli(['dev', 'capture', '--help']);
436
+ assertExit(captureHelp, 0, 'capture help');
437
+ assert.match(captureHelp.stdout, /--timeout/);
438
+ const invalidCapture = runCli(['dev', 'capture', '--timeout', '0', '--format', 'json']);
439
+ assertExit(invalidCapture, 1, 'capture invalid timeout');
440
+ assert.equal(parseSingleJson(invalidCapture, 'capture invalid timeout').errorCode, 'DEV_CAPTURE_INVALID_OPTIONS');
441
+ assertExit(runCli(['dev', 'capture', '--format', 'ndjson']), 1, 'capture invalid format inherited from dev');
442
+ assertExit(runCli(['dev', 'capture', '--unknown-option']), 1, 'capture unknown option');
443
+ const captureSmoke = spawnSync(process.execPath, [path.join(cliRoot, 'scripts/dev-capture-smoke.js'), cliEntry], {
444
+ cwd: cliRoot, encoding: 'utf8', timeout: 45000,
445
+ });
446
+ assertExit(captureSmoke, 0, 'capture built-artifact process smoke');
447
+ console.log(captureSmoke.stdout.trim());
251
448
  console.log('built CLI agent contract verified');
@@ -0,0 +1,11 @@
1
+ // Test-only browser boundary: never launch a real browser from process smoke.
2
+ const cp = require('node:child_process');
3
+ const fs = require('node:fs');
4
+ const original = cp.spawn;
5
+ cp.spawn = function(command, args, options) {
6
+ if (command === 'open' || /(?:xdg-open|powershell\.exe)$/i.test(command)) {
7
+ fs.appendFileSync(process.env.TTMG_AUTH_OPEN_TEST_RECEIPT, JSON.stringify({command, args}) + '\n');
8
+ return original(process.execPath, ['-e', 'process.exit(0)'], {stdio: 'ignore'});
9
+ }
10
+ return original.call(this, command, args, options);
11
+ };