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

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.
Files changed (100) hide show
  1. package/README.md +644 -2
  2. package/dist/index.js +22351 -5968
  3. package/dist/index.js.map +1 -1
  4. package/dist/package.json +38 -4
  5. package/dist/public/assets/Card-CN2lxY0Y.js +1 -0
  6. package/dist/public/assets/Detail-CELZZ-4h.js +1 -0
  7. package/dist/public/assets/Detail-CELZZ-4h.js.br +0 -0
  8. package/dist/public/assets/MonetizationMode-C7DNqium.js +1 -0
  9. package/dist/public/assets/MonetizationModeSummary-DYE7XUq1.js +1 -0
  10. package/dist/public/assets/{SectionHeader-Caf8qPRH.js → SectionHeader-BKc3BWXP.js} +1 -1
  11. package/dist/public/assets/{baseForm-DbKDw3IN.js → baseForm-I9suIiQn.js} +1 -1
  12. package/dist/public/assets/baseForm-I9suIiQn.js.br +0 -0
  13. package/dist/public/assets/{col-DlUD2IyX.js → col-w9Q-2mgM.js} +1 -1
  14. package/dist/public/assets/{compass-D8bLLLSR.js → compass-D6waJ52O.js} +1 -1
  15. package/dist/public/assets/{index-eoDMPIdb.js → index-8sDAOAcS.js} +1 -1
  16. package/dist/public/assets/index-8sDAOAcS.js.br +0 -0
  17. package/dist/public/assets/{index-DF6WNroB.js → index-B6hY5GdS.js} +1 -1
  18. package/dist/public/assets/{index-DenoanNX.js → index-BIrFkB6r.js} +1 -1
  19. package/dist/public/assets/index-BIrFkB6r.js.br +0 -0
  20. package/dist/public/assets/{index-CPfhNCcU.js → index-BLWgpW64.js} +1 -1
  21. package/dist/public/assets/{index-y9pNuDMs.js → index-BY3eQmfP.js} +1 -1
  22. package/dist/public/assets/index-BY3eQmfP.js.br +0 -0
  23. package/dist/public/assets/index-BegU9LBT.js +21 -0
  24. package/dist/public/assets/index-BegU9LBT.js.br +0 -0
  25. package/dist/public/assets/{index-Cf7bng8i.js → index-BqwflQml.js} +1 -1
  26. package/dist/public/assets/{index-BR6l0C9y.js → index-BrOFdVbv.js} +1 -1
  27. package/dist/public/assets/{index-BlupVk90.js → index-BvPolBSn.js} +1 -1
  28. package/dist/public/assets/index-BwrZjkCc.js +1 -0
  29. package/dist/public/assets/{index-BLmfGH4o.js → index-CLKKui8G.js} +1 -1
  30. package/dist/public/assets/index-Cb4Ja0tZ.js +1 -0
  31. package/dist/public/assets/{index-Ggo1rV6S.js → index-CcUgN29Y.js} +1 -1
  32. package/dist/public/assets/index-CgXOkYrX.js +1 -0
  33. package/dist/public/assets/index-CgXOkYrX.js.br +0 -0
  34. package/dist/public/assets/{index-yR_bPMcB.css → index-Ckp_mJJy.css} +1 -1
  35. package/dist/public/assets/index-Ckp_mJJy.css.br +0 -0
  36. package/dist/public/assets/{index-D0iKOimx.js → index-CsiIrLKY.js} +1 -1
  37. package/dist/public/assets/{index-BeG7a_8l.js → index-Csmg5oyu.js} +1 -1
  38. package/dist/public/assets/{index-CTkNRReQ.js → index-DDecPuod.js} +1 -1
  39. package/dist/public/assets/{index-B0fmVxbN.js → index-DDy4PkWT.js} +1 -1
  40. package/dist/public/assets/{index-BV532pMK.js → index-DEQ2hVPA.js} +1 -1
  41. package/dist/public/assets/index-DEQ2hVPA.js.br +0 -0
  42. package/dist/public/assets/{index-D9-bb2oL.js → index-DGkgqtgJ.js} +1 -1
  43. package/dist/public/assets/index-FVHabbDB.js +1 -0
  44. package/dist/public/assets/index-M8jf-YAZ.js +1 -0
  45. package/dist/public/assets/index-Q0GC8Irj.js +1 -0
  46. package/dist/public/assets/index-Q0GC8Irj.js.br +0 -0
  47. package/dist/public/assets/{index-D2zLbzBh.css → index-WyNc-BTG.css} +1 -1
  48. package/dist/public/assets/index-WyNc-BTG.css.br +0 -0
  49. package/dist/public/assets/{index-BvRI6rq9.js → index-YVB9ZW00.js} +1 -1
  50. package/dist/public/assets/index-mjEeEQcC.js +1 -0
  51. package/dist/public/assets/index-mjEeEQcC.js.br +0 -0
  52. package/dist/public/assets/{index-DXXBlk9b.js → index-rLkONFpx.js} +1 -1
  53. package/dist/public/assets/{layers-BQ_Tw855.js → layers-Dbwq9AyL.js} +1 -1
  54. package/dist/public/assets/{list-checks-9HC7bocq.js → list-checks-og1usOZf.js} +1 -1
  55. package/dist/public/assets/{sparkles-BLHMCsIj.js → sparkles-p-dZbjGb.js} +1 -1
  56. package/dist/public/assets/{triangle-alert-BprMFvuT.js → triangle-alert-BZVAEUiL.js} +1 -1
  57. package/dist/public/assets/{zap-Bj4LJ903.js → zap-BAhZUPPa.js} +1 -1
  58. package/dist/public/index.html +2 -2
  59. package/dist/scripts/acceptance-fixture.test.js +33 -0
  60. package/dist/scripts/dev-capture-smoke.js +76 -0
  61. package/dist/scripts/dev-client-preview-control-smoke.js +606 -0
  62. package/dist/scripts/dev-default-smoke.js +67 -0
  63. package/dist/scripts/dev-game-ready-smoke.js +155 -0
  64. package/dist/scripts/dev-workflow-smoke.js +260 -0
  65. package/dist/scripts/prepare-client-preview-acceptance.js +65 -0
  66. package/dist/scripts/preview-page-server-smoke.js +63 -0
  67. package/dist/scripts/run-ts-test.js +41 -0
  68. package/dist/scripts/verify-agent-contract.js +410 -0
  69. package/dist/scripts/verify-dist-version.js +41 -0
  70. package/dist/scripts/vibe-ppe-test-preload.cjs +23 -0
  71. package/dist/scripts/vibe-upload-demo.js +72 -0
  72. package/dist/scripts/vibe-upload-smoke.js +412 -0
  73. package/dist/scripts/wasm-smoke.js +53 -0
  74. package/dist/scripts/wasm-worker.js +63 -0
  75. package/dist/scripts/worker.js +28 -22
  76. package/package.json +38 -4
  77. package/dist/public/assets/Detail-CTD5OeB0.js +0 -1
  78. package/dist/public/assets/Detail-CTD5OeB0.js.br +0 -0
  79. package/dist/public/assets/MonetizationMode-DZmggvdZ.js +0 -1
  80. package/dist/public/assets/MonetizationModeSummary-7jDe4Q_6.js +0 -1
  81. package/dist/public/assets/Tag-DSB9Uput.js +0 -1
  82. package/dist/public/assets/baseForm-DbKDw3IN.js.br +0 -0
  83. package/dist/public/assets/index-B1gECabp.js +0 -1
  84. package/dist/public/assets/index-B1gECabp.js.br +0 -0
  85. package/dist/public/assets/index-B3TFdXWD.js +0 -1
  86. package/dist/public/assets/index-BV532pMK.js.br +0 -0
  87. package/dist/public/assets/index-BzbUu6Wd.js +0 -1
  88. package/dist/public/assets/index-COLHKvH0.js +0 -1
  89. package/dist/public/assets/index-D2zLbzBh.css.br +0 -0
  90. package/dist/public/assets/index-DenoanNX.js.br +0 -0
  91. package/dist/public/assets/index-DnsrxgPV.js +0 -1
  92. package/dist/public/assets/index-DnsrxgPV.js.br +0 -0
  93. package/dist/public/assets/index-Do3D3DDt.js +0 -1
  94. package/dist/public/assets/index-Do3D3DDt.js.br +0 -0
  95. package/dist/public/assets/index-DyR-YyZt.js +0 -1
  96. package/dist/public/assets/index-OPQyWrBC.js +0 -21
  97. package/dist/public/assets/index-OPQyWrBC.js.br +0 -0
  98. package/dist/public/assets/index-eoDMPIdb.js.br +0 -0
  99. package/dist/public/assets/index-y9pNuDMs.js.br +0 -0
  100. package/dist/public/assets/index-yR_bPMcB.css.br +0 -0
