-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathmigrate-meta.e2e.test.ts
More file actions
248 lines (232 loc) · 9.93 KB
/
Copy pathmigrate-meta.e2e.test.ts
File metadata and controls
248 lines (232 loc) · 9.93 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
// 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, rmSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ObjectStackDefinitionSchema } from '@objectstack/spec';
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), the required→storage.notNull
* explicitization, 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 {
name: 'migrate_meta_e2e',
label: 'Migrate Meta E2E',
objects: [{
name: 'e2e_ticket',
label: 'Ticket',
fields: {
title: { type: 'text', label: 'Title', required: true }, // 16: required implied NOT NULL
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',
'field-required-notnull-explicit',
'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: { ...process.env, 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);
}
});
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 explicitization: the pre-17 required field carries its column
// constraint in writing; the optional field gains nothing.
expect(snap.objects[0].fields.title.storage).toEqual({ notNull: true });
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);
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);
});