|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0130 D4 — `os compile` / `os build` write side, measured on the artifact |
| 5 | + * the commands actually put on disk. |
| 6 | + * |
| 7 | + * D4's acceptance has two halves and they pull in opposite directions, which is |
| 8 | + * why both are measured here from real runs rather than argued from the source: |
| 9 | + * |
| 10 | + * 1. A single-package project keeps writing `manifest` EXACTLY as today. The |
| 11 | + * default compile output does not move — no new key, no reordering, no |
| 12 | + * materialised empty list. This is the half a schema change breaks by |
| 13 | + * accident (a `.default([])` on the new key would rewrite every project's |
| 14 | + * artifact on its next build), so it is pinned as an exact top-level key |
| 15 | + * set, not as a spot check. |
| 16 | + * |
| 17 | + * 2. An artifact that DOES declare `packages` survives the whole pipeline — |
| 18 | + * `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema` |
| 19 | + * → `JSON.stringify(finalBundle)`. Each of those steps shallow-clones the |
| 20 | + * top level, so the key passes through *by construction*; construction is |
| 21 | + * exactly the kind of claim that stops being true when someone adds a |
| 22 | + * whitelist to one of the three, and nothing would have failed. |
| 23 | + * |
| 24 | + * ⛔ A green run here does NOT mean a multi-package artifact installs. The load |
| 25 | + * path that iterates the list (ADR-0130 D5, topologically ordered through |
| 26 | + * `resolvePluginOrder`) and the `installPackage` co-ownership gate (D1/D3) are |
| 27 | + * separate, dependent cards. This file pins what the COMPILER writes. |
| 28 | + */ |
| 29 | + |
| 30 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 31 | +import { execFile } from 'node:child_process'; |
| 32 | +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'; |
| 33 | +import { tmpdir } from 'node:os'; |
| 34 | +import { join, resolve } from 'node:path'; |
| 35 | +import { fileURLToPath } from 'node:url'; |
| 36 | +import { childEnv } from './helpers/serve-process.js'; |
| 37 | + |
| 38 | +const HERE = resolve(fileURLToPath(import.meta.url), '..'); |
| 39 | +const CLI = resolve(HERE, '../bin/run-dev.js'); |
| 40 | +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); |
| 41 | + |
| 42 | +interface Run { |
| 43 | + code: number; |
| 44 | + stdout: string; |
| 45 | + stderr: string; |
| 46 | +} |
| 47 | + |
| 48 | +function runCli(args: string[], cwd: string): Promise<Run> { |
| 49 | + return new Promise((resolvePromise) => { |
| 50 | + execFile( |
| 51 | + TSX, |
| 52 | + [CLI, ...args], |
| 53 | + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, |
| 54 | + (err, stdout, stderr) => { |
| 55 | + resolvePromise({ |
| 56 | + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, |
| 57 | + stdout: String(stdout), |
| 58 | + stderr: String(stderr), |
| 59 | + }); |
| 60 | + }, |
| 61 | + ); |
| 62 | + }); |
| 63 | +} |
| 64 | + |
| 65 | +function payloadOf(run: Run, label: string): Record<string, unknown> { |
| 66 | + try { |
| 67 | + return JSON.parse(run.stdout) as Record<string, unknown>; |
| 68 | + } catch { |
| 69 | + throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`); |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +/** Today's shape: one package, declared through the singular `manifest`. */ |
| 74 | +const CONFIG_SINGLE = ` |
| 75 | +export default { |
| 76 | + manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' }, |
| 77 | + objects: [ |
| 78 | + { |
| 79 | + name: 'solo_ticket', |
| 80 | + label: 'Ticket', |
| 81 | + sharingModel: 'private', |
| 82 | + fields: { title: { type: 'text', label: 'Title' } }, |
| 83 | + }, |
| 84 | + ], |
| 85 | +}; |
| 86 | +`; |
| 87 | + |
| 88 | +/** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */ |
| 89 | +const CONFIG_MULTI = ` |
| 90 | +export default { |
| 91 | + manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' }, |
| 92 | + packages: [ |
| 93 | + { manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' } }, |
| 94 | + { manifest: { id: 'com.example.crm.cpq', name: 'cpq', version: '1.0.0', type: 'module', namespace: 'crm' } }, |
| 95 | + ], |
| 96 | + objects: [ |
| 97 | + { |
| 98 | + name: 'crm_account', |
| 99 | + label: 'Account', |
| 100 | + sharingModel: 'private', |
| 101 | + fields: { name: { type: 'text', label: 'Name' } }, |
| 102 | + }, |
| 103 | + ], |
| 104 | +}; |
| 105 | +`; |
| 106 | + |
| 107 | +/** |
| 108 | + * The reservation, violated: the manifest body inlined flat as the array |
| 109 | + * element. Must be refused at the compile door, not written to an artifact. |
| 110 | + */ |
| 111 | +const CONFIG_FLATTENED = ` |
| 112 | +export default { |
| 113 | + packages: [ |
| 114 | + { id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' }, |
| 115 | + ], |
| 116 | + objects: [], |
| 117 | +}; |
| 118 | +`; |
| 119 | + |
| 120 | +const dirs: Record<string, string> = {}; |
| 121 | +let root = ''; |
| 122 | + |
| 123 | +beforeAll(() => { |
| 124 | + root = mkdtempSync(join(tmpdir(), 'os-d4-packages-')); |
| 125 | + for (const [name, source] of Object.entries({ |
| 126 | + single: CONFIG_SINGLE, |
| 127 | + multi: CONFIG_MULTI, |
| 128 | + flattened: CONFIG_FLATTENED, |
| 129 | + })) { |
| 130 | + const dir = join(root, name); |
| 131 | + mkdirSync(dir, { recursive: true }); |
| 132 | + writeFileSync(join(dir, 'objectstack.config.ts'), source); |
| 133 | + dirs[name] = dir; |
| 134 | + } |
| 135 | +}); |
| 136 | + |
| 137 | +afterAll(() => { |
| 138 | + if (root) rmSync(root, { recursive: true, force: true }); |
| 139 | +}); |
| 140 | + |
| 141 | +const artifactOf = (payload: Record<string, unknown>): Record<string, unknown> => |
| 142 | + JSON.parse(readFileSync(String(payload.output), 'utf8')) as Record<string, unknown>; |
| 143 | + |
| 144 | +describe('ADR-0130 D4 — the default (single-package) compile output does not move', () => { |
| 145 | + it('writes `manifest` and NO `packages` key', async () => { |
| 146 | + const run = await runCli(['build', '--json'], dirs.single); |
| 147 | + expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0); |
| 148 | + |
| 149 | + const artifact = artifactOf(payloadOf(run, 'os build --json')); |
| 150 | + |
| 151 | + expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.solo'); |
| 152 | + // The exact key set, so a materialised `"packages": []` — the near-miss |
| 153 | + // this criterion exists for — fails here rather than being noticed by a |
| 154 | + // customer diffing their artifact. |
| 155 | + expect(Object.keys(artifact).sort()).toEqual(['manifest', 'objects']); |
| 156 | + expect(readFileSync(String(payloadOf(run, 'os build --json').output), 'utf8')).not.toContain('"packages"'); |
| 157 | + }, 180_000); |
| 158 | +}); |
| 159 | + |
| 160 | +describe('ADR-0130 D4 — an artifact declaring `packages` compiles and keeps it', () => { |
| 161 | + it('carries both package manifests through to the written artifact, in order', async () => { |
| 162 | + const run = await runCli(['build', '--json'], dirs.multi); |
| 163 | + expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0); |
| 164 | + |
| 165 | + const artifact = artifactOf(payloadOf(run, 'os build --json')); |
| 166 | + const packages = artifact.packages as { manifest: { id: string; type: string } }[]; |
| 167 | + |
| 168 | + expect(Array.isArray(packages), 'the compiler dropped the `packages` key').toBe(true); |
| 169 | + expect(packages.map((p) => p.manifest.id)).toEqual([ |
| 170 | + 'com.example.crm', |
| 171 | + 'com.example.crm.cpq', |
| 172 | + ]); |
| 173 | + // The wrapper survives as a wrapper — not flattened, not unwrapped. |
| 174 | + expect(packages[0]).toEqual({ manifest: expect.objectContaining({ id: 'com.example.crm' }) }); |
| 175 | + expect(packages[1].manifest.type).toBe('module'); |
| 176 | + }, 180_000); |
| 177 | + |
| 178 | + it('keeps the singular `manifest` beside it — retained, not replaced', async () => { |
| 179 | + const run = await runCli(['build', '--json'], dirs.multi); |
| 180 | + const artifact = artifactOf(payloadOf(run, 'os build --json')); |
| 181 | + |
| 182 | + expect((artifact.manifest as Record<string, unknown>).id).toBe('com.example.crm'); |
| 183 | + }, 180_000); |
| 184 | +}); |
| 185 | + |
| 186 | +describe('ADR-0130 D4 — the compile door refuses a flattened entry', () => { |
| 187 | + it('exits non-zero rather than writing an artifact in the unreserved shape', async () => { |
| 188 | + const run = await runCli(['build', '--json'], dirs.flattened); |
| 189 | + |
| 190 | + expect(run.code, `expected a refusal, got exit 0:\n${run.stdout}`).not.toBe(0); |
| 191 | + const payload = payloadOf(run, 'os build --json'); |
| 192 | + expect(payload.success).toBe(false); |
| 193 | + |
| 194 | + // The refusal must point at the offending entry, so the author can find it |
| 195 | + // in an artifact with N packages. |
| 196 | + const errors = JSON.stringify(payload.errors ?? payload.error ?? ''); |
| 197 | + expect(errors).toContain('packages'); |
| 198 | + }, 180_000); |
| 199 | +}); |
0 commit comments