@ttmg/cli 0.4.6-agent-beta.1 → 0.4.6-agent-beta.3

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,13 +1,14 @@
1
1
  {
2
2
  "name": "@ttmg/cli",
3
- "version": "0.4.6-agent-beta.1",
3
+ "version": "0.4.6-agent-beta.3",
4
4
  "description": "TikTok Mini Game Command Line Tool",
5
5
  "license": "ISC",
6
6
  "bin": {
7
7
  "ttmg": "dist/index.js"
8
8
  },
9
9
  "files": [
10
- "dist"
10
+ "dist",
11
+ "vibe-upload.md"
11
12
  ],
12
13
  "publishConfig": {
13
14
  "access": "public",
@@ -15,7 +16,8 @@
15
16
  },
16
17
  "scripts": {
17
18
  "demo:vibe": "npm run build && node scripts/vibe-upload-demo.js",
18
- "test:vibe": "node scripts/run-ts-test.js src/commands/common/vibe/vibe.test.ts",
19
+ "test:game-categories": "node scripts/run-ts-test.js src/commands/common/game/categories.test.ts",
20
+ "test:vibe": "npm run test:game-categories && node scripts/run-ts-test.js src/commands/common/vibe/vibe.test.ts",
19
21
  "test:vibe-process": "node scripts/vibe-upload-smoke.js",
20
22
  "prepack": "npm run build && npm run verify:dist-version && npm run verify:agent-contract",
21
23
  "build": "node scripts/build.js",
@@ -30,7 +32,8 @@
30
32
  "test:auth": "node scripts/run-ts-test.js src/commands/common/auth.test.ts",
31
33
  "test:capabilities": "node scripts/run-ts-test.js src/commands/common/capabilities.test.ts",
32
34
  "test:test-account": "node scripts/run-ts-test.js src/commands/common/testAccount.test.ts",
33
- "test:dev-session": "node scripts/run-ts-test.js src/commands/native/dev/agentSession.test.ts",
35
+ "test:dev-session": "node scripts/run-ts-test.js src/commands/native/dev/agentSession.test.ts && npm run test:packaging-compatibility",
36
+ "test:packaging-compatibility": "node scripts/run-ts-test.js src/commands/native/dev/packagingCompatibility.test.ts",
34
37
  "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",
35
38
  "test:dev-runtime-event": "node scripts/run-ts-test.js src/commands/native/dev/utils/scoket.test.ts",
36
39
  "test:dev-runtime-client": "node scripts/run-ts-test.js src/commands/native/dev/runtime/runtime.test.ts",
@@ -45,7 +48,7 @@
45
48
  "test:check-ai": "node scripts/run-ts-test.js src/commands/native/check/ai.test.ts",
46
49
  "test:build-structured": "node scripts/run-ts-test.js src/commands/native/build/build.test.ts",
47
50
  "test:doctor": "node scripts/run-ts-test.js src/commands/native/doctor/doctor.test.ts",
48
- "test:wasm": "node scripts/run-ts-test.js src/commands/native/wasm/wasm.test.ts && node scripts/acceptance-fixture.test.js",
51
+ "test:wasm": "node scripts/run-ts-test.js src/commands/native/wasm/wasm.test.ts && node scripts/run-ts-test.js src/commands/native/wasm/collect.test.ts && node scripts/acceptance-fixture.test.js",
49
52
  "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",
50
53
  "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",
51
54
  "publish:beta": "npm publish --registry=https://registry.npmjs.org/ --access public --tag beta",
@@ -93,7 +96,7 @@
93
96
  "qs": "6.12.1",
94
97
  "semver": "^7.7.2",
95
98
  "socks-proxy-agent": "^9.0.0",
96
- "ttmg-pack": "0.4.13-beta.2",
99
+ "ttmg-pack": "0.4.13",
97
100
  "ws": "^8.18.3"
98
101
  },
99
102
  "devDependencies": {
@@ -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');
@@ -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
  });
@@ -126,6 +126,18 @@ const capabilitiesResult = runCli(['capabilities', '--format', 'json']);
126
126
  assertExit(capabilitiesResult, 0, 'capabilities');
127
127
  const capabilities = parseSingleJson(capabilitiesResult, 'capabilities');
128
128
  assert.equal(capabilities.command, 'capabilities');
