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