|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `os migrate meta` — end-to-end over the REAL CLI process (ADR-0087 D3/D5). |
| 5 | + * |
| 6 | + * The chain's transforms are fixture-tested in @objectstack/spec |
| 7 | + * (conversions.test.ts, migrations.test.ts), but the COMMAND path — config |
| 8 | + * loading via bundleRequire, the convert:false normalization that keeps the |
| 9 | + * chain attributable, the schema-validity verdict, the machine JSON shape, |
| 10 | + * `--out` snapshots, and the support-floor refusal — had no test at all. |
| 11 | + * This spawns `bin/run-dev.js` against a temp project authored in the |
| 12 | + * PRE-protocol-17 dialect, exercising one key from every v17 conversion |
| 13 | + * family in a single pass, exactly the cross-major "consumer upgrades" |
| 14 | + * scenario the command exists for. |
| 15 | + */ |
| 16 | + |
| 17 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 18 | +import { execFile } from 'node:child_process'; |
| 19 | +import { promisify } from 'node:util'; |
| 20 | +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; |
| 21 | +import { tmpdir } from 'node:os'; |
| 22 | +import { join, resolve } from 'node:path'; |
| 23 | +import { fileURLToPath } from 'node:url'; |
| 24 | +import { ObjectStackDefinitionSchema } from '@objectstack/spec'; |
| 25 | + |
| 26 | +const execFileP = promisify(execFile); |
| 27 | +const HERE = resolve(fileURLToPath(import.meta.url), '..'); |
| 28 | +const CLI = resolve(HERE, '../bin/run-dev.js'); |
| 29 | +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); |
| 30 | + |
| 31 | +/** |
| 32 | + * A stack authored against protocol 16: every line marked `// 16:` is a shape |
| 33 | + * the v17 chain must rewrite, spanning each conversion family — renames |
| 34 | + * (action execute→target, sharing full→edit), the required→storage.notNull |
| 35 | + * explicitization, and the #3896 close-out removals (rls.priority, the four |
| 36 | + * tool keys, flow active/template/outputSchema/fallbackNodeId, view/dashboard |
| 37 | + * inert keys, agent.knowledge, skill.triggerPhrases). |
| 38 | + */ |
| 39 | +const PRE17_CONFIG = ` |
| 40 | +export default { |
| 41 | + name: 'migrate_meta_e2e', |
| 42 | + label: 'Migrate Meta E2E', |
| 43 | + objects: [{ |
| 44 | + name: 'e2e_ticket', |
| 45 | + label: 'Ticket', |
| 46 | + fields: { |
| 47 | + title: { type: 'text', label: 'Title', required: true }, // 16: required implied NOT NULL |
| 48 | + notes: { type: 'textarea', label: 'Notes' }, |
| 49 | + }, |
| 50 | + }], |
| 51 | + actions: [{ |
| 52 | + name: 'close_ticket', |
| 53 | + label: 'Close', |
| 54 | + type: 'script', |
| 55 | + execute: 'closeHandler', // 16: renamed to target |
| 56 | + shortcut: 'Ctrl+K', // 16: removed (never dispatched) |
| 57 | + bulkEnabled: true, // 16: removed (toolbar reads view bulkActions) |
| 58 | + }], |
| 59 | + flows: [{ |
| 60 | + name: 'e2e_flow', |
| 61 | + label: 'E2E Flow', |
| 62 | + type: 'autolaunched', |
| 63 | + active: false, // 16: removed (status is the lifecycle) |
| 64 | + template: true, // 16: removed (no reader) |
| 65 | + errorHandling: { strategy: 'retry', fallbackNodeId: 'n9' }, // 16: fault edges own this |
| 66 | + nodes: [ |
| 67 | + { id: 'n1', type: 'start', label: 'Start', outputSchema: { ok: { type: 'boolean' } } }, // 16: removed |
| 68 | + { id: 'n2', type: 'delete_record', label: 'Purge', config: { objectName: 'e2e_ticket', filter: { done: true } } }, // canonical since protocol 11 — must pass through untouched |
| 69 | + ], |
| 70 | + edges: [], |
| 71 | + }], |
| 72 | + views: [{ |
| 73 | + object: 'e2e_ticket', |
| 74 | + list: { type: 'grid', columns: ['title'], responsive: { hiddenOn: ['xs'] }, performance: { lazyLoad: true } }, // 16: removed |
| 75 | + form: { type: 'simple', sections: [{ fields: ['title'] }], defaultSort: [{ field: 'title' }], aria: { label: 'Ticket' } }, // 16: removed |
| 76 | + }], |
| 77 | + dashboards: [{ |
| 78 | + name: 'e2e_kpis', |
| 79 | + label: 'E2E KPIs', |
| 80 | + aria: { label: 'KPIs' }, // 16: removed |
| 81 | + performance: { prefetch: true },// 16: removed |
| 82 | + widgets: [], |
| 83 | + }], |
| 84 | + agents: [{ |
| 85 | + name: 'e2e_agent', |
| 86 | + label: 'Agent', |
| 87 | + role: 'Helper', |
| 88 | + instructions: 'help', |
| 89 | + knowledge: { sources: ['faq'] }, // 16: removed (never scoped retrieval) |
| 90 | + }], |
| 91 | + skills: [{ |
| 92 | + name: 'e2e_skill', |
| 93 | + label: 'Skill', |
| 94 | + tools: ['query_records'], |
| 95 | + triggerPhrases: ['do the thing'], // 16: removed (never matched) |
| 96 | + }], |
| 97 | + tools: [{ |
| 98 | + name: 'e2e_tool', |
| 99 | + label: 'Tool', |
| 100 | + description: 'A tool', |
| 101 | + parameters: { type: 'object' }, |
| 102 | + category: 'action', // 16: removed |
| 103 | + permissions: ['x.y'], // 16: removed (gated nothing) |
| 104 | + active: true, // 16: removed (withdrew nothing) |
| 105 | + builtIn: false, // 16: removed |
| 106 | + }], |
| 107 | + permissions: [{ |
| 108 | + name: 'e2e_set', |
| 109 | + label: 'Set', |
| 110 | + objects: { e2e_ticket: { allowRead: true } }, |
| 111 | + rowLevelSecurity: [{ |
| 112 | + name: 'own_rows', |
| 113 | + object: 'e2e_ticket', |
| 114 | + operation: 'select', |
| 115 | + using: 'owner = current_user.id', |
| 116 | + enabled: true, |
| 117 | + priority: 10, // 16: removed (no conflict to order) |
| 118 | + }], |
| 119 | + }], |
| 120 | + sharingRules: [{ |
| 121 | + name: 'share_hot', |
| 122 | + type: 'criteria', |
| 123 | + object: 'e2e_ticket', |
| 124 | + label: 'Hot tickets', |
| 125 | + condition: "record.hot == true", |
| 126 | + sharedWith: { type: 'team', value: 'support' }, |
| 127 | + accessLevel: 'full', // 16: full→edit (walked at the TOP-LEVEL collection) |
| 128 | + }], |
| 129 | +}; |
| 130 | +`; |
| 131 | + |
| 132 | +/** Conversion ids the run MUST attribute at least one rewrite to. */ |
| 133 | +const EXPECTED_CONVERSIONS = [ |
| 134 | + 'action-execute-to-target', |
| 135 | + 'action-inert-keys-removed', |
| 136 | + 'flow-inert-keys-removed', |
| 137 | + 'view-inert-keys-removed', |
| 138 | + 'dashboard-inert-keys-removed', |
| 139 | + 'agent-knowledge-removed', |
| 140 | + 'skill-trigger-phrases-removed', |
| 141 | + 'tool-inert-authoring-keys-removed', |
| 142 | + 'permission-rls-priority-removed', |
| 143 | + 'field-required-notnull-explicit', |
| 144 | + 'sharing-rule-access-level-full-to-edit', |
| 145 | +]; |
| 146 | + |
| 147 | +let dir: string; |
| 148 | +let out: { stdout: string; parsed: any }; |
| 149 | + |
| 150 | +async function runMeta(args: string[], cwd: string) { |
| 151 | + const { stdout } = await execFileP(TSX, [CLI, 'migrate', 'meta', ...args], { |
| 152 | + cwd, |
| 153 | + maxBuffer: 16 * 1024 * 1024, |
| 154 | + env: { ...process.env, NO_COLOR: '1' }, |
| 155 | + }); |
| 156 | + return stdout; |
| 157 | +} |
| 158 | + |
| 159 | +beforeAll(async () => { |
| 160 | + dir = mkdtempSync(join(tmpdir(), 'os-migrate-meta-e2e-')); |
| 161 | + writeFileSync(join(dir, 'objectstack.config.ts'), PRE17_CONFIG); |
| 162 | + const stdout = await runMeta(['--from', '16', '--json', '--out', join(dir, 'migrated.json')], dir); |
| 163 | + out = { stdout, parsed: JSON.parse(stdout) }; |
| 164 | +}, 120_000); |
| 165 | + |
| 166 | +afterAll(() => { |
| 167 | + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } |
| 168 | +}); |
| 169 | + |
| 170 | +describe('os migrate meta --from 16 (e2e over the real CLI)', () => { |
| 171 | + it('replays the v17 chain and reports schema-valid output', () => { |
| 172 | + expect(out.parsed.from).toBe(16); |
| 173 | + expect(out.parsed.schemaValid).toBe(true); |
| 174 | + }); |
| 175 | + |
| 176 | + it('attributes at least one rewrite to every expected v17 conversion family', () => { |
| 177 | + const ids = new Set(out.parsed.applied.map((a: any) => a.conversionId)); |
| 178 | + for (const id of EXPECTED_CONVERSIONS) { |
| 179 | + expect(ids.has(id), `expected a rewrite from ${id}; got ${[...ids].join(', ')}`).toBe(true); |
| 180 | + } |
| 181 | + }); |
| 182 | + |
| 183 | + it('surfaces the semantic TODOs instead of auto-applying them', () => { |
| 184 | + expect(Array.isArray(out.parsed.todos)).toBe(true); |
| 185 | + expect(out.parsed.todos.length).toBeGreaterThan(0); |
| 186 | + for (const t of out.parsed.todos) { |
| 187 | + expect(t.reason?.length).toBeGreaterThan(0); |
| 188 | + expect(t.acceptanceCriteria?.length).toBeGreaterThan(0); |
| 189 | + } |
| 190 | + }); |
| 191 | + |
| 192 | + it('the --out snapshot re-parses under the CURRENT schema and carries the rewrites', () => { |
| 193 | + const snap = JSON.parse(readFileSync(join(dir, 'migrated.json'), 'utf-8')); |
| 194 | + const parsed = ObjectStackDefinitionSchema.safeParse(snap); |
| 195 | + expect(parsed.success, JSON.stringify(parsed.success ? '' : parsed.error.issues.slice(0, 3))).toBe(true); |
| 196 | + |
| 197 | + // Spot-check one rewrite per family on the snapshot itself. |
| 198 | + expect(snap.actions[0].target).toBe('closeHandler'); |
| 199 | + expect(snap.actions[0].execute).toBeUndefined(); |
| 200 | + expect(snap.actions[0].shortcut).toBeUndefined(); |
| 201 | + expect(snap.flows[0].active).toBeUndefined(); |
| 202 | + expect(snap.flows[0].errorHandling.fallbackNodeId).toBeUndefined(); |
| 203 | + expect(snap.flows[0].nodes[0].outputSchema).toBeUndefined(); |
| 204 | + expect(snap.flows[0].nodes[1].config.filter, 'canonical key passes through untouched').toEqual({ done: true }); |
| 205 | + expect(snap.views[0].list.responsive).toBeUndefined(); |
| 206 | + expect(snap.views[0].form.defaultSort).toBeUndefined(); |
| 207 | + expect(snap.dashboards[0].aria).toBeUndefined(); |
| 208 | + expect(snap.agents[0].knowledge).toBeUndefined(); |
| 209 | + expect(snap.skills[0].triggerPhrases).toBeUndefined(); |
| 210 | + expect(snap.tools[0].category).toBeUndefined(); |
| 211 | + expect(snap.permissions[0].rowLevelSecurity[0].priority).toBeUndefined(); |
| 212 | + expect(snap.sharingRules[0].accessLevel).toBe('edit'); |
| 213 | + // ADR-0113 explicitization: the pre-17 required field carries its column |
| 214 | + // constraint in writing; the optional field gains nothing. |
| 215 | + expect(snap.objects[0].fields.title.storage).toEqual({ notNull: true }); |
| 216 | + expect(snap.objects[0].fields.notes.storage).toBeUndefined(); |
| 217 | + }); |
| 218 | + |
| 219 | + it('is idempotent: replaying the chain on the migrated snapshot applies zero changes', async () => { |
| 220 | + const dir2 = mkdtempSync(join(tmpdir(), 'os-migrate-meta-e2e2-')); |
| 221 | + try { |
| 222 | + const snap = readFileSync(join(dir, 'migrated.json'), 'utf-8'); |
| 223 | + writeFileSync(join(dir2, 'objectstack.config.ts'), `export default ${snap};`); |
| 224 | + const stdout = await runMeta(['--from', '16', '--json'], dir2); |
| 225 | + const again = JSON.parse(stdout); |
| 226 | + expect(again.applied).toEqual([]); |
| 227 | + expect(again.schemaValid).toBe(true); |
| 228 | + } finally { |
| 229 | + try { rmSync(dir2, { recursive: true, force: true }); } catch { /* ignore */ } |
| 230 | + } |
| 231 | + }, 120_000); |
| 232 | + |
| 233 | + it('refuses a --from below the support floor with the structured error', async () => { |
| 234 | + let failed = false; |
| 235 | + try { |
| 236 | + await runMeta(['--from', '9', '--json'], dir); |
| 237 | + } catch (e: any) { |
| 238 | + failed = true; |
| 239 | + const body = JSON.parse(String(e.stdout || '{}')); |
| 240 | + expect(body.error).toBe('unsupported_from_major'); |
| 241 | + expect(body.message).toMatch(/floor|support/i); |
| 242 | + } |
| 243 | + expect(failed, 'expected a non-zero exit below the floor').toBe(true); |
| 244 | + }, 120_000); |
| 245 | +}); |
0 commit comments