129
+ const gameCategories = runCli(['game', 'categories', '--format', 'json']);
130
+ assertExit(gameCategories, 0, 'game categories');
131
+ const categoryCatalog = parseSingleJson(gameCategories, 'game categories');
132
+ assert.equal(categoryCatalog.platformWrite, false);
133
+ assert.equal(categoryCatalog.categoryCount, 12);
134
+ assert.equal(categoryCatalog.subcategoryCount, 114);
135
+ assert.equal(capabilities.commands.gameCategories.catalogVersion, categoryCatalog.catalogVersion);
136
+ assert.match(capabilities.commands.vibeUpload.command, /--vibe --web/);
137
+ assert.equal(capabilities.commands.vibeUpload.skillWorkflow.keepUploadProcessRunning, true);
138
+ assert.equal(capabilities.commands.vibeUpload.skillWorkflow.realUploadSuccess.simulation, false);
139
+ assert.equal(capabilities.commands.vibeUpload.appInfoFields.defaultedByCli.includes('privacy_policy'), true);
140
+ assert.equal(packageJson.files.includes('vibe-upload.md'), true);
129
141
  assert.equal(capabilities.structuredOutput.stdoutProtocolOnly, true);
130
142
  assert.equal(capabilities.commands.check.platformWrite, false);
131
143
  assert.equal(capabilities.commands.check.ai.option, '--ai');