@@ -0,0 +1,412 @@
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
+ const archiver = require('archiver');
7
+ const http = require('node:http');
8
+
9
+ const entry = path.resolve(__dirname, '../dist/index.js');
10
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ttmg-vibe-process-'));
11
+ const icon = path.join(temp, 'avatar.png');
12
+ const zip = path.join(temp, 'game.zip');
13
+ const input = [
14
+ 'upload',
15
+ '--vibe',
16
+ '--title',
17
+ 'Local test game',
18
+ '--icon',
19
+ icon,
20
+ '--package',
21
+ zip,
22
+ '--no-open',
23
+ ];
24
+ const png =
25
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXyoAAAAASUVORK5CYII=';
26
+ const children = new Set();
27
+ let fixtureServer;
28
+ const requests = [];
29
+ let failStatus = false;
30
+ const env = {
31
+ ...process.env,
32
+ HOME: temp,
33
+ USERPROFILE: temp,
34
+ TTMGRC_PATH: path.join(temp, '.ttmgrc'),
35
+ TTMG_VIBE_PAGE_TTL_MS: '7000',
36
+ TTMG_VIBE_BACKEND: '',
37
+ TTMG_VIBE_API: '',
38
+ TTMG_VIBE_PPE: '',
39
+ };
40
+
41
+ function run(args, onEvent, extraEnv = {}) {
42
+ return new Promise((resolve, reject) => {
43
+ const child = spawn(process.execPath, [entry, ...args], {
44
+ cwd: temp,
45
+ env: { ...env, ...extraEnv },
46
+ stdio: ['ignore', 'pipe', 'pipe'],
47
+ });
48
+ children.add(child);
49
+ let stdout = '';
50
+ let stderr = '';
51
+ let pending = '';
52
+ let callbackError;
53
+ const callbacks = [];
54
+ const timer = setTimeout(() => {
55
+ child.kill('SIGKILL');
56
+ reject(new Error('CLI process timed out'));
57
+ }, 12000);
58
+ child.stdout.on('data', chunk => {
59
+ stdout += chunk;
60
+ pending += chunk;
61
+ let newline;
62
+ while ((newline = pending.indexOf('\n')) >= 0) {
63
+ const line = pending.slice(0, newline);
64
+ pending = pending.slice(newline + 1);
65
+ if (onEvent && line.trim()) {
66
+ try {
67
+ callbacks.push(
68
+ Promise.resolve(onEvent(JSON.parse(line), child)).catch(error => {
69
+ callbackError = error;
70
+ }),
71
+ );
72
+ } catch (error) {
73
+ callbackError = error;
74
+ }
75
+ }
76
+ }
77
+ });
78
+ child.stderr.on('data', chunk => {
79
+ stderr += chunk;
80
+ });
81
+ child.once('error', reject);
82
+ child.once('close', async (code, signal) => {
83
+ clearTimeout(timer);
84
+ children.delete(child);
85
+ await Promise.all(callbacks);
86
+ if (callbackError) reject(callbackError);
87
+ else resolve({ code, signal, stdout, stderr });
88
+ });
89
+ });
90
+ }
91
+ function events(result) {
92
+ assert.doesNotMatch(
93
+ result.stdout,
94
+ /upload_uuid|upload_token|import_token|importToken|mock-secret|mock-authorization/,
95
+ );
96
+ return result.stdout
97
+ .trim()
98
+ .split('\n')
99
+ .map(line => JSON.parse(line));
100
+ }
101
+ async function main() {
102
+ fixtureServer = http.createServer((req, res) => {
103
+ const url = new URL(req.url, 'http://127.0.0.1');
104
+ requests.push({ path: url.pathname, method: req.method, headers: req.headers });
105
+ req.resume();
106
+ const create = url.pathname.endsWith('/create_minis_import_task_for_agent/');
107
+ const status = url.pathname.endsWith('/get_minis_import_task_status_for_agent/');
108
+ assert.ok(create || status, 'Unexpected real upload endpoint');
109
+ assert.equal(req.headers['x-use-ppe'], '1');
110
+ assert.equal(req.headers['x-tt-env'], 'ppe_op_vc');
111
+ assert.equal(req.headers.cookie, undefined);
112
+ if (status) {
113
+ assert.equal(url.searchParams.get('task_id'), '7684370037737553941');
114
+ assert.equal(url.searchParams.get('import_token'), 'process-test-secret');
115
+ }
116
+ const data = create ? {
117
+ task_id: '7684370037737553941', import_token: 'process-test-secret',
118
+ landing_url: 'https://developers.tiktok.com/portal_h5/minis_import?import_token=process-test-secret',
119
+ poll_interval_seconds: 0.01,
120
+ } : { status: 1, ready_for_input: false };
121
+ res.writeHead(200, { 'Content-Type': 'application/json' });
122
+ res.end(JSON.stringify({ ...data, error_info: { code: failStatus && status ? 420500 : 0 } }));
123
+ });
124
+ await new Promise(resolve => fixtureServer.listen(0, '127.0.0.1', resolve));
125
+ env.TTMG_VIBE_TEST_PORT = String(fixtureServer.address().port);
126
+ env.NODE_OPTIONS = `--require ${JSON.stringify(path.join(__dirname, 'vibe-ppe-test-preload.cjs'))}`;
127
+ fs.writeFileSync(icon, Buffer.from(png, 'base64'));
128
+ await new Promise((resolve, reject) => {
129
+ const out = fs.createWriteStream(zip);
130
+ const archive = archiver('zip');
131
+ out.once('close', resolve);
132
+ out.once('error', reject);
133
+ archive.once('error', reject);
134
+ archive.pipe(out);
135
+ archive.append('console.log("local fixture")', { name: 'game.js' });
136
+ archive.finalize().catch(reject);
137
+ });
138
+
139
+ let bindingHtml;
140
+ const appInfoPath = path.join(temp, 'app-info.json');
141
+ fs.writeFileSync(appInfoPath, JSON.stringify({
142
+ description: 'Process contract fixture', category: 'Casual', subcategory: 'Puzzle',
143
+ terms_of_service: 'https://example.invalid/terms', privacy_policy: 'https://example.invalid/privacy',
144
+ copyright_confirmation: true,
145
+ }));
146
+ const badIcon = await run([...input, '--mock', '--app-info', appInfoPath, '--format', 'json']);
147
+ assert.equal(badIcon.code, 1);
148
+ assert.equal(events(badIcon)[0].errorCode, 'VIBE_PLATFORM_ICON_INVALID');
149
+ assert.equal(events(badIcon)[0].platformWrite, false);
150
+ assert.equal(requests.length, 0, 'Invalid input must not create a task');
151
+ assert.equal(fs.existsSync(path.join(temp, '.ttmg/vibe-project.json')), false);
152
+ const platformHeader = Buffer.from(png, 'base64');
153
+ platformHeader.writeUInt32BE(1024, 16);
154
+ platformHeader.writeUInt32BE(1024, 20);
155
+ fs.writeFileSync(icon, platformHeader); // Header preflight fixture, not a decoded image.
156
+ const completeDry = await run([...input, '--app-info', appInfoPath, '--dry-run', '--format', 'json']);
157
+ assert.equal(completeDry.code, 0, completeDry.stderr);
158
+ assert.equal(events(completeDry)[0].status, 'prepared');
159
+ assert.equal(requests.length, 0);
160
+ fs.writeFileSync(appInfoPath, '{private-token');
161
+ const badInfo = await run([...input, '--mock', '--app-info', appInfoPath, '--format', 'json']);
162
+ assert.equal(events(badInfo)[0].errorCode, 'VIBE_INVALID_APP_INFO');
163
+ assert.doesNotMatch(badInfo.stdout + badInfo.stderr, /private-token/);
164
+ assert.equal(requests.length, 0);
165
+ const legacyInfo = await run(['upload', '--app-info', appInfoPath, '--format', 'json']);
166
+ assert.equal(events(legacyInfo)[0].errorCode, 'VIBE_OPTION_REQUIRES_VIBE');
167
+ assert.equal(events(legacyInfo)[0].platformWrite, false);
168
+ fs.writeFileSync(icon, Buffer.from(png, 'base64'));
169
+ // Replay the live package receipt through the built CLI without PPE writes.
170
+ const uploadPaths = [];
171
+ const uploadServer = http.createServer((req, res) => {
172
+ const url = new URL(req.url, 'http://127.0.0.1');
173
+ const endpoint = url.pathname.split('/').filter(Boolean).at(-1);
174
+ uploadPaths.push(endpoint);
175
+ req.resume();
176
+ const replies = {
177
+ create_minis_import_task_for_agent: {
178
+ task_id: '7684370037737619477', import_token: 'receipt-test-secret',
179
+ landing_url: 'https://example.invalid/auth', poll_interval_seconds: 0.01,
180
+ },
181
+ get_minis_import_task_status_for_agent: { status: 2, ready_for_input: true },
182
+ upload_minis_import_app_icon_for_agent: { icon_uri: 'test-icon' },
183
+ submit_minis_import_info_for_agent: {},
184
+ upload_minis_import_package_for_agent: { status: 2, package_uri: 'test-package' },
185
+ };
186
+ res.writeHead(endpoint in replies ? 200 : 404, { 'Content-Type': 'application/json' });
187
+ res.end(JSON.stringify({ error_info: { code: 0 }, ...replies[endpoint] }));
188
+ });
189
+ await new Promise(resolve => uploadServer.listen(0, '127.0.0.1', resolve));
190
+ try {
191
+ const saved = await run([...input, '--format', 'ndjson'], undefined, {
192
+ TTMG_VIBE_BACKEND: 'local-http',
193
+ TTMG_VIBE_API: `http://127.0.0.1:${uploadServer.address().port}`,
194
+ });
195
+ assert.equal(saved.code, 0, saved.stderr);
196
+ const savedEvents = events(saved);
197
+ const savedFinal = savedEvents.at(-1);
198
+ assert.equal(savedFinal.status, 'uploaded', JSON.stringify(savedEvents.map(e => ({ status: e.status, terminal: e.terminal, step: e.uploadStep, uploaded: e.uploaded }))));
199
+ assert.equal(savedFinal.uploaded, true);
200
+ assert.equal(savedFinal.taskStatusRaw, 2);
201
+ assert.equal(savedEvents.filter(e => e.terminal).length, 1);
202
+ assert.deepEqual(uploadPaths, [
203
+ 'create_minis_import_task_for_agent', 'get_minis_import_task_status_for_agent',
204
+ 'upload_minis_import_app_icon_for_agent', 'submit_minis_import_info_for_agent',
205
+ 'upload_minis_import_package_for_agent',
206
+ ]);
207
+ assert.doesNotMatch(saved.stdout + saved.stderr, /receipt-test-secret|test-package/);
208
+ } finally {
209
+ uploadServer.close();
210
+ }
211
+ const success = await run(
212
+ [...input, '--mock', '--format', 'ndjson', '--poll-interval', '0.2'],
213
+ async event => {
214
+ if (event.status === 'waiting_authorization')
215
+ bindingHtml = await (await fetch(event.pageUrl)).text();
216
+ },
217
+ );
218
+ assert.equal(success.code, 0, success.stderr);
219
+ assert.match(bindingHtml, /Upload to TikTok/);
220
+ assert.match(bindingHtml, /class="game-avatar"/);
221
+ assert.match(bindingHtml, /PPE \+ Mock/);
222
+ assert.match(bindingHtml, /data-phase="binding"/);
223
+ assert.match(bindingHtml, /Phone authorization is not required/);
224
+ assert.doesNotMatch(bindingHtml, /<form|<input/);
225
+ const sequence = events(success);
226
+ const final = sequence[sequence.length - 1];
227
+ assert.equal(final.schemaVersion, '2');
228
+ assert.equal(final.status, 'uploaded');
229
+ assert.equal(final.terminal, true);
230
+ assert.equal(final.nextAction, 'none');
231
+ assert.equal(sequence.filter(e => e.terminal).length, 1);
232
+ assert.equal(final.uploaded, true);
233
+ assert.equal(final.platformWrite, true);
234
+ assert.equal(final.backend, 'ppe-mock');
235
+ assert.equal(final.taskStatusRaw, 1);
236
+ assert.equal(final.readyForInput, false);
237
+ assert.equal(final.simulatedReadyForInput, true);
238
+ assert.equal(final.simulatedTaskStatusRaw, 3);
239
+ assert.equal(requests.length, 4);
240
+ assert.equal(requests[0].method, 'POST');
241
+ assert.ok(requests.slice(1).every(r => r.method === 'GET'));
242
+ assert.doesNotMatch(success.stdout, /process-test-secret/);
243
+ assert.equal(final.simulation, true);
244
+ const progress = sequence.filter(
245
+ e => e.status === 'uploading' && e.loadedBytes !== undefined,
246
+ );
247
+ assert.equal(progress[0].loadedBytes, 0);
248
+ assert.equal(
249
+ progress[progress.length - 1].loadedBytes,
250
+ progress[progress.length - 1].totalBytes,
251
+ );
252
+ assert.ok(progress.length >= 6);
253
+ assert.equal(
254
+ sequence.filter(e => e.status === 'waiting_authorization').length,
255
+ 1,
256
+ );
257
+ const previewHtml = await (await fetch(final.pageUrl)).text();
258
+ assert.match(previewHtml, /Simulated upload complete/);
259
+ assert.match(previewHtml, /data-phase="complete"/);
260
+ assert.doesNotMatch(previewHtml, /QR|preview|review|Publish to TikTok/);
261
+
262
+ const json = await run(
263
+ [...input, '--format', 'json', '--poll-interval', '0.01'],
264
+ undefined,
265
+ { TTMG_VIBE_BACKEND: 'mock' },
266
+ );
267
+ assert.equal(json.code, 0, json.stderr);
268
+ assert.equal(events(json).length, 1);
269
+ assert.equal(JSON.parse(json.stdout).status, 'uploaded');
270
+ assert.equal(JSON.parse(json.stdout).simulation, true);
271
+ assert.match(json.stderr, /http:\/\/127\.0\.0\.1:/);
272
+
273
+ const missingPpe = await run([...input, '--format', 'json'], undefined, {
274
+ TTMG_VIBE_BACKEND: 'ppe',
275
+ });
276
+ assert.equal(missingPpe.code, 1);
277
+ assert.equal(events(missingPpe)[0].errorCode, 'VIBE_INVALID_PPE_ENV');
278
+ assert.equal(events(missingPpe)[0].platformWrite, false);
279
+ const dryPpe = await run([...input, '--dry-run', '--format', 'json'], undefined, {
280
+ TTMG_VIBE_BACKEND: 'ppe', TTMG_VIBE_PPE: 'ppe_op_vc',
281
+ });
282
+ assert.equal(dryPpe.code, 0, dryPpe.stderr);
283
+ assert.equal(events(dryPpe)[0].status, 'prepared');
284
+ assert.equal(events(dryPpe)[0].platformWrite, false);
285
+ const missingHybrid = await run([...input, '--format', 'json'], undefined, {
286
+ TTMG_VIBE_BACKEND: 'ppe-mock',
287
+ });
288
+ assert.equal(events(missingHybrid)[0].errorCode, 'VIBE_INVALID_PPE_ENV');
289
+ assert.equal(events(missingHybrid)[0].platformWrite, false);
290
+ const dryHybrid = await run([...input, '--dry-run', '--format', 'json'], undefined, {
291
+ TTMG_VIBE_BACKEND: 'ppe-mock', TTMG_VIBE_PPE: 'ppe_op_vc',
292
+ });
293
+ assert.equal(dryHybrid.code, 0, dryHybrid.stderr);
294
+ assert.equal(events(dryHybrid)[0].status, 'prepared');
295
+ assert.equal(events(dryHybrid)[0].platformWrite, false);
296
+ const hybridMockOverride = await run([...input, '--mock', '--poll-interval', '0.01', '--format', 'json'], undefined, {
297
+ TTMG_VIBE_BACKEND: 'ppe-mock', TTMG_VIBE_PPE: 'ppe_op_vc',
298
+ });
299
+ assert.equal(hybridMockOverride.code, 0, hybridMockOverride.stderr);
300
+ assert.equal(events(hybridMockOverride)[0].backend, 'ppe-mock');
301
+ assert.equal(events(hybridMockOverride)[0].platformWrite, true);
302
+
303
+ failStatus = true;
304
+ const realFailure = await run([...input, '--mock', '--format', 'json']);
305
+ assert.equal(realFailure.code, 1, realFailure.stderr);
306
+ assert.equal(events(realFailure)[0].errorCode, 'VIBE_BACKEND_REJECTED');
307
+ assert.equal(events(realFailure)[0].uploadAttempted, false);
308
+ assert.equal(events(realFailure)[0].uploaded, false);
309
+ failStatus = false;
310
+
311
+ for (const [scenario, errorCode, attempted] of [
312
+ ['denied', 'VIBE_AUTH_DENIED', false],
313
+ ['upload-failed', 'VIBE_BACKEND_REQUEST_FAILED', true],
314
+ ]) {
315
+ const failed = await run([
316
+ ...input,
317
+ '--mock',
318
+ '--mock-scenario',
319
+ scenario,
320
+ '--poll-interval',
321
+ '0.01',
322
+ '--format',
323
+ 'json',
324
+ ]);
325
+ assert.equal(failed.code, 1, failed.stderr);
326
+ const result = events(failed)[0];
327
+ assert.equal(result.status, 'failed');
328
+ assert.equal(result.errorCode, errorCode);
329
+ assert.equal(result.uploadAttempted, attempted);
330
+ assert.equal(result.uploaded, false);
331
+ assert.equal(result.terminal, true);
332
+ const html = await (await fetch(result.pageUrl)).text();
333
+ assert.match(html, /data-phase="failed"/);
334
+ assert.doesNotMatch(html, /alt="Authorization QR"|data-phase="complete"/);
335
+ }
336
+
337
+ const dryRun = await run([...input, '--dry-run', '--format', 'json']);
338
+ assert.equal(dryRun.code, 0, dryRun.stderr);
339
+ const prepared = events(dryRun)[0];
340
+ assert.equal(prepared.status, 'prepared');
341
+ assert.equal(prepared.backend, 'none');
342
+ assert.equal(prepared.pageUrl, undefined);
343
+ assert.equal(prepared.uploadAttempted, false);
344
+ const beforeDryMock = requests.length;
345
+ const dryMock = await run([...input, '--mock', '--dry-run', '--format', 'json']);
346
+ assert.equal(dryMock.code, 0);
347
+ assert.equal(events(dryMock)[0].platformWrite, false);
348
+ assert.equal(requests.length, beforeDryMock);
349
+
350
+ const project = path.join(temp, 'project');
351
+ fs.mkdirSync(project);
352
+ fs.writeFileSync(path.join(project, 'game.js'), 'console.log("game");\n');
353
+ fs.writeFileSync(path.join(project, 'game.json'), '{}\n');
354
+ const projectRun = await run([
355
+ 'upload',
356
+ '--vibe',
357
+ '--title',
358
+ 'Source game',
359
+ '--icon',
360
+ icon,
361
+ '--dir',
362
+ project,
363
+ '--dry-run',
364
+ '--format',
365
+ 'json',
366
+ ]);
367
+ assert.equal(projectRun.code, 0, projectRun.stdout + projectRun.stderr);
368
+ assert.equal(events(projectRun)[0].status, 'prepared');
369
+ assert.ok(events(projectRun)[0].packageSizeBytes > 0);
370
+
371
+ const missingBackend = await run([...input, '--format', 'json']);
372
+ assert.equal(missingBackend.code, 1);
373
+ assert.equal(
374
+ events(missingBackend)[0].errorCode,
375
+ 'VIBE_BACKEND_NOT_CONFIGURED',
376
+ );
377
+ const badMode = await run(['upload', '--title', 'x', '--format', 'json']);
378
+ assert.equal(badMode.code, 1);
379
+ assert.equal(events(badMode)[0].errorCode, 'VIBE_OPTION_REQUIRES_VIBE');
380
+ const help = await run(['upload', '--help']);
381
+ assert.equal(help.code, 0);
382
+ assert.match(help.stdout, /--vibe/);
383
+ assert.match(help.stdout, /--client-key/);
384
+
385
+ let cancelledPage;
386
+ const cancelled = await run(
387
+ [...input, '--mock', '--mock-scenario', 'timeout', '--format', 'ndjson'],
388
+ (event, child) => {
389
+ if (event.status === 'waiting_authorization') {
390
+ cancelledPage = event.pageUrl;
391
+ child.kill('SIGINT');
392
+ }
393
+ },
394
+ );
395
+ assert.equal(cancelled.code, 130, cancelled.stderr);
396
+ assert.equal(events(cancelled).slice(-1)[0].status, 'cancelled');
397
+ await assert.rejects(fetch(cancelledPage));
398
+ // Detached success pages expire without leaving a permanent background worker.
399
+ await new Promise(resolve => setTimeout(resolve, 2600));
400
+ await assert.rejects(fetch(final.pageUrl));
401
+ console.log('vibe upload process smoke passed');
402
+ }
403
+ main()
404
+ .catch(error => {
405
+ console.error(error);
406
+ process.exitCode = 1;
407
+ })
408
+ .finally(() => {
409
+ for (const child of children) child.kill('SIGKILL');
410
+ fixtureServer?.close();
411
+ fs.rmSync(temp, { recursive: true, force: true });
412
+ });
@@ -0,0 +1,53 @@
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 crypto = require('node:crypto');
6
+ const { spawnSync } = 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.mkdtempSync(path.join(os.tmpdir(), 'ttmg-wasm-smoke-'));
9
+ const input = path.join(root, 'original.wasm');
10
+ const wasm = Buffer.from('0061736d010000000105016000017f0304030000000404017000030707010372756e00000909010041000b030001020a1003040010010b0400412a0b040041070b', 'hex');
11
+ const sha = crypto.createHash('sha256').update(wasm).digest('hex');
12
+ function run(args, env = {}) {
13
+ const result = spawnSync(process.execPath, [entry, ...args, '--format', 'json'], {
14
+ cwd: root, encoding: 'utf8', timeout: 15000,
15
+ env: { ...process.env, TTMGRC_PATH: path.join(root, 'rc.json'), ...env },
16
+ });
17
+ assert.ifError(result.error);
18
+ assert.equal(result.stdout.trim().split('\n').length, 1, result.stdout + result.stderr);
19
+ const body = JSON.parse(result.stdout);
20
+ assert.equal(result.status, body.status === 'failed' ? 1 : 0, result.stderr);
21
+ return body;
22
+ }
23
+ try {
24
+ fs.writeFileSync(input, wasm);
25
+ const stub = path.join(root, 'missing-binding.js');
26
+ fs.writeFileSync(stub, "const Module=require('node:module'); const load=Module._load; Module._load=function(name,...args){if(name==='@anrans001/ttmg-wasmtool')throw new Error('fixture unavailable');return load.call(this,name,...args)};");
27
+ const unavailable = run(['wasm', 'prepare', '--input', input, '--output', path.join(root, 'missing')], { NODE_OPTIONS: '--require=' + stub });
28
+ assert.equal(unavailable.errorCode, 'WASM_BINDING_UNAVAILABLE');
29
+ assert.equal(fs.existsSync(path.join(root, 'missing')), false);
30
+ const preflight = run(['wasm', 'prepare', '--input', input, '--output', path.join(root, 'dry'), '--dry-run'], { NODE_OPTIONS: '--require=' + stub });
31
+ assert.equal(preflight.status, 'ready');
32
+ assert.equal(preflight.nativeBindingChecked, false);
33
+ assert.equal(fs.existsSync(path.join(root, 'dry')), false);
34
+ const collection = path.join(root, 'collection.json');
35
+ fs.writeFileSync(collection, JSON.stringify({ schemaVersion: '1', wasmSha256: sha, funcIds: [0, 1], bootFuncIds: [0] }));
36
+ const prepared = run(['wasm', 'prepare', '--input', input, '--output', path.join(root, 'prepared')]);
37
+ if (prepared.errorCode === 'WASM_BINDING_UNAVAILABLE') {
38
+ console.log('wasm process contract passed; native execution unavailable on this host, not verified');
39
+ } else {
40
+ assert.equal(prepared.status, 'succeeded', JSON.stringify(prepared));
41
+ const split = run(['wasm', 'split', '--input', input, '--collection', collection, '--output', path.join(root, 'split')]);
42
+ assert.equal(split.status, 'succeeded', JSON.stringify(split));
43
+ assert.equal(split.detail.totalWasmCount, 3);
44
+ assert.equal(split.detail.mainWasmCount, 2);
45
+ assert.equal(split.gameValidation, 'not_evaluated');
46
+ assert.equal(split.input.wasmSha256, sha);
47
+ assert.deepEqual(JSON.parse(fs.readFileSync(split.manifest, 'utf8')), split);
48
+ for (const artifact of split.artifacts) assert.equal(crypto.createHash('sha256').update(fs.readFileSync(artifact.path)).digest('hex'), artifact.sha256);
49
+ assert.deepEqual(fs.readFileSync(input), wasm);
50
+ assert.equal(fs.existsSync(path.join(root, 'rc.json')), false);
51
+ console.log(`wasm process contract passed: native prepare/split on ${process.platform}/${process.arch}, immutable input and verified artifacts`);
52
+ }
53
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
@@ -0,0 +1,63 @@
1
+ // Native Wasm operations run in a disposable process so a deadline can stop
2
+ // synchronous native code without blocking or corrupting the CLI owner.
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const zlib = require('node:zlib');
6
+
7
+ process.once('message', request => {
8
+ let response;
9
+ try {
10
+ let binding;
11
+ try {
12
+ binding = require('@anrans001/ttmg-wasmtool');
13
+ } catch {
14
+ throw Object.assign(new Error('Wasm tool binding is unavailable for this OS/architecture.'), {
15
+ code: 'WASM_BINDING_UNAVAILABLE',
16
+ });
17
+ }
18
+ if (request.operation === 'prepare') {
19
+ const output = path.join(request.output, 'prepared.wasm');
20
+ const result = binding.prepare(request.input, output);
21
+ if (!fs.existsSync(output) || result.outputSize <= 0)
22
+ throw new Error('Wasm preparation produced no output.');
23
+ fs.writeFileSync(output + '.br', zlib.brotliCompressSync(fs.readFileSync(output), {
24
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 9 },
25
+ }));
26
+ response = { ok: true, detail: { outputSize: result.outputSize, timeCost: result.timeCost } };
27
+ } else if (request.operation === 'split') {
28
+ const result = binding.split({
29
+ input: request.input,
30
+ outputDir: request.output,
31
+ funcIds: request.funcIds,
32
+ alwaysInclude: request.bootFuncIds,
33
+ archive: true,
34
+ compress: true,
35
+ quality: 9,
36
+ callClosure: true,
37
+ callIndirectClosure: false,
38
+ });
39
+ if (result.code !== 0) throw new Error(result.errMsg || 'Wasm split failed.');
40
+ if (![...request.funcIds, ...request.bootFuncIds].every(id => id < result.totalWasmCount))
41
+ throw Object.assign(new Error('Collection contains function IDs outside this Wasm module.'), {
42
+ code: 'WASM_COLLECTION_INVALID',
43
+ });
44
+ const detail = {};
45
+ for (const key of ['totalWasmCount', 'mainWasmCount', 'tableSize', 'timeCost',
46
+ 'globalVarList', 'collectFuncCount', 'alwaysIncludeAdded', 'closureAdded',
47
+ 'indirectClosureAdded', 'indirectClosureTypes', 'exportAdded']) detail[key] = result[key];
48
+ detail.outputFiles = [result.mainWasmPath, result.subWasmPath, result.funcMetaPath, result.archivePath]
49
+ .filter(Boolean).map(file => {
50
+ if (path.dirname(path.resolve(file)) !== path.resolve(request.output))
51
+ throw new Error('Native output escaped its assigned directory.');
52
+ return path.basename(file);
53
+ });
54
+ response = { ok: true, detail };
55
+ } else throw new Error('Unknown Wasm operation.');
56
+ response.detail.bindingVersion = require('@anrans001/ttmg-wasmtool/package.json').version;
57
+ response.detail.platform = process.platform;
58
+ response.detail.arch = process.arch;
59
+ } catch (error) {
60
+ response = { ok: false, errorCode: error.code || 'WASM_OPERATION_FAILED', error: error.message };
61
+ }
62
+ process.send(response, () => process.disconnect());
63
+ });
@@ -17,7 +17,8 @@ const messages = {
17
17
  'worker.compile.initOutputFailed':
18
18
  'Failed to initialize output directory: {error}',
19
19
  'worker.compile.buildFailed': 'Build failed',
20
- 'worker.update.noGlobalInstall': 'Global install of {pkgName} was not detected',
20
+ 'worker.update.noGlobalInstall':
21
+ 'Global install of {pkgName} was not detected',
21
22
  'worker.update.available': 'Update available!',
22
23
  'worker.update.changelog': 'Changelog:',
23
24
  'worker.update.toUpdate': 'To update, run:',
@@ -143,29 +144,32 @@ async function compile(context = {}) {
143
144
  parentPort?.postMessage({ type: 'status', status: 'start' });
144
145
 
145
146
  // 调用打包
146
- const { isSuccess, errorMsg, packages } = await debugPkgs({
147
- entry: entryDir,
148
- output: outputDir,
149
- dev: {
150
- enable: true,
151
- port: devPort,
152
- host: 'localhost',
153
- enableSourcemap: true,
154
- enableLog: false,
147
+ const { isSuccess, errorMsg, packages, checkResults } = await debugPkgs(
148
+ {
149
+ entry: entryDir,
150
+ output: outputDir,
151
+ dev: {
152
+ enable: true,
153
+ port: devPort,
154
+ host: 'localhost',
155
+ enableSourcemap: true,
156
+ enableLog: false,
157
+ },
158
+ build: {
159
+ enableAPICheck: true,
160
+ enableOdr: false,
161
+ pkgSizeLimit: 30 * 1024 * 1024,
162
+ mainPkgSizeLimit: 4 * 1024 * 1024,
163
+ independentSubPkgSizeLimit: 4 * 1024 * 1024,
164
+ },
165
+ // 如果 ttmg-pack 支持透传选项,可考虑加上 stdio/并发等设置:
166
+ // spawnOptions: { stdio: ['ignore', 'pipe', 'pipe'] },
167
+ // concurrency: 4,
155
168
  },
156
- build: {
157
- enableAPICheck: true,
158
- enableOdr: false,
159
- pkgSizeLimit: 30 * 1024 * 1024,
160
- mainPkgSizeLimit: 4 * 1024 * 1024,
161
- independentSubPkgSizeLimit: 4 * 1024 * 1024,
169
+ {
170
+ lang,
162
171
  },
163
- // 如果 ttmg-pack 支持透传选项,可考虑加上 stdio/并发等设置:
164
- // spawnOptions: { stdio: ['ignore', 'pipe', 'pipe'] },
165
- // concurrency: 4,
166
- }, {
167
- lang,
168
- });
172
+ );
169
173
  if (fs.existsSync(path.join(outputDir, '__TTMG_TEMP__'))) {
170
174
  fs.rmSync(path.join(outputDir, '__TTMG_TEMP__'), {
171
175
  recursive: true,
@@ -179,6 +183,7 @@ async function compile(context = {}) {
179
183
  status: 'end',
180
184
  isSuccess: false,
181
185
  errorMsg: errorMsg || t('worker.compile.buildFailed'),
186
+ checkResults: checkResults || [],
182
187
  });
183
188
  } else {
184
189
  parentPort?.postMessage({
@@ -186,6 +191,7 @@ async function compile(context = {}) {
186
191
  status: 'end',
187
192
  isSuccess: true,
188
193
  packages,
194
+ checkResults: checkResults || [],
189
195
  });
190
196
  }
191
197
  } catch (err) {