@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.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttmg/cli",
3
- "version": "0.4.6-vibe-beta.7",
3
+ "version": "0.4.6-vibe-beta.8",
4
4
  "description": "TikTok Mini Game Command Line Tool",
5
5
  "license": "ISC",
6
6
  "bin": {
@@ -31,11 +31,12 @@
31
31
  "test:auth": "node scripts/run-ts-test.js src/commands/common/auth.test.ts",
32
32
  "test:capabilities": "node scripts/run-ts-test.js src/commands/common/capabilities.test.ts",
33
33
  "test:test-account": "node scripts/run-ts-test.js src/commands/common/testAccount.test.ts",
34
- "test:dev-session": "node scripts/run-ts-test.js src/commands/native/dev/agentSession.test.ts",
35
- "test:dev-session-command": "node scripts/run-ts-test.js src/commands/native/dev/session.test.ts",
34
+ "test:dev-session": "node scripts/run-ts-test.js src/commands/native/dev/agentSession.test.ts && npm run test:packaging-compatibility",
35
+ "test:packaging-compatibility": "node scripts/run-ts-test.js src/commands/native/dev/packagingCompatibility.test.ts",
36
+ "test:dev-session-command": "node scripts/run-ts-test.js src/commands/native/dev/session.test.ts && node scripts/run-ts-test.js src/commands/native/dev/projectSession.test.ts && node scripts/run-ts-test.js src/commands/native/dev/scanPage.test.ts && node scripts/run-ts-test.js src/commands/native/dev/runtime/logArchive.test.ts && node scripts/run-ts-test.js src/commands/native/dev/capture.test.ts",
36
37
  "test:dev-runtime-event": "node scripts/run-ts-test.js src/commands/native/dev/utils/scoket.test.ts",
37
38
  "test:dev-runtime-client": "node scripts/run-ts-test.js src/commands/native/dev/runtime/runtime.test.ts",
38
- "test:dev-session-control": "node scripts/run-ts-test.js src/commands/native/dev/runtime/sessionControl.test.ts",
39
+ "test:dev-session-control": "node scripts/run-ts-test.js src/commands/native/dev/runtime/sessionControl.test.ts && node scripts/run-ts-test.js src/commands/native/dev/runtime/previewWorkflow.test.ts",
39
40
  "test:dev-runtime-session-follow": "node scripts/run-ts-test.js src/commands/native/dev/runtime/runtimeSessionFollow.test.ts",
40
41
  "test:dev-client-preview-orchestrator": "node scripts/run-ts-test.js src/commands/native/dev/runtime/clientPreviewOrchestrator.test.ts",
41
42
  "test:dev-watch-change": "node scripts/run-ts-test.js src/commands/native/dev/utils/watchChange.test.ts",
@@ -46,9 +47,10 @@
46
47
  "test:check-ai": "node scripts/run-ts-test.js src/commands/native/check/ai.test.ts",
47
48
  "test:build-structured": "node scripts/run-ts-test.js src/commands/native/build/build.test.ts",
48
49
  "test:doctor": "node scripts/run-ts-test.js src/commands/native/doctor/doctor.test.ts",
49
- "test:agent-contract": "npm run test:login && npm run test:auth && npm run test:capabilities && npm run test:test-account && npm run test:upload-preview && npm run test:preview-page && npm run test:check && npm run test:check-ai && npm run test:build-structured && npm run test:doctor && npm run test:dev-session && npm run test:dev-session-command && npm run test:dev-runtime-event && npm run test:dev-runtime-client && npm run test:dev-session-control && npm run test:dev-runtime-session-follow && npm run test:dev-client-preview-orchestrator && npm run test:dev-watch-change && npm run test:network && npm run test:vibe && npx tsc --noEmit && npm run build && npm run test:vibe-process && npm run test:preview-page-process && npm run test:preview-page-detached && npm run verify:dist-version && npm run verify:agent-contract",
50
- "test:agent-contract:full": "npm run test:agent-contract && npm run test:dev-default-process && npm run test:dev-game-ready-process && npm run test:dev-client-preview-control-process",
51
- "publish:beta": "npm publish --registry=https://registry.npmjs.org/ --access public --tag beta",
50
+ "test:wasm": "node scripts/run-ts-test.js src/commands/native/wasm/wasm.test.ts && node scripts/acceptance-fixture.test.js",
51
+ "test:agent-contract": "npm run test:login && npm run test:auth && npm run test:capabilities && npm run test:test-account && npm run test:upload-preview && npm run test:preview-page && npm run test:check && npm run test:check-ai && npm run test:build-structured && npm run test:doctor && npm run test:wasm && npm run test:dev-session && npm run test:dev-session-command && npm run test:dev-runtime-event && npm run test:dev-runtime-client && npm run test:dev-session-control && npm run test:dev-runtime-session-follow && npm run test:dev-client-preview-orchestrator && npm run test:dev-watch-change && npm run test:network && npm run test:vibe && npx tsc --noEmit && npm run build && npm run test:vibe-process && npm run test:preview-page-process && npm run test:preview-page-detached && npm run verify:dist-version && npm run verify:agent-contract",
52
+ "test:agent-contract:full": "npm run test:agent-contract && npm run test:dev-default-process && npm run test:dev-game-ready-process && npm run test:dev-client-preview-control-process && node scripts/dev-workflow-smoke.js",
53
+ "publish:beta": "npm publish --registry=https://registry.npmjs.org/ --access public --tag vibe-beta",
52
54
  "setup": "node scripts/setup.js",
53
55
  "reset:local-state": "node scripts/resetLocalState.js",
54
56
  "dev:link": "npm run build && npm link",
@@ -60,7 +62,7 @@
60
62
  ],
