-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathmigrate-meta.e2e.test.ts
More file actions
542 lines (500 loc) · 24.1 KB
/
Copy pathmigrate-meta.e2e.test.ts
File metadata and controls
542 lines (500 loc) · 24.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* `os migrate meta` — end-to-end over the REAL CLI process (ADR-0087 D3/D5).
*
* The chain's transforms are fixture-tested in @objectstack/spec
* (conversions.test.ts, migrations.test.ts), but the COMMAND path — config
* loading via bundleRequire, the convert:false normalization that keeps the
* chain attributable, the schema-validity verdict, the machine JSON shape,
* `--out` snapshots, and the support-floor refusal — had no test at all.
* This spawns `bin/run-dev.js` against a temp project authored in the
* PRE-protocol-17 dialect, exercising one key from every v17 conversion
* family in a single pass, exactly the cross-major "consumer upgrades"
* scenario the command exists for.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { ObjectStackDefinitionSchema } from '@objectstack/spec';
import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
import { childEnv } from './helpers/serve-process.js';
const execFileP = promisify(execFile);
const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
/**
* A stack authored against protocol 16: every line marked `// 16:` is a shape
* the v17 chain must rewrite, spanning each conversion family — renames
* (action execute→target, sharing full→edit) and the #3896 close-out removals (rls.priority, the four
* tool keys, flow active/template/outputSchema/fallbackNodeId, view/dashboard
* inert keys, agent.knowledge, skill.triggerPhrases).
*/
const PRE17_CONFIG = `
export default {
// #8687: top-level name/label were never stack keys (silently stripped
// before the strict close, refused now) — the identity lives in manifest.
manifest: { id: 'migrate_meta_e2e', name: 'Migrate Meta E2E', version: '1.0.0', type: 'app' },
objects: [{
name: 'e2e_ticket',
label: 'Ticket',
fields: {
title: { type: 'text', label: 'Title', required: true }, // 16: required implied NOT NULL — v17 does NOT write that down (#16693)
notes: { type: 'textarea', label: 'Notes' },
},
}],
actions: [{
name: 'close_ticket',
label: 'Close',
type: 'script',
execute: 'closeHandler', // 16: renamed to target
shortcut: 'Ctrl+K', // 16: removed (never dispatched)
bulkEnabled: true, // 16: removed (toolbar reads view bulkActions)
}],
flows: [{
name: 'e2e_flow',
label: 'E2E Flow',
type: 'autolaunched',
active: false, // 16: removed (status is the lifecycle)
template: true, // 16: removed (no reader)
// 16: fallbackNodeId removed (fault edges own this). maxRetries is stated
// because #4247 refuses a zero-attempt 'retry' — the count is the one v17
// flow change the chain surfaces as a semantic TODO instead of rewriting.
errorHandling: { strategy: 'retry', maxRetries: 2, fallbackNodeId: 'n9' },
nodes: [
{ id: 'n1', type: 'start', label: 'Start', outputSchema: { ok: { type: 'boolean' } } }, // 16: removed
{ id: 'n2', type: 'delete_record', label: 'Purge', config: { objectName: 'e2e_ticket', filter: { done: true } } }, // canonical since protocol 11 — must pass through untouched
],
edges: [],
}],
views: [{
object: 'e2e_ticket',
list: { type: 'grid', columns: ['title'], responsive: { hiddenOn: ['xs'] }, performance: { lazyLoad: true } }, // 16: removed
form: { type: 'simple', sections: [{ fields: ['title'] }], defaultSort: [{ field: 'title' }], aria: { label: 'Ticket' } }, // 16: removed
}],
dashboards: [{
name: 'e2e_kpis',
label: 'E2E KPIs',
aria: { label: 'KPIs' }, // 16: removed
performance: { prefetch: true },// 16: removed
widgets: [],
}],
agents: [{
name: 'e2e_agent',
label: 'Agent',
role: 'Helper',
instructions: 'help',
knowledge: { sources: ['faq'] }, // 16: removed (never scoped retrieval)
}],
skills: [{
name: 'e2e_skill',
label: 'Skill',
tools: ['query_records'],
triggerPhrases: ['do the thing'], // 16: removed (never matched)
}],
tools: [{
name: 'e2e_tool',
label: 'Tool',
description: 'A tool',
parameters: { type: 'object' },
category: 'action', // 16: removed
permissions: ['x.y'], // 16: removed (gated nothing)
active: true, // 16: removed (withdrew nothing)
builtIn: false, // 16: removed
}],
permissions: [{
name: 'e2e_set',
label: 'Set',
objects: { e2e_ticket: { allowRead: true } },
rowLevelSecurity: [{
name: 'own_rows',
object: 'e2e_ticket',
operation: 'select',
using: 'owner = current_user.id',
enabled: true,
priority: 10, // 16: removed (no conflict to order)
}],
}],
sharingRules: [{
name: 'share_hot',
type: 'criteria',
object: 'e2e_ticket',
label: 'Hot tickets',
condition: "record.hot == true",
sharedWith: { type: 'team', value: 'support' },
accessLevel: 'full', // 16: full→edit (walked at the TOP-LEVEL collection)
}],
};
`;
/** Conversion ids the run MUST attribute at least one rewrite to. */
const EXPECTED_CONVERSIONS = [
'action-execute-to-target',
'action-inert-keys-removed',
'flow-inert-keys-removed',
'view-inert-keys-removed',
'dashboard-inert-keys-removed',
'agent-knowledge-removed',
'skill-trigger-phrases-removed',
'tool-inert-authoring-keys-removed',
'permission-rls-priority-removed',
'sharing-rule-access-level-full-to-edit',
];
let dir: string;
let out: { stdout: string; parsed: any };
async function runMeta(args: string[], cwd: string) {
const { stdout } = await execFileP(TSX, [CLI, 'migrate', 'meta', ...args], {
cwd,
maxBuffer: 16 * 1024 * 1024,
env: childEnv({ NO_COLOR: '1' }),
});
return stdout;
}
beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-migrate-meta-e2e-'));
writeFileSync(join(dir, 'objectstack.config.ts'), PRE17_CONFIG);
const stdout = await runMeta(['--from', '16', '--json', '--out', join(dir, 'migrated.json')], dir);
out = { stdout, parsed: JSON.parse(stdout) };
}, 120_000);
afterAll(() => {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('os migrate meta --from 16 (e2e over the real CLI)', () => {
it('replays the v17 chain and reports schema-valid output', () => {
expect(out.parsed.from).toBe(16);
expect(out.parsed.schemaValid).toBe(true);
});
it('attributes at least one rewrite to every expected v17 conversion family', () => {
const ids = new Set(out.parsed.applied.map((a: any) => a.conversionId));
for (const id of EXPECTED_CONVERSIONS) {
expect(ids.has(id), `expected a rewrite from ${id}; got ${[...ids].join(', ')}`).toBe(true);
}
});
/**
* ⛔ The negative half of the assertion above (#16693). A conversion that
* stamps `storage: { notNull: true }` onto every `required: true` field
* asserts the implication ADR-0113 abolished, so `migrate meta` must
* attribute NOTHING to it — on a source (`title`) that would have triggered
* it, which is what keeps this from passing vacuously.
*/
it('attributes nothing to the withdrawn required→storage.notNull conversion', () => {
const ids = new Set(out.parsed.applied.map((a: any) => a.conversionId));
expect(ids.has('field-required-notnull-explicit')).toBe(false);
// Anti-vacuity: the same run DID attribute rewrites, so an empty `applied`
// cannot be what makes the line above green.
expect(ids.size).toBeGreaterThan(0);
});
it('surfaces the semantic TODOs instead of auto-applying them', () => {
expect(Array.isArray(out.parsed.todos)).toBe(true);
expect(out.parsed.todos.length).toBeGreaterThan(0);
for (const t of out.parsed.todos) {
expect(t.reason?.length).toBeGreaterThan(0);
expect(t.acceptanceCriteria?.length).toBeGreaterThan(0);
}
});
it('the --out snapshot re-parses under the CURRENT schema and carries the rewrites', () => {
const snap = JSON.parse(readFileSync(join(dir, 'migrated.json'), 'utf-8'));
const parsed = ObjectStackDefinitionSchema.safeParse(snap);
expect(parsed.success, JSON.stringify(parsed.success ? '' : parsed.error.issues.slice(0, 3))).toBe(true);
// Spot-check one rewrite per family on the snapshot itself.
expect(snap.actions[0].target).toBe('closeHandler');
expect(snap.actions[0].execute).toBeUndefined();
expect(snap.actions[0].shortcut).toBeUndefined();
expect(snap.flows[0].active).toBeUndefined();
expect(snap.flows[0].errorHandling.fallbackNodeId).toBeUndefined();
expect(snap.flows[0].nodes[0].outputSchema).toBeUndefined();
expect(snap.flows[0].nodes[1].config.filter, 'canonical key passes through untouched').toEqual({ done: true });
expect(snap.views[0].list.responsive).toBeUndefined();
expect(snap.views[0].form.defaultSort).toBeUndefined();
expect(snap.dashboards[0].aria).toBeUndefined();
expect(snap.agents[0].knowledge).toBeUndefined();
expect(snap.skills[0].triggerPhrases).toBeUndefined();
expect(snap.tools[0].category).toBeUndefined();
expect(snap.permissions[0].rowLevelSecurity[0].priority).toBeUndefined();
expect(snap.sharingRules[0].accessLevel).toBe('edit');
// ⛔ ADR-0113, #16693: the chain does NOT write a column constraint for the
// author. `required: true` crosses 16→17 as the write-time contract and
// nothing else, so `title` comes out with NO `storage` block — exactly like
// the optional field beside it. The withdrawn `field-required-notnull-
// explicit` conversion used to make this line read `{ notNull: true }`, and
// an app that followed the boot warning it raised was performing a
// destructive `tighten_not_null` migration on a populated database while
// believing it was clearing a deprecation notice.
expect(snap.objects[0].fields.title.required, 'the write contract survives the chain').toBe(true);
expect(snap.objects[0].fields.title.storage, 'no column constraint is invented').toBeUndefined();
expect(snap.objects[0].fields.notes.storage).toBeUndefined();
});
it('is idempotent: replaying the chain on the migrated snapshot applies zero changes', async () => {
const dir2 = mkdtempSync(join(tmpdir(), 'os-migrate-meta-e2e2-'));
try {
const snap = readFileSync(join(dir, 'migrated.json'), 'utf-8');
writeFileSync(join(dir2, 'objectstack.config.ts'), `export default ${snap};`);
const stdout = await runMeta(['--from', '16', '--json'], dir2);
const again = JSON.parse(stdout);
expect(again.applied).toEqual([]);
expect(again.schemaValid).toBe(true);
} finally {
try { rmSync(dir2, { recursive: true, force: true }); } catch { /* ignore */ }
}
}, 120_000);
/**
* ADR-0104 / #3438. The metadata half of a 16→17 upgrade is only half of it:
* two DATA migrations gate enforcement per deployment, and a gate nobody is
* told about is served by nobody. The advice is scoped to the field classes
* the author actually declares, so it is never noise.
*/
describe('per-deployment data migrations (#3438)', () => {
const withFields = (fields: string) => `
export default {
name: 'dm_probe', label: 'DM Probe',
objects: [{ name: 'dm_thing', label: 'Thing', fields: { ${fields} } }],
};
`;
async function runJson(fields: string, args: string[] = ['--from', '16', '--json']) {
const d = mkdtempSync(join(tmpdir(), 'os-migrate-meta-dm-'));
try {
writeFileSync(join(d, 'objectstack.config.ts'), withFields(fields));
return JSON.parse(await runMeta(args, d));
} finally {
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
it('names the file migration when the metadata declares a media field', async () => {
const res = await runJson(`cover: { type: 'image', label: 'Cover' }`);
expect(res.dataMigrations.map((m: any) => m.id)).toEqual(['adr-0104-file-references']);
}, 120_000);
it('names the value-shape scan when the metadata declares a covered field', async () => {
const res = await runJson(`spot: { type: 'location', label: 'Spot' }`);
expect(res.dataMigrations.map((m: any) => m.id)).toEqual(['adr-0104-value-shapes']);
}, 120_000);
it('names both when both classes are declared', async () => {
const res = await runJson(
`cover: { type: 'image', label: 'Cover' }, spot: { type: 'location', label: 'Spot' }`,
);
expect(res.dataMigrations.map((m: any) => m.id).sort()).toEqual([
'adr-0104-file-references',
'adr-0104-value-shapes',
]);
}, 120_000);
it('stays silent for metadata that declares neither class', async () => {
const res = await runJson(`title: { type: 'text', label: 'Title' }`);
expect(res.dataMigrations).toEqual([]);
}, 120_000);
it('says nothing on a chain that does not cross into 17', async () => {
const res = await runJson(`cover: { type: 'image', label: 'Cover' }`, ['--from', '17', '--json']);
expect(res.dataMigrations).toEqual([]);
}, 120_000);
it('ends the human-readable upgrade with the advice, not just the JSON', async () => {
// Where an operator actually reads it. The advice names the command and
// says the run is dry by default, so nothing here reads as "do this and
// something irreversible happens".
const d = mkdtempSync(join(tmpdir(), 'os-migrate-meta-dm-h-'));
try {
writeFileSync(join(d, 'objectstack.config.ts'), withFields(`cover: { type: 'image', label: 'Cover' }`));
const stdout = await runMeta(['--from', '16'], d);
expect(stdout).toMatch(/os migrate files-to-references/);
expect(stdout).toMatch(/dry-run by default/);
} finally {
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
}
}, 120_000);
});
it('refuses a --from below the support floor with the structured error', async () => {
let failed = false;
try {
await runMeta(['--from', '9', '--json'], dir);
} catch (e: any) {
failed = true;
const body = JSON.parse(String(e.stdout || '{}'));
expect(body.error).toBe('unsupported_from_major');
expect(body.message).toMatch(/floor|support/i);
}
expect(failed, 'expected a non-zero exit below the floor').toBe(true);
}, 120_000);
});
/**
* #9418 — the input class the codemod EXISTS for: a source carrying a RETIRED
* key, migrated end-to-end.
*
* The suite above deliberately does not reach this. Its fixture is a bare
* `export default { … }` object literal, and a bare literal is validated by
* nobody at load — so it passed on a build where `os migrate meta` could not
* open a single real project. A real `objectstack.config.ts` runs the CURRENT
* schema itself: `os init` scaffolds `export default defineStack({ … })`, every
* `define*` helper is a `Schema.parse()`, and a retired key is a `retiredKey()`
* tombstone — `z.never()` carrying the upgrade prescription — so the parse
* REJECTS it rather than stripping it. The refusal therefore happened while the
* config module was being evaluated inside the load, before the command reached
* its first conversion, and the message it printed was the very prescription
* telling the author to run this command.
*
* So the fixture here is deliberately shaped like a real project and not like a
* test: `defineStack` at the root, per-artifact helpers imported from a spec
* SUBPATH (`@objectstack/spec/ai`, the spelling the example apps use), and the
* retired keys authored inside them. A `defineStack`-only tolerance passes the
* first and fails the last two.
*
* The second test is the other half of the pin, and it is the one that keeps
* this a restoration rather than a widening: `os validate` on the SAME fixture
* must still refuse. The tolerance belongs to the codemod alone — for every
* other command the tombstone is the upgrade channel.
*/
describe('os migrate meta over a source carrying a retired key (#9418)', () => {
/**
* Resolved through node_modules rather than by walking up from this file:
* `packages/cli` already depends on `@objectstack/spec`, so the dependency is
* one turbo already knows about, and a package specifier is not a
* cross-package source read.
*/
const SPEC_PACKAGE_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/spec/package.json'));
const RETIRED_KEY_CONFIG = `
import { defineStack } from '@objectstack/spec';
import { defineAgent, defineSkill } from '@objectstack/spec/ai';
export default defineStack({
manifest: { id: 'retired_key_e2e', name: 'Retired Key E2E', version: '1.0.0', type: 'app', namespace: 'rk' },
objects: [{
name: 'rk_ticket',
label: 'Ticket',
fields: { title: { type: 'text', label: 'Title' } },
}],
agents: [defineAgent({
name: 'rk_agent',
label: 'Agent',
role: 'Helper',
instructions: 'help',
knowledge: { sources: ['faq'] }, // RETIRED in protocol 17 — the whole point
})],
skills: [defineSkill({
name: 'rk_skill',
label: 'Skill',
tools: ['query_records'],
triggerPhrases: ['do the thing'], // RETIRED in protocol 17
})],
});
`;
let rkDir: string;
let specLink: string;
beforeAll(() => {
rkDir = mkdtempSync(join(tmpdir(), 'os-migrate-meta-retired-'));
mkdirSync(join(rkDir, 'node_modules', '@objectstack'), { recursive: true });
specLink = join(rkDir, 'node_modules', '@objectstack', 'spec');
symlinkSync(SPEC_PACKAGE_ROOT, specLink, 'dir');
writeFileSync(join(rkDir, 'objectstack.config.ts'), RETIRED_KEY_CONFIG);
});
afterAll(() => {
// Unlinked BEFORE the recursive remove, and named explicitly: this symlink
// points at the real `packages/spec` in the checkout, and the one thing
// that must never be ambiguous in a cleanup is whether it can follow it.
try { unlinkSync(specLink); } catch { /* already gone */ }
try { rmSync(rkDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('loads it, converts it, and reports the migrated stack schema-valid', async () => {
const stdout = await runMeta(['--from', '16', '--json', '--out', join(rkDir, 'migrated.json')], rkDir);
const parsed = JSON.parse(stdout);
const appliedAt = new Map<string, string>(
parsed.applied.map((a: any) => [a.conversionId, a.path] as [string, string]),
);
// Authored through `defineStack`'s own inline literal…
expect(parsed.from).toBe(16);
// …and through helpers imported from a spec subpath, which is where a
// tolerance scoped to `defineStack` alone would still have refused.
expect(appliedAt.get('agent-knowledge-removed')).toBe('agents[0].knowledge');
expect(appliedAt.get('skill-trigger-phrases-removed')).toBe('skills[0].triggerPhrases');
expect(parsed.schemaValid).toBe(true);
const snap = JSON.parse(readFileSync(join(rkDir, 'migrated.json'), 'utf-8'));
expect(snap.agents[0].knowledge).toBeUndefined();
expect(snap.skills[0].triggerPhrases).toBeUndefined();
// The rest of the artifact survives the tolerant load intact — the codemod
// removes what retired, not what it could not parse.
expect(snap.agents[0].name).toBe('rk_agent');
expect(snap.skills[0].tools).toEqual(['query_records']);
}, 180_000);
it('still refuses the same source everywhere else — `os validate` keeps the prescription', async () => {
let refused = false;
try {
await execFileP(TSX, [CLI, 'validate'], {
cwd: rkDir,
maxBuffer: 16 * 1024 * 1024,
env: childEnv({ NO_COLOR: '1' }),
});
} catch (e: any) {
refused = true;
const output = `${String(e.stdout ?? '')}${String(e.stderr ?? '')}`;
expect(output).toMatch(/`agent\.knowledge` was removed/);
expect(output).toMatch(/os migrate meta --from 16/);
}
expect(refused, '`os validate` must still reject a retired key').toBe(true);
}, 180_000);
});
/**
* The chain line states this build's protocol in the protocol's own units.
*
* `PROTOCOL_VERSION` is the protocol major padded to a semver ('17.0.0') and is
* never the installed package version. Printed here as a bare semver under the
* word "runtime", it read as one: on a 17.3.0 install the operator saw
* "runtime 17.0.0" beside the real package versions of the same upgrade
* session, which reads as an apparent downgrade or a stale install. Nothing
* about the VALUE was wrong; the label and the semver FORM were.
*
* Both halves are asserted, because either one alone is satisfiable the wrong
* way. A line that merely stopped saying "runtime" could still print the padded
* semver in a version position; and a line that dropped the parenthetical
* altogether would lose the one fact it carries -- with `--to` stopping below
* this build's major it is the only place the operator is told where the
* runtime actually stands, which is why the third case drives exactly that.
*
* The `--json` half is pinned by the last case, and that pin was RE-POINTED
* rather than deleted when the key moved: it used to hold `runtime` unchanged,
* and it now holds `protocolVersion` carrying the value AND `runtime` being
* absent. Both halves are asserted for the same reason the human line asserts
* two: a pin that only checked the new key would stay green if the old spelling
* were quietly re-added alongside, which is precisely the dual-key state this
* rename was ruled against. Keeping the pin pointed at the live key is what
* makes the NEXT rename of this published payload loud instead of silent.
*/
describe('os migrate meta — the chain line names the protocol, not a package version', () => {
const LABEL_CONFIG = `
export default {
manifest: { id: 'chain_label_e2e', name: 'Chain Label E2E', version: '1.0.0', type: 'app' },
objects: [{ name: 'label_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }],
};
`;
let labelDir: string;
beforeAll(() => {
labelDir = mkdtempSync(join(tmpdir(), 'os-migrate-meta-label-'));
writeFileSync(join(labelDir, 'objectstack.config.ts'), LABEL_CONFIG);
});
afterAll(() => {
try { rmSync(labelDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it("names this build's protocol major, in majors", async () => {
const stdout = await runMeta(['--from', String(PROTOCOL_MAJOR)], labelDir);
expect(stdout).toContain(
`Chain: protocol ${PROTOCOL_MAJOR} → ${PROTOCOL_MAJOR} (this runtime implements protocol ${PROTOCOL_MAJOR})`,
);
}, 120_000);
it('prints no padded protocol semver in the human output, under any label', async () => {
const stdout = await runMeta(['--from', String(PROTOCOL_MAJOR)], labelDir);
expect(stdout).not.toContain(PROTOCOL_VERSION);
expect(stdout).not.toMatch(/runtime \d+\.\d+\.\d+/);
}, 120_000);
it('still says where the runtime stands when --to stops below this build\'s major', async () => {
const below = PROTOCOL_MAJOR - 1;
const stdout = await runMeta(['--from', String(below), '--to', String(below)], labelDir);
expect(stdout).toContain(
`Chain: protocol ${below} → ${below} (this runtime implements protocol ${PROTOCOL_MAJOR})`,
);
expect(stdout).not.toContain(PROTOCOL_VERSION);
}, 120_000);
it('emits the protocol version under `protocolVersion`, with no `runtime` key left', async () => {
const parsed = JSON.parse(await runMeta(['--from', String(PROTOCOL_MAJOR), '--json'], labelDir));
expect(parsed.protocolVersion).toBe(PROTOCOL_VERSION);
// Removed OUTRIGHT -- no alias, no dual-key grace window. `in` rather than
// a truthiness check: an explicit `runtime: undefined` would satisfy the
// latter while still shipping the key through `JSON.stringify`'s omission.
expect(Object.keys(parsed)).not.toContain('runtime');
}, 120_000);
});