@@ -270,6 +282,44 @@ assert.equal(parseSingleJson(invalidBackground, 'background').errorCode, 'DEV_IT
270
282
  const invalidManual = runCli(['dev', '--update-mode', 'invalid', '--format', 'json']);
271
283
  assertExit(invalidManual, 1, 'update mode validation');
272
284
  assert.equal(parseSingleJson(invalidManual, 'manual').errorCode, 'DEV_ITERATION_INVALID_OPTIONS');
285
+ // Validation must happen before writing evidence or starting local services.
286
+ const unsafeEvidence = path.join(cliRoot, 'unsafe-evidence-' + process.pid);
287
+ const invalidEvidence = runCli(['dev', '--client-key', 'fixture', '--mode', 'client-preview', '--no-open', '--format', 'json', '--evidence-dir', unsafeEvidence]);
288
+ assertExit(invalidEvidence, 1, 'evidence directory isolation');
289
+ assert.equal(parseSingleJson(invalidEvidence, 'evidence directory').errorCode, 'DEV_EVIDENCE_DIRECTORY_UNSAFE');
290
+ assert.equal(fs.existsSync(unsafeEvidence), false);
291
+ assert.deepEqual(capabilities.commands.dev.sessionLogs.evidenceDirectoryLocations, ['outside-project', 'project-root-.ttmg']);
292
+ const packagingFixture = fs.mkdtempSync(path.join(os.tmpdir(), 'ttmg-installed-pack-gate-'));
293
+ try {
294
+ fs.writeFileSync(path.join(packagingFixture, 'game.js'), 'console.log("fixture");');
295
+ fs.writeFileSync(path.join(packagingFixture, 'game.json'), '{}');
296
+ fs.mkdirSync(path.join(packagingFixture, '.ttmg'));
297
+ fs.writeFileSync(path.join(packagingFixture, '.ttmg/logs.ndjson'), Buffer.alloc(5 * 1024 * 1024));
298
+ fs.writeFileSync(path.join(packagingFixture, '.ttmg/旧构建.js'), 'tt.login();');
299
+ const checked = runCli(['check', '--dir', packagingFixture, '--format', 'json']);
300
+ assertExit(checked, 0, 'installed packaging evidence exclusion');
301
+ const report = parseSingleJson(checked, 'installed packaging evidence exclusion');
302
+ assert.ok(!report.diagnostics.some(item => item.type === 'size' && !item.passed), 'installed ttmg-pack counted CLI evidence in package size');
303
+ assert.ok(report.diagnostics.some(item => item.name === 'login' && !item.passed), 'installed ttmg-pack scanned APIs from old debug builds');
304
+ } finally {
305
+ fs.rmSync(packagingFixture, { recursive: true, force: true });
306
+ }
307
+ if (process.platform !== 'win32') {
308
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ttmg-evidence-symlink-'));
309
+ try {
310
+ fs.mkdirSync(path.join(root, 'assets'));
311
+ fs.writeFileSync(path.join(root, 'game.js'), 'console.log("fixture");');
312
+ fs.symlinkSync(path.join(root, 'assets'), path.join(root, '.ttmg'));
313
+ for (const extra of [[], ['--background']]) {
314
+ const result = runCli(['dev', '--mode', 'client-preview', '--update-mode', 'manual', '--client-key', 'fixture', '--no-open', '--format', 'json', ...extra], {}, root);
315
+ assertExit(result, 1, 'symlinked evidence isolation');
316
+ assert.equal(parseSingleJson(result, 'symlinked evidence').errorCode, 'DEV_EVIDENCE_DIRECTORY_UNSAFE');
317
+ assert.deepEqual(fs.readdirSync(path.join(root, 'assets')), [], 'validation wrote into game assets');
318
+ }
319
+ } finally {
320
+ fs.rmSync(root, { recursive: true, force: true });
321
+ }
322
+ }
273
323
  // Both formats perform the same local build validation; the former text placeholder is retired.
274
324
  const missingBuildDirectory = path.join(os.tmpdir(), 'ttmg-build-dir-that-does-not-exist-' + process.pid);
275
325
  for (const format of ['text', 'json']) {
@@ -347,7 +397,14 @@ assert.notEqual(
347
397
  );
348
398
 
349
399
  const uploadHelp = runCli(['upload', '--help']);
400
+ assert.doesNotMatch(uploadHelp.stdout, /--app-info/);
401
+ assert.match(uploadHelp.stdout.replace(/\s+/g, ' '), /only title and icon|只收集标题和头像/);
350
402
  assertExit(uploadHelp, 0, 'upload help');
403
+ assert.match(uploadHelp.stdout, /--vibe --web --dir/);
404
+ assert.match(uploadHelp.stdout, /authorizationOpenCommand/);
405
+ assert.match(uploadHelp.stdout, /simulation=false/);
406
+ assert.match(uploadHelp.stdout, /--category <category>/);
407
+ assert.match(uploadHelp.stdout, /--subcategory <subcategory>/);
351
408
  assert.match(uploadHelp.stdout, /--preview\b/);
352
409
  assert.match(uploadHelp.stdout, /--serve-preview-page\b/);
353
410
  assert.match(uploadHelp.stdout, /--no-open\b/);
@@ -394,6 +451,17 @@ const wasmSmoke = spawnSync(process.execPath, [path.join(cliRoot, 'scripts/wasm-
394
451
  });
395
452
  assertExit(wasmSmoke, 0, 'Wasm built-artifact process smoke');
396
453
  console.log(wasmSmoke.stdout.trim());
454
+ for (const operation of ['start', 'export']) {
455
+ assertExit(runWithoutWasmBinding(['wasm', 'collect', operation, '--help']), 0, `collect ${operation} help without binding`);
456
+ const invalid = runCli(['wasm', 'collect', operation, '--format', 'json']);
457
+ assertExit(invalid, 1, `collect ${operation} missing input`);
458
+ assert.equal(parseSingleJson(invalid, `collect ${operation}`).errorCode, 'WASM_COLLECT_INVALID_OPTIONS');
459
+ }
460
+ const collectSmoke = spawnSync(process.execPath, [path.join(cliRoot, 'scripts/wasm-collect-smoke.js')], {
461
+ cwd: cliRoot, encoding: 'utf8', timeout: 60000,
462
+ });
463
+ assertExit(collectSmoke, 0, 'Wasm collection built-artifact process smoke');
464
+ console.log(collectSmoke.stdout.trim());
397
465
  const captureHelp = runCli(['dev', 'capture', '--help']);
398
466
  assertExit(captureHelp, 0, 'capture help');
399
467
  assert.match(captureHelp.stdout, /--timeout/);
@@ -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
+ };
@@ -3,18 +3,32 @@
3
3
  const assert = require('node:assert/strict');
4
4
  const http = require('node:http');
5
5
  const https = require('node:https');
6
+ const net = require('node:net');
6
7
  const port = Number(process.env.TTMG_VIBE_TEST_PORT);
7
8
  assert.ok(Number.isInteger(port) && port > 0 && port < 65536);
8
9
  https.request = (options, callback) => {
9
10
  assert.equal(typeof options, 'object');
10
11
  assert.equal(options.hostname, 'developers.tiktok.com');
11
12
  assert.equal(options.protocol, 'https:');
12
- assert.match(options.path, /^\/bff\/app_info\/minis_import\/(create_minis_import_task_for_agent|get_minis_import_task_status_for_agent)\//);
13
- return http.request({
13
+ assert.match(options.path, /^\/bff\/app_info\/minis_import\/(create_minis_import_task_for_agent|get_minis_import_task_status_for_agent|upload_minis_import_app_icon_for_agent|submit_minis_import_info_for_agent|upload_minis_import_package_for_agent)\//);
14
+ const request = http.request({
14
15
  ...options,
15
16
  protocol: 'http:', hostname: '127.0.0.1', host: undefined, port,
16
17
  agent: undefined, servername: undefined,
17
18
  }, callback);
19
+ if (process.env.TTMG_VIBE_TEST_RETAIN_UPLOAD_SOCKET === '1' &&
20
+ /upload_minis_import_(app_icon|package)_for_agent/.test(options.path)) {
21
+ // Reproduce the live gateway's completed response with a retained upload
22
+ // socket. The socket is real but loopback-only; operation cleanup must close it.
23
+ assert.ok(options.agent && options.agent !== https.globalAgent);
24
+ request.once('response', response => response.once('end', () => {
25
+ const socket = net.connect({host: '127.0.0.1', port});
26
+ const key = 'retained-vibe-test-sockets';
27
+ (options.agent.sockets[key] ||= []).push(socket);
28
+ socket.on('error', () => {});
29
+ }));
30
+ }
31
+ return request;
18
32
  };
19
33
  https.get = (options, callback) => {
20
34
  const request = https.request(options, callback);
@@ -27,6 +27,10 @@ const children = new Set();
27
27
  let fixtureServer;
28
28
  const requests = [];
29
29
  let failStatus = false;
30
+ let webMode = false;
31
+ let webPolls = 0;
32
+ let webFailureEndpoint;
33
+ let expectedWebCategory = { category: 'Other', subcategory: 'Other' };
30
34
  const env = {
31
35
  ...process.env,
32
36
  HOME: temp,
@@ -96,16 +100,83 @@ function events(result) {
96
100
  return result.stdout
97
101
  .trim()
98
102
  .split('\n')
99
- .map(line => JSON.parse(line));
103
+ .map(line => {
104
+ const event = JSON.parse(line);
105
+ if (event.mode === 'vibe') {
106
+ assert.deepEqual(event.inputRequirements.requiredUserInputs, ['title', 'icon']);
107
+ assert.equal(event.inputRequirements.requestAdditionalMetadata, false);
108
+ assert.equal(event.inputRequirements.additionalMetadataSource, 'cli-defaults');
109
+ assert.equal(event.inputRequirements.defaultMetadataProfile, 'ppe-trial');
110
+ }
111
+ return event;
112
+ });
100
113
  }
101
114
  async function main() {
102
- fixtureServer = http.createServer((req, res) => {
115
+ // Exercise the built worker IPC and new command without creating a platform task.
116
+ const authWorker = spawn(process.execPath, [entry, '__vibe-page-server'], {
117
+ env: {...env, TTMG_VIBE_PAGE_TTL_MS: '30000'}, stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
118
+ });
119
+ children.add(authWorker);
120
+ const workerMessage = predicate => new Promise((resolve, reject) => {
121
+ const timer = setTimeout(() => {authWorker.off('message', receive); reject(new Error('worker timeout'));}, 5000);
122
+ function receive(message) {
123
+ if (predicate(message)) {clearTimeout(timer); authWorker.off('message', receive); resolve(message);}
124
+ }
125
+ authWorker.on('message', receive);
126
+ });
127
+ const ready = await workerMessage(message => message.type === 'ready');
128
+ const landing = 'https://developers.tiktok.com/portal_h5/minis_import?task_id=123&import_token=process-private-token';
129
+ let rendered = workerMessage(message => message.type === 'rendered' && message.id === 1);
130
+ authWorker.send({type: 'render', id: 1, html: '<h1>Waiting</h1>', browserLandingUrl: landing});
131
+ await rendered;
132
+ const browserReceipt = path.join(temp, 'browser-open.ndjson');
133
+ const browserEnv = {
134
+ NODE_OPTIONS: `--require=${path.join(__dirname, 'vibe-auth-open-test-preload.cjs')}`,
135
+ TTMG_AUTH_OPEN_TEST_RECEIPT: browserReceipt,
136
+ };
137
+ for (const format of ['json', 'ndjson']) {
138
+ const opened = await run(['auth', 'open', '--page-url', ready.url, '--format', format], undefined, browserEnv);
139
+ assert.equal(opened.code, 0, opened.stderr);
140
+ const result = JSON.parse(opened.stdout);
141
+ assert.equal(result.status, 'open_requested');
142
+ assert.equal(result.platformWrite, false);
143
+ assert.doesNotMatch(opened.stdout + opened.stderr, /import_token|process-private-token/);
144
+ }
145
+ const launches = fs.readFileSync(browserReceipt, 'utf8').trim().split('\n').map(JSON.parse);
146
+ assert.equal(launches.length, 2);
147
+ assert.ok(launches.every(item => item.args.includes(ready.url + '/authorize')));
148
+ const redirect = await fetch(ready.url + '/authorize', {redirect: 'manual'});
149
+ assert.equal(redirect.headers.get('location'), landing);
150
+ assert.equal(redirect.status, 302);
151
+ rendered = workerMessage(message => message.type === 'rendered' && message.id === 2);
152
+ authWorker.send({type: 'render', id: 2, html: '<h1>Complete</h1>'});
153
+ await rendered;
154
+ const stale = await run(['auth', 'open', '--page-url', ready.url, '--format', 'json'], undefined, browserEnv);
155
+ assert.equal(stale.code, 1);
156
+ assert.equal(JSON.parse(stale.stdout).errorCode, 'VIBE_AUTH_PAGE_UNAVAILABLE');
157
+ for (const args of [[], ['--page-url', landing]]) {
158
+ const invalid = await run(['auth', 'open', ...args, '--format', 'json'], undefined, browserEnv);
159
+ assert.equal(invalid.code, 1);
160
+ assert.equal(JSON.parse(invalid.stdout).errorCode, 'VIBE_INVALID_PAGE_URL');
161
+ assert.doesNotMatch(invalid.stdout + invalid.stderr, /process-private-token/);
162
+ }
163
+ assert.equal(fs.readFileSync(browserReceipt, 'utf8').trim().split('\n').length, 2);
164
+ authWorker.kill();
165
+ children.delete(authWorker);
166
+ fixtureServer = http.createServer(async (req, res) => {
103
167
  const url = new URL(req.url, 'http://127.0.0.1');
104
168
  requests.push({ path: url.pathname, method: req.method, headers: req.headers });
105
- req.resume();
169
+ const chunks = [];
170
+ for await (const chunk of req) chunks.push(chunk);
171
+ const body = Buffer.concat(chunks);
106
172
  const create = url.pathname.endsWith('/create_minis_import_task_for_agent/');
107
173
  const status = url.pathname.endsWith('/get_minis_import_task_status_for_agent/');
108
- assert.ok(create || status, 'Unexpected real upload endpoint');
174
+ const iconUpload = url.pathname.endsWith('/upload_minis_import_app_icon_for_agent/');
175
+ const infoUpload = url.pathname.endsWith('/submit_minis_import_info_for_agent/');
176
+ const packageUpload = url.pathname.endsWith('/upload_minis_import_package_for_agent/');
177
+ if (create) webPolls = 0;
178
+ if (status && webMode) webPolls++;
179
+ assert.ok(create || status || (webMode && (iconUpload || infoUpload || packageUpload)), 'Mock must not call material-upload endpoints');
109
180
  assert.equal(req.headers['x-use-ppe'], '1');
110
181
  assert.equal(req.headers['x-tt-env'], 'ppe_op_vc');
111
182
  assert.equal(req.headers.cookie, undefined);
@@ -113,13 +184,41 @@ async function main() {
113
184
  assert.equal(url.searchParams.get('task_id'), '7684370037737553941');
114
185
  assert.equal(url.searchParams.get('import_token'), 'process-test-secret');
115
186
  }
187
+ if (iconUpload || infoUpload || packageUpload) {
188
+ assert.equal(webPolls, 5, 'Do not upload before server readiness');
189
+ assert.equal(req.method, 'POST');
190
+ if (infoUpload) {
191
+ assert.match(body.toString(), /"task_id":7684370037737553941/);
192
+ const info = JSON.parse(body.toString());
193
+ assert.equal(info.import_token, 'process-test-secret');
194
+ assert.equal(info.app_info.icon_uri, 'web-icon-receipt');
195
+ assert.equal(info.app_info.app_name, input[input.indexOf('--title') + 1]);
196
+ assert.deepEqual(info.app_info, {
197
+ description: 'DevTool PPE integration test fixture. Not a production game.',
198
+ ...expectedWebCategory,
199
+ terms_of_service: 'https://www.tiktok.com',
200
+ privacy_policy: 'https://www.tiktok.com',
201
+ copyright_confirmation: true, contact_email: 'devtool-test@example.invalid',
202
+ app_name: input[input.indexOf('--title') + 1], icon_uri: 'web-icon-receipt',
203
+ }, 'CLI must send complete PPE trial defaults without asking the user for them');
204
+ } else {
205
+ assert.match(req.headers['content-type'], /^multipart\/form-data; boundary=/);
206
+ assert.match(body.toString(), /name="task_id"\r\n\r\n7684370037737553941\r\n/);
207
+ assert.match(body.toString(), /name="import_token"\r\n\r\nprocess-test-secret\r\n/);
208
+ assert.ok(body.includes(fs.readFileSync(iconUpload ? icon : zip)));
209
+ if (packageUpload) assert.match(body.toString(), /name="asset_type"\r\n\r\n2\r\n/);
210
+ }
211
+ }
116
212
  const data = create ? {
117
213
  task_id: '7684370037737553941', import_token: 'process-test-secret',
118
- landing_url: 'https://developers.tiktok.com/portal_h5/minis_import?import_token=process-test-secret',
214
+ landing_url: 'https://developers.tiktok.com/portal_h5/minis_import?task_id=7684370037737553941&import_token=process-test-secret',
119
215
  poll_interval_seconds: 0.01,
120
- } : { status: 1, ready_for_input: false };
216
+ } : iconUpload ? {icon_uri: 'web-icon-receipt'}
217
+ : infoUpload ? {}
218
+ : packageUpload ? {status: 3, package_uri: 'web-package-receipt'}
219
+ : { status: webMode && webPolls >= 5 ? 2 : 1, ready_for_input: webMode && webPolls >= 5 };
121
220
  res.writeHead(200, { 'Content-Type': 'application/json' });
122
- res.end(JSON.stringify({ ...data, error_info: { code: failStatus && status ? 420500 : 0 } }));
221
+ res.end(JSON.stringify({ ...data, error_info: { code: (failStatus && status) || url.pathname === webFailureEndpoint ? 420500 : 0 } }));
123
222
  });
124
223
  await new Promise(resolve => fixtureServer.listen(0, '127.0.0.1', resolve));
125
224
  env.TTMG_VIBE_TEST_PORT = String(fixtureServer.address().port);
@@ -138,6 +237,13 @@ async function main() {
138
237
 
139
238
  let bindingHtml;
140
239
  const appInfoPath = path.join(temp, 'app-info.json');
240
+ const invalidLegacy = await run([...input, '--app-info', appInfoPath, '--dry-run', '--format', 'json']);
241
+ assert.equal(invalidLegacy.code, 1);
242
+ const legacyFailure = events(invalidLegacy)[0];
243
+ assert.equal(legacyFailure.errorCode, 'VIBE_INVALID_APP_INFO');
244
+ assert.equal(legacyFailure.platformWrite, false);
245
+ assert.match(legacyFailure.message, /Remove --app-info|移除 --app-info/);
246
+ assert.doesNotMatch(legacyFailure.message, /are required|需要简介/);
141
247
  fs.writeFileSync(appInfoPath, JSON.stringify({
142
248
  description: 'Process contract fixture', category: 'Casual', subcategory: 'Puzzle',
143
249
  terms_of_service: 'https://example.invalid/terms', privacy_policy: 'https://example.invalid/privacy',
@@ -156,6 +262,14 @@ async function main() {
156
262
  const completeDry = await run([...input, '--app-info', appInfoPath, '--dry-run', '--format', 'json']);
157
263
  assert.equal(completeDry.code, 0, completeDry.stderr);
158
264
  assert.equal(events(completeDry)[0].status, 'prepared');
265
+ assert.equal(events(completeDry)[0].classification.category, 'Casual');
266
+ assert.equal(events(completeDry)[0].classification.subcategory, 'Puzzle');
267
+ assert.equal(events(completeDry)[0].classification.source, 'app-info');
268
+ const overrideDry = await run([...input, '--app-info', appInfoPath, '--category', 'Puzzle', '--subcategory', 'Physics', '--dry-run', '--format', 'json']);
269
+ assert.equal(overrideDry.code, 0, overrideDry.stderr);
270
+ assert.equal(events(overrideDry)[0].classification.category, 'Puzzle');
271
+ assert.equal(events(overrideDry)[0].classification.subcategory, 'Physics');
272
+ assert.equal(events(overrideDry)[0].classification.source, 'arguments');
159
273
  assert.equal(requests.length, 0);
160
274
  fs.writeFileSync(appInfoPath, '{private-token');
161
275
  const badInfo = await run([...input, '--mock', '--app-info', appInfoPath, '--format', 'json']);
@@ -259,6 +373,102 @@ async function main() {
259
373
  assert.match(previewHtml, /data-phase="complete"/);
260
374
  assert.doesNotMatch(previewHtml, /QR|preview|review|Publish to TikTok/);
261
375
 
376
+ const beforeWeb = requests.length;
377
+ const webConflict = await run([...input, '--web', '--mock', '--format', 'json']);
378
+ assert.equal(events(webConflict)[0].errorCode, 'VIBE_WEB_OPTION_CONFLICT');
379
+ const legacyWeb = await run(['upload', '--web', '--format', 'json']);
380
+ assert.equal(events(legacyWeb)[0].errorCode, 'VIBE_OPTION_REQUIRES_VIBE');
381
+ assert.equal(requests.length, beforeWeb);
382
+ for (const [args, code] of [
383
+ [['--category', 'Puzzle'], 'VIBE_CATEGORY_PAIR_REQUIRED'],
384
+ [['--subcategory', 'Physics'], 'VIBE_CATEGORY_PAIR_REQUIRED'],
385
+ [['--category', 'Other', '--subcategory', 'Physics'], 'VIBE_INVALID_CATEGORY'],
386
+ [['--category', 'Unknown', '--subcategory', 'Other'], 'VIBE_INVALID_CATEGORY'],
387
+ [['--category', '', '--subcategory', ''], 'VIBE_INVALID_CATEGORY'],
388
+ ]) {
389
+ const invalid = await run([...input.filter(v => v !== '--no-open'), '--web', ...args, '--format', 'json']);
390
+ assert.equal(invalid.code, 1);
391
+ const result = events(invalid)[0];
392
+ assert.equal(result.errorCode, code);
393
+ assert.equal(result.platformWrite, false);
394
+ assert.equal(result.taskId, undefined);
395
+ assert.equal(result.pageUrl, undefined);
396
+ }
397
+ assert.equal(requests.length, beforeWeb, 'Invalid classification must not create tasks or upload');
398
+ webMode = true;
399
+ for (const format of ['json', 'ndjson']) {
400
+ expectedWebCategory = format === 'json' ? {category: 'Other', subcategory: 'Other'} : {category: 'Puzzle', subcategory: 'Physics'};
401
+ const categoryArgs = format === 'json' ? [] : ['--category', 'Puzzle', '--subcategory', 'Physics'];
402
+ const receipt = path.join(temp, `upload-web-${format}.ndjson`);
403
+ const requestStart = requests.length;
404
+ const web = await run([...input.filter(v => v !== '--no-open'), '--web', ...categoryArgs, '--format', format, '--poll-interval', '0.2'], async event => {
405
+ if (event.status === 'prepared') assert.deepEqual(
406
+ {category: event.classification.category, subcategory: event.classification.subcategory},
407
+ expectedWebCategory);
408
+ if (event.status === 'waiting_authorization') {
409
+ assert.equal(event.authorizationMode, 'web');
410
+ assert.equal(event.browserOpenRequested, true);
411
+ assert.equal(event.simulation, false);
412
+ assert.equal(event.nextAction, 'open_browser');
413
+ assert.equal(event.qrPurpose, undefined);
414
+ const response = await fetch(event.pageUrl + '/authorize', {redirect: 'manual'});
415
+ assert.equal(response.status, 302);
416
+ assert.match(response.headers.get('location'), /^https:\/\/developers\.tiktok\.com\/portal_h5\/minis_import\?/);
417
+ const html = await (await fetch(event.pageUrl)).text();
418
+ assert.match(html, /Authorize in your browser/);
419
+ assert.doesNotMatch(html, /alt="Authorization QR"|Scan the QR code/);
420
+ }
421
+ }, {
422
+ NODE_OPTIONS: env.NODE_OPTIONS + ' --require=' + path.join(__dirname, 'vibe-auth-open-test-preload.cjs'),
423
+ TTMG_AUTH_OPEN_TEST_RECEIPT: receipt,
424
+ // Explicit --web must not inherit an old Mock backend setting.
425
+ TTMG_VIBE_BACKEND: 'mock',
426
+ TTMG_VIBE_TEST_RETAIN_UPLOAD_SOCKET: '1',
427
+ });
428
+ assert.equal(web.code, 0, web.stderr);
429
+ const output = events(web);
430
+ assert.equal(output.at(-1).status, 'uploaded');
431
+ assert.equal(output.at(-1).simulation, false);
432
+ assert.equal(output.at(-1).backend, 'ppe');
433
+ assert.equal(output.at(-1).readyForInput, true);
434
+ assert.equal(output.at(-1).uploadAttempted, true);
435
+ assert.equal(output.at(-1).uploaded, true);
436
+ assert.deepEqual(
437
+ { category: output.at(-1).classification.category, subcategory: output.at(-1).classification.subcategory },
438
+ expectedWebCategory);
439
+ assert.equal(output.at(-1).classification.source, format === 'json' ? 'defaults' : 'arguments');
440
+ assert.equal(output.at(-1).taskStatusRaw, 3);
441
+ assert.equal(output.filter(e => e.terminal).length, 1);
442
+ assert.equal(webPolls, 5, 'Only true backend readiness unlocks authorization');
443
+ assert.deepEqual(requests.slice(requestStart).map(r => r.path.split('/').filter(Boolean).at(-1)), [
444
+ 'create_minis_import_task_for_agent', ...Array(5).fill('get_minis_import_task_status_for_agent'),
445
+ 'upload_minis_import_app_icon_for_agent', 'submit_minis_import_info_for_agent', 'upload_minis_import_package_for_agent',
446
+ ]);
447
+ if (format === 'json') assert.equal(output.length, 1);
448
+ const opens = fs.readFileSync(receipt, 'utf8').trim().split('\n').map(line => JSON.parse(line));
449
+ assert.equal(opens.length, 1);
450
+ assert.ok(opens[0].args.some(arg => /^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{48}\/authorize$/.test(arg)));
451
+ assert.doesNotMatch(JSON.stringify(opens) + web.stdout + web.stderr, /process-test-secret|import_token|web-icon-receipt|web-package-receipt|aweme:\/\//);
452
+ }
453
+ expectedWebCategory = {category: 'Other', subcategory: 'Other'};
454
+ webFailureEndpoint = '/bff/app_info/minis_import/submit_minis_import_info_for_agent/';
455
+ const failedStart = requests.length;
456
+ const webUploadFailure = await run([...input.filter(v => v !== '--no-open'), '--web', '--format', 'json'], undefined, {
457
+ NODE_OPTIONS: env.NODE_OPTIONS + ' --require=' + path.join(__dirname, 'vibe-auth-open-test-preload.cjs'),
458
+ TTMG_AUTH_OPEN_TEST_RECEIPT: path.join(temp, 'web-failure-open.ndjson'),
459
+ TTMG_VIBE_TEST_RETAIN_UPLOAD_SOCKET: '1',
460
+ });
461
+ assert.equal(webUploadFailure.code, 1);
462
+ const webFailure = events(webUploadFailure)[0];
463
+ assert.equal(webFailure.errorCode, 'VIBE_BACKEND_REJECTED');
464
+ assert.equal(webFailure.uploadAttempted, true);
465
+ assert.equal(webFailure.uploaded, false);
466
+ assert.equal(webFailure.simulation, false);
467
+ assert.equal(requests.at(-1).path, webFailureEndpoint);
468
+ assert.equal(requests.slice(failedStart).filter(r => r.path === webFailureEndpoint).length, 1);
469
+ webFailureEndpoint = undefined;
470
+ webMode = false;
471
+
262
472
  const json = await run(
263
473
  [...input, '--format', 'json', '--poll-interval', '0.01'],
264
474
  undefined,
@@ -377,10 +587,36 @@ async function main() {
377
587
  const badMode = await run(['upload', '--title', 'x', '--format', 'json']);
378
588
  assert.equal(badMode.code, 1);
379
589
  assert.equal(events(badMode)[0].errorCode, 'VIBE_OPTION_REQUIRES_VIBE');
590
+ const badCategoryMode = await run(['upload', '--category', 'Puzzle', '--subcategory', 'Physics', '--format', 'json']);
591
+ assert.equal(events(badCategoryMode)[0].errorCode, 'VIBE_OPTION_REQUIRES_VIBE');
592
+ const beforeCatalog = requests.length;
593
+ const catalog = await run(['game', 'categories', '--format', 'json']);
594
+ assert.equal(catalog.code, 0, catalog.stderr);
595
+ assert.equal(events(catalog).length, 1);
596
+ assert.equal(events(catalog)[0].categoryCount, 12);
597
+ assert.equal(events(catalog)[0].subcategoryCount, 114);
598
+ assert.equal(events(catalog)[0].platformWrite, false);
599
+ const filtered = await run(['game', 'categories', '--category', 'Puzzle', '--format', 'ndjson']);
600
+ assert.equal(filtered.code, 0);
601
+ assert.deepEqual(events(filtered)[0].categories.map(item => item.value), ['Puzzle']);
602
+ assert.ok(events(filtered)[0].categories[0].subcategories.every(item => item.description));
603
+ for (const [args, code] of [
604
+ [['--category', 'unknown'], 'GAME_CATEGORY_UNKNOWN'],
605
+ [['--format', 'xml'], 'GAME_CATEGORIES_INVALID_FORMAT'],
606
+ ]) {
607
+ const invalid = await run(['game', 'categories', '--format', 'json', ...args]);
608
+ assert.equal(invalid.code, 1);
609
+ assert.equal(events(invalid)[0].errorCode, code);
610
+ assert.equal(events(invalid)[0].platformWrite, false);
611
+ }
612
+ assert.equal(requests.length, beforeCatalog, 'Catalog commands must remain offline');
380
613
  const help = await run(['upload', '--help']);
381
614
  assert.equal(help.code, 0);
382
615
  assert.match(help.stdout, /--vibe/);
383
616
  assert.match(help.stdout, /--client-key/);
617
+ const authHelp = await run(['auth', 'open', '--help']);
618
+ assert.equal(authHelp.code, 0);
619
+ assert.match(authHelp.stdout, /--page-url/);
384
620
 
385
621
  let cancelledPage;
386
622
  const cancelled = await run(