61
63
  "author": "zhanghongyang.mocha",
62
64
  "dependencies": {
63
- "@anrans001/ttmg-wasmtool": "^0.3.2",
65
+ "@anrans001/ttmg-wasmtool": "^0.3.3",
64
66
  "acorn": "^8.15.0",
65
67
  "archiver": "^7.0.1",
66
68
  "axios": "^1.11.0",
@@ -93,7 +95,7 @@
93
95
  "qs": "6.12.1",
94
96
  "semver": "^7.7.2",
95
97
  "socks-proxy-agent": "^9.0.0",
96
- "ttmg-pack": "0.4.13-beta.2",
98
+ "ttmg-pack": "0.4.13",
97
99
  "ws": "^8.18.3"
98
100
  },
99
101
  "devDependencies": {
@@ -0,0 +1,33 @@
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 vm = require('node:vm');
6
+ const { test } = require('node:test');
7
+ const { prepareFixture } = require('./prepare-client-preview-acceptance');
8
+
9
+ test('two fixture rounds have unique correlated logs/screens and never start a client', t => {
10
+ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'ttmg-acceptance-test-'));
11
+ t.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
12
+ const root = path.join(temporary, 'run');
13
+ const result = prepareFixture(root);
14
+ assert.equal(result.deviceVerified, false);
15
+ assert.equal(result.clientStarted, false);
16
+ assert.throws(() => prepareFixture(root), /EEXIST/);
17
+ assert.notEqual(prepareFixture(path.join(temporary, 'other')).runId, result.runId);
18
+ for (const round of result.rounds) {
19
+ const logs = [], text = [];
20
+ const context = { fillRect() {}, fillText(value) { text.push(value); } };
21
+ vm.runInNewContext(fs.readFileSync(path.join(round.source, 'game.js'), 'utf8'), {
22
+ tt: { createCanvas: () => ({ getContext: () => context }),
23
+ getSystemInfoSync: () => ({ windowWidth: 360, windowHeight: 640 }), onShow() {} },
24
+ console: Object.fromEntries(['info', 'warn', 'error'].map(level => [level, value => logs.push([level, value])])),
25
+ });
26
+ assert.deepEqual(logs.map(x => x[0]), round.severities);
27
+ assert.ok(logs.every(x => x[1].startsWith(round.marker)));
28
+ assert.equal(text[0], 'ROUND ' + round.round);
29
+ assert.equal(text.slice(1).join(''), result.runId);
30
+ }
31
+ assert.equal(fs.readFileSync(path.join(result.project, 'game.js'), 'utf8'),
32
+ fs.readFileSync(path.join(root, 'round-a', 'game.js'), 'utf8'));
33
+ });
@@ -0,0 +1,76 @@
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 net = require('node:net');
6
+ const { spawn } = require('node:child_process');
7
+ const entry = process.argv[2] ? path.resolve(process.argv[2]) : path.resolve(__dirname, '../dist/index.js');
8
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(process.platform === 'win32' ? os.tmpdir() : '/tmp', 'ttmg-cap-')));
9
+ const id = 'dev_capture_test';
10
+ const directory = path.join(root, '.ttmg', 'sessions', id);
11
+ fs.mkdirSync(directory, { recursive: true });
12
+ fs.writeFileSync(path.join(root, 'game.json'), '{}');
13
+ const address = process.platform === 'win32'
14
+ ? { transport: 'named-pipe', path: `\\\\.\\pipe\\ttmg-${id}` }
15
+ : { transport: 'unix', path: path.join(directory, 'ipc') };
16
+ fs.writeFileSync(path.join(directory, 'manifest.json'), JSON.stringify({
17
+ schemaVersion: '1', sessionId: id, mode: 'client-preview', status: 'ready',
18
+ project: { directory: root, clientKey: 'fixture' }, process: { pid: process.pid },
19
+ control: address, runtime: { runtimeSessionId: 'runtime_fixture', sessionStartCursor: '0' },
20
+ }));
21
+ const rows = Array.from({ length: 1201 }, (_, i) => ({ sequence: String(i + 1), severity: 'info', message: `fixture ${i}` }));
22
+ const server = net.createServer(socket => {
23
+ let buffer = '';
24
+ socket.on('data', data => {
25
+ buffer += data;
26
+ if (!buffer.includes('\n')) return;
27
+ const request = JSON.parse(buffer.trim());
28
+ const params = request.params || {};
29
+ let result = { runtimeSessionId: 'runtime_fixture' };
30
+ if (request.operation === 'logs.snapshot') {
31
+ const until = params.until || '1201';
32
+ const eligible = rows.filter(x => BigInt(x.sequence) > BigInt(params.after || '0') && BigInt(x.sequence) <= BigInt(until));
33
+ const records = eligible.slice(0, params.limit);
34
+ result = { ...result, records, snapshotCursor: until, oldestCursor: '1',
35
+ nextCursor: records.length ? records[records.length - 1].sequence : until,
36
+ cursorExpired: false, hasMore: eligible.length > records.length };
37
+ } else if (request.operation === 'screenshot.capture') {
38
+ fs.writeFileSync(params.output, 'fixture screenshot');
39
+ result = { ...result, path: params.output, mime: 'image/jpeg', width: 10, height: 20, captureTime: '123' };
40
+ }
41
+ socket.end(JSON.stringify({ requestId: request.requestId, type: 'result', result }) + '\n');
42
+ });
43
+ });
44
+ function run(args) {
45
+ return new Promise((resolve, reject) => {
46
+ const child = spawn(process.execPath, [entry, ...args, '--format', 'json'], {
47
+ cwd: root, env: { ...process.env, TTMGRC_PATH: path.join(root, 'rc.json') }, stdio: ['ignore', 'pipe', 'pipe'],
48
+ });
49
+ let stdout = '', stderr = '';
50
+ const timer = setTimeout(() => child.kill('SIGKILL'), 15000);
51
+ child.stdout.on('data', x => { stdout += x; });child.stderr.on('data', x => { stderr += x; });
52
+ child.on('error', reject);
53
+ child.on('close', code => {
54
+ clearTimeout(timer);
55
+ try { assert.equal(code, 0, stderr + stdout);assert.equal(stdout.trim().split('\n').length, 1);resolve(JSON.parse(stdout)); }
56
+ catch (error) { reject(error); }
57
+ });
58
+ });
59
+ }
60
+ (async () => {
61
+ try {
62
+ await new Promise((resolve, reject) => { server.once('error', reject);server.listen(address.path, resolve); });
63
+ const legacy = await run(['dev', 'logs']);
64
+ assert.equal(legacy.records.length, 200);
65
+ assert.equal(legacy.cursor.hasMore, true);
66
+ const capture = await run(['dev', 'capture']);
67
+ assert.equal(capture.logs.records, 1201);assert.equal(capture.logs.complete, true);
68
+ assert.deepEqual(fs.readFileSync(capture.logs.path, 'utf8').trim().split('\n').map(JSON.parse), rows);
69
+ assert.equal(fs.existsSync(capture.screenshot.path), true);
70
+ assert.equal(capture.runtimeSessionId, 'runtime_fixture');
71
+ assert.equal(capture.platformWrite, false);
72
+ console.log('capture process passed: 1201 logs, screenshot path, session discovery, unchanged paged logs; local IPC fixture only');
73
+ } finally {
74
+ await new Promise(resolve => server.close(resolve));fs.rmSync(root, { recursive: true, force: true });
75
+ }
76
+ })().catch(error => { console.error(error);process.exitCode = 1; });
@@ -5,27 +5,67 @@ const os = require('node:os');
5
5
  const path = require('node:path');
6
6
  const { spawn } = require('node:child_process');
7
7
 
8
- function runCli(args, cwd) {
8
+ function runCli(args, cwd, inspectScanPage) {
9
9
  return new Promise((resolve, reject) => {
10
10
  const child = spawn(
11
11
  process.execPath,
12
12
  [path.resolve(__dirname, '../dist/index.js'), ...args],
13
13
  {
14
14
  cwd,
15
- env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' },
15
+ env: {
16
+ ...process.env,
17
+ NO_COLOR: '1',
18
+ FORCE_COLOR: '0',
19
+ TTMG_DEV_NO_OPEN: '1',
20
+ },
16
21
  stdio: ['ignore', 'pipe', 'pipe'],
17
22
  },
18
23
  );
19
24
  let stdout = '';
20
25
  let stderr = '';
26
+ let inspection = Promise.resolve();
27
+ let inspectionStarted = false;
28
+ let inspectionError;
29
+ const timer = setTimeout(() => child.kill('SIGKILL'), 20000);
21
30
  child.stdout.on('data', chunk => {
22
31
  stdout += chunk;
32
+ if (inspectScanPage && !inspectionStarted) {
33
+ for (const line of stdout.split(/\r?\n/)) {
34
+ let event;
35
+ try {
36
+ event = JSON.parse(line);
37
+ } catch {
38
+ continue;
39
+ }
40
+ if (event.payload?.state !== 'READY_FOR_SCAN') continue;
41
+ inspectionStarted = true;
42
+ inspection = inspectScanPage(event.payload).catch(error => {
43
+ inspectionError = error;
44
+ });
45
+ break;
46
+ }
47
+ }
23
48
  });
24
49
  child.stderr.on('data', chunk => {
25
50
  stderr += chunk;
26
51
  });
27
- child.once('error', reject);
28
- child.once('close', status => resolve({ status, stdout, stderr }));
52
+ child.once('error', error => {
53
+ clearTimeout(timer);
54
+ reject(error);
55
+ });
56
+ child.once('close', async status => {
57
+ clearTimeout(timer);
58
+ await inspection;
59
+ if (inspectionError) {
60
+ reject(inspectionError);
61
+ return;
62
+ }
63
+ if (inspectScanPage && !inspectionStarted) {
64
+ reject(new Error(`No live scan page: ${stderr || stdout}`));
65
+ return;
66
+ }
67
+ resolve({ status, stdout, stderr });
68
+ });
29
69
  });
30
70
  }
31
71
 
@@ -92,6 +132,15 @@ async function run() {
92
132
  buffer += chunk.toString();
93
133
  if (!buffer.includes('\n')) return;
94
134
  const request = JSON.parse(buffer.slice(0, buffer.indexOf('\n')));
135
+ if (request.operation === 'session.status') {
136
+ write(socket, {
137
+ requestId: request.requestId,
138
+ type: 'result',
139
+ result: { status: 'ready' },
140
+ });
141
+ socket.end();
142
+ return;
143
+ }
95
144
  if (request.operation === 'logs.snapshot') {
96
145
  write(socket, {
97
146
  requestId: request.requestId,
@@ -157,15 +206,31 @@ async function run() {
157
206
  return;
158
207
  }
159
208
  if (request.operation === 'screenshot.capture') {
209
+ if (
210
+ request.params.runtimeSessionId &&
211
+ request.params.runtimeSessionId !== 'runtime-control-smoke'
212
+ ) {
213
+ write(socket, {
214
+ requestId: request.requestId,
215
+ type: 'error',
216
+ errorCode: 'RUNTIME_SESSION_MISMATCH',
217
+ error: 'Runtime session changed.',
218
+ });
219
+ socket.end();
220
+ return;
221
+ }
160
222
  assert.equal(request.params.format, 'jpeg');
161
223
  assert.equal(request.params.quality, 0.8);
162
- const output = request.params.output;
224
+ const output =
225
+ request.params.output ||
226
+ path.join(screenshotsDirectory, 'default.jpeg');
163
227
  fs.writeFileSync(output, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
164
228
  write(socket, {
165
229
  requestId: request.requestId,
166
230
  type: 'result',
167
231
  result: {
168
232
  path: output,
233
+ runtimeSessionId: 'runtime-control-smoke',
169
234
  mime: 'image/jpeg',
170
235
  size: 4,
171
236
  width: 1,
@@ -190,10 +255,53 @@ async function run() {
190
255
  '--events',
191
256
  'ndjson',
192
257
  '--no-open',
258
+ '--record-logs',
193
259
  '--timeout',
194
- '1',
260
+ '3',
195
261
  ],
196
262
  previewGameDirectory,
263
+ async ready => {
264
+ const pageResponse = await fetch(ready.scanPageUrl, {
265
+ signal: AbortSignal.timeout(1500),
266
+ });
267
+ assert.equal(pageResponse.status, 200);
268
+ assert.match(
269
+ pageResponse.headers.get('content-security-policy'),
270
+ /frame-ancestors 'none'/,
271
+ );
272
+ assert.equal(
273
+ pageResponse.headers.get('access-control-allow-origin'),
274
+ null,
275
+ 'scan page must bypass game CORS middleware',
276
+ );
277
+ const html = await pageResponse.text();
278
+ assert.match(html, /id="status-title"/);
279
+ const status = await (
280
+ await fetch(`${ready.scanPageUrl}/status`)
281
+ ).json();
282
+ assert.equal(status.state, 'waiting');
283
+ assert.equal(status.compile, 'succeeded');
284
+ assert.equal(status.developerOptions.values.enableDomainCheck, true);
285
+ const editToken = html.match(/editToken="([a-f0-9]+)"/)[1];
286
+ const saved = await fetch(`${ready.scanPageUrl}/settings`, {
287
+ method: 'POST', headers: { 'Content-Type': 'application/json',
288
+ Origin: new URL(ready.scanPageUrl).origin, 'X-TTMG-Scan-Token': editToken },
289
+ body: JSON.stringify({ ...status.developerOptions.values, enableAdMock: true }),
290
+ });
291
+ assert.equal(saved.status, 200);
292
+ const savedOptions = (await saved.json()).developerOptions;
293
+ assert.equal(savedOptions.values.enableAdMock, true);
294
+ assert.equal(savedOptions.delivery, 'pending');
295
+ assert.deepEqual((await (await fetch(`${ready.scanPageUrl}/status`)).json()).developerOptions, savedOptions);
296
+ const png = Buffer.from(
297
+ await (await fetch(`${ready.scanPageUrl}/qr.png`)).arrayBuffer(),
298
+ );
299
+ assert.deepEqual(
300
+ png,
301
+ fs.readFileSync(ready.qrPngPath),
302
+ 'page and terminal PNG must use the same launch payload',
303
+ );
304
+ },
197
305
  );
198
306
  assert.equal(previewRun.status, 2, previewRun.stderr || previewRun.stdout);
199
307
  const previewEvents = previewRun.stdout
@@ -203,16 +311,49 @@ async function run() {
203
311
  .map(JSON.parse);
204
312
  const readyForScan = previewEvents.find(
205
313
  event =>
206
- event.type === 'lifecycle' &&
207
- event.payload?.state === 'READY_FOR_SCAN',
314
+ event.type === 'lifecycle' && event.payload?.state === 'READY_FOR_SCAN',
208
315
  );
209
316
  assert.ok(readyForScan, previewRun.stderr || previewRun.stdout);
210
317
  assert.equal(fs.existsSync(readyForScan.payload.qrPngPath), true);
318
+ await assert.rejects(
319
+ fetch(readyForScan.payload.scanPageUrl, {
320
+ signal: AbortSignal.timeout(1000),
321
+ }),
322
+ 'scan page stops with the CLI',
323
+ );
324
+ assert.match(
325
+ readyForScan.payload.scanPageUrl,
326
+ /^http:\/\/127\.0\.0\.1:\d+\/__ttmg\/client-preview\/[a-f0-9]+$/,
327
+ );
211
328
  const previewManifest = JSON.parse(
212
329
  fs.readFileSync(readyForScan.payload.manifest, 'utf8'),
213
330
  );
331
+ assert.ok(previewManifest.evidence.runtimeLogs);
332
+ const savedRead = await runCli(
333
+ [
334
+ 'dev',
335
+ 'logs',
336
+ '--source',
337
+ 'archive',
338
+ '--session',
339
+ previewManifest.sessionId,
340
+ '--format',
341
+ 'json',
342
+ ],
343
+ previewGameDirectory,
344
+ );
345
+ assert.equal(savedRead.status, 0, savedRead.stderr || savedRead.stdout);
346
+ const saved = JSON.parse(savedRead.stdout);
347
+ assert.equal(saved.source, 'archive');
348
+ assert.equal(saved.archive.state, 'closed');
349
+ assert.deepEqual(saved.records, []);
350
+ assert.equal(saved.runtimeSessionId, null);
351
+ assert.equal(saved.platformWrite, false);
214
352
  if (previewManifest.control?.transport === 'unix') {
215
- assert.equal(Buffer.byteLength(previewManifest.control.path) <= 100, true);
353
+ assert.equal(
354
+ Buffer.byteLength(previewManifest.control.path) <= 100,
355
+ true,
356
+ );
216
357
  }
217
358
  assert.match(previewRun.stderr, /QR image:/);
218
359
  assert.equal(
@@ -224,6 +365,39 @@ async function run() {
224
365
  'session evidence must not trigger an endless recompilation loop',
225
366
  );
226
367
 
368
+ const humanPreview = await runCli(
369
+ [
370
+ 'dev',
371
+ '--mode',
372
+ 'client-preview',
373
+ '--client-key',
374
+ 'smoke-client',
375
+ '--timeout',
376
+ '1',
377
+ ],
378
+ previewGameDirectory,
379
+ );
380
+ assert.equal(
381
+ humanPreview.status,
382
+ 2,
383
+ humanPreview.stderr || humanPreview.stdout,
384
+ );
385
+ assert.match(humanPreview.stdout, /Scan with TikTok|使用 TikTok 扫码/);
386
+ assert.match(humanPreview.stdout, /QR image:|二维码图片:/);
387
+ assert.match(humanPreview.stdout, /ttmg dev screenshot/);
388
+ assert.ok(!humanPreview.stdout.includes('"schemaVersion"'));
389
+ for (const id of fs.readdirSync(
390
+ path.join(previewGameDirectory, '.ttmg', 'sessions'),
391
+ )) {
392
+ if (id === previewManifest.sessionId) continue;
393
+ const base = path.join(previewGameDirectory, '.ttmg', 'sessions', id);
394
+ const unrecorded = JSON.parse(
395
+ fs.readFileSync(path.join(base, 'manifest.json'), 'utf8'),
396
+ );
397
+ assert.equal(unrecorded.evidence.runtimeLogs, undefined);
398
+ assert.equal(fs.existsSync(path.join(base, 'runtime-logs')), false);
399
+ }
400
+
227
401
  await new Promise((resolve, reject) => {
228
402
  server.once('error', reject);
229
403
  server.listen(control.path, resolve);
@@ -305,9 +479,119 @@ async function run() {
305
479
  assert.equal(screenshotLines.length, 1);
306
480
  const screenshot = JSON.parse(screenshotLines[0]);
307
481
  assert.equal(screenshot.path, screenshotOutput);
482
+ assert.equal(screenshot.runtimeSessionId, 'runtime-control-smoke');
308
483
  assert.equal(screenshot.mime, 'image/jpeg');
309
484
  assert.equal('data' in screenshot, false);
310
485
  assert.equal(fs.readFileSync(screenshotOutput).toString('hex'), 'ffd8ffd9');
486
+ for (const command of [
487
+ ['dev', 'screenshot', '--session', sessionId],
488
+ ['dev', 'session', 'screenshot', sessionId],
489
+ ]) {
490
+ const pinnedRun = await runCli(
491
+ [
492
+ ...command,
493
+ '--runtime-session',
494
+ 'runtime-control-smoke',
495
+ '--output',
496
+ path.join(root, `pinned-${command[1]}.jpeg`),
497
+ '--format',
498
+ 'json',
499
+ ],
500
+ gameDirectory,
501
+ );
502
+ assert.equal(pinnedRun.status, 0, pinnedRun.stderr || pinnedRun.stdout);
503
+ const lines = pinnedRun.stdout.trim().split(/\r?\n/);
504
+ assert.equal(lines.length, 1);
505
+ assert.equal(
506
+ JSON.parse(lines[0]).runtimeSessionId,
507
+ 'runtime-control-smoke',
508
+ );
509
+ const invalidOutput = path.join(root, `stale-${command[1]}.jpeg`);
510
+ const staleRun = await runCli(
511
+ [
512
+ ...command,
513
+ '--runtime-session',
514
+ 'old-runtime',
515
+ '--output',
516
+ invalidOutput,
517
+ '--format',
518
+ 'json',
519
+ ],
520
+ gameDirectory,
521
+ );
522
+ assert.equal(staleRun.status, 1);
523
+ assert.equal(
524
+ JSON.parse(staleRun.stdout).errorCode,
525
+ 'RUNTIME_SESSION_MISMATCH',
526
+ );
527
+ assert.equal(fs.existsSync(invalidOutput), false);
528
+ }
529
+
530
+ // Professional-developer shortcuts use the same IPC, without a session ID.
531
+ const subdirectory = path.join(gameDirectory, 'src');
532
+ fs.mkdirSync(subdirectory);
533
+ const humanLogs = await runCli(['dev', 'logs'], subdirectory);
534
+ assert.equal(humanLogs.status, 0, humanLogs.stderr || humanLogs.stdout);
535
+ assert.match(humanLogs.stdout, /\[INFO\] \[native_runtime\] snapshot/);
536
+ const jsonLogs = await runCli(
537
+ ['dev', 'logs', '--format', 'json'],
538
+ gameDirectory,
539
+ );
540
+ assert.equal(jsonLogs.status, 0, jsonLogs.stderr || jsonLogs.stdout);
541
+ assert.equal(
542
+ JSON.parse(jsonLogs.stdout).records[0].sequence,
543
+ '90071992547409930002',
544
+ );
545
+ const humanFollow = await runCli(
546
+ ['dev', 'logs', '--follow', '--timeout', '1'],
547
+ gameDirectory,
548
+ );
549
+ assert.equal(
550
+ humanFollow.status,
551
+ 0,
552
+ humanFollow.stderr || humanFollow.stdout,
553
+ );
554
+ assert.match(humanFollow.stdout, /\[ERROR\] \[structured_trace\] live/);
555
+ const aliasFollow = await runCli(
556
+ ['dev', 'logs', '--follow', '--timeout', '1', '--format', 'ndjson'],
557
+ gameDirectory,
558
+ );
559
+ assert.equal(
560
+ aliasFollow.status,
561
+ 0,
562
+ aliasFollow.stderr || aliasFollow.stdout,
563
+ );
564
+ assert.deepEqual(
565
+ aliasFollow.stdout
566
+ .trim()
567
+ .split(/\r?\n/)
568
+ .map(JSON.parse)
569
+ .map(frame => frame.type),
570
+ ['log_snapshot', 'log_batch', 'follow_end'],
571
+ );
572
+ const humanShot = await runCli(['dev', 'screenshot'], gameDirectory);
573
+ assert.equal(humanShot.status, 0, humanShot.stderr || humanShot.stdout);
574
+ assert.match(humanShot.stdout, /default\.jpeg/);
575
+ assert.ok(fs.existsSync(path.join(screenshotsDirectory, 'default.jpeg')));
576
+ const jsonShot = await runCli(
577
+ [
578
+ 'dev',
579
+ 'screenshot',
580
+ '--output',
581
+ path.join(root, 'alias.jpeg'),
582
+ '--format',
583
+ 'json',
584
+ ],
585
+ gameDirectory,
586
+ );
587
+ assert.equal(jsonShot.status, 0, jsonShot.stderr || jsonShot.stdout);
588
+ assert.equal(JSON.parse(jsonShot.stdout).mime, 'image/jpeg');
589
+ const noSession = await runCli(['dev', 'logs', '--format', 'json'], root);
590
+ assert.equal(noSession.status, 1);
591
+ assert.equal(
592
+ JSON.parse(noSession.stdout).errorCode,
593
+ 'DEV_SESSION_NOT_RUNNING',
594
+ );
311
595
  } finally {
312
596
  await new Promise(resolve => server.close(resolve));
313
597
  fs.rmSync(root, { recursive: true, force: true });
@@ -13,6 +13,10 @@ async function run() {
13
13
  fs.mkdirSync(gameDirectory);
14
14
  fs.writeFileSync(path.join(gameDirectory, 'game.js'), 'console.log("game");\n');
15
15
  fs.writeFileSync(path.join(gameDirectory, 'game.json'), '{}\n');
16
+ for (const directory of ['.ttmg', '.ttmg-assets', 'assets/.ttmg']) {
17
+ fs.mkdirSync(path.join(gameDirectory, directory), { recursive: true });
18
+ fs.writeFileSync(path.join(gameDirectory, directory, 'data.txt'), 'before');
19
+ }
16
20
 
17
21
  let output = '';
18
22
  const child = spawn(
@@ -49,7 +53,24 @@ async function run() {
49
53
  await delay(100);
50
54
  }
51
55
  assert.match(output, /Game resources compiled successfully|游戏资源编译成功/);
52
- assert.equal(fs.existsSync(path.join(gameDirectory, '.ttmg')), false);
56
+ // Ordinary dev must not create an Agent session. Existing evidence must
57
+ // not trigger reloads, while similarly named runtime assets still do.
58
+ assert.equal(fs.existsSync(path.join(gameDirectory, '.ttmg/sessions')), false);
59
+ const compileCount = () => (output.match(/Game resources compiled successfully|游戏资源编译成功/g) || []).length;
60
+ const initialCount = compileCount();
61
+ fs.writeFileSync(path.join(gameDirectory, '.ttmg/data.txt'), 'new log');
62
+ await delay(4500);
63
+ assert.equal(compileCount(), initialCount, 'session writes triggered a rebuild');
64
+ for (const file of ['.ttmg-assets/data.txt', 'assets/.ttmg/data.txt']) {
65
+ const before = compileCount();
66
+ fs.writeFileSync(path.join(gameDirectory, file), 'updated runtime asset');
67
+ const deadline = Date.now() + 15000;
68
+ while (compileCount() === before && Date.now() < deadline) {
69
+ if (child.exitCode !== null) throw new Error(`Default dev exited while watching ${file}:\n${output}`);
70
+ await delay(100);
71
+ }
72
+ assert.ok(compileCount() > before, `${file} did not trigger a rebuild`);
73
+ }
53
74
  } finally {
54
75
  if (child.exitCode === null) {
55
76
  child.kill('SIGTERM');