|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #15232 — the i18n auto-detect reads `translations` from `packages[]` too. |
| 4 | +// |
| 5 | +// ── The defect ───────────────────────────────────────────────────────────── |
| 6 | +// |
| 7 | +// `DevPlugin`'s 3b block decided whether to register the file-based |
| 8 | +// `I18nServicePlugin` from `stack.translations` alone. A multi-package artifact |
| 9 | +// under ADR-0130 D4's option-B shape carries each definition once, inside |
| 10 | +// `packages[]`, with no flattened top level — so the read returned `undefined`, |
| 11 | +// the detection said "this app declares no copy", and `os dev` booted on the |
| 12 | +// core in-memory i18n fallback. Nothing throws. Nothing logs. The developer |
| 13 | +// sees message keys, or last release's strings, where the app declared real |
| 14 | +// translations. |
| 15 | +// |
| 16 | +// ── What is pinned here, and in which direction ──────────────────────────── |
| 17 | +// |
| 18 | +// Both directions, because only the pair is a discrimination: |
| 19 | +// |
| 20 | +// - today's ADDITIVE artifact answers exactly as it did before (the flattened |
| 21 | +// level answers FIRST and short-circuits — the caller's original expression |
| 22 | +// is preserved, not re-expressed, which is the trap #15006 measured); |
| 23 | +// - the option-B artifact, whose ONLY copy is under `packages[]`, is now |
| 24 | +// detected — the row this card added to the #15004 ledger and then deleted. |
| 25 | +// |
| 26 | +// The last case boots the real `DevPlugin` rather than only calling the |
| 27 | +// decision, because what a developer experiences is the SERVICE: the plugin has |
| 28 | +// to reach `new I18nServicePlugin(...)` with the locales the detection derived. |
| 29 | +// `@objectstack/service-i18n` is mocked to a recording double for that arm |
| 30 | +// (present and constructible), and every other optional package is mocked |
| 31 | +// ABSENT — the #3060 convention in this package's sibling tests, which keeps a |
| 32 | +// dev-assembly boot off the vite transform hot path. |
| 33 | + |
| 34 | +import { describe, it, expect, vi } from 'vitest'; |
| 35 | +import { composeStacks, defineStack, type ObjectStackDefinition } from '@objectstack/spec'; |
| 36 | + |
| 37 | +import { devI18nPluginOptions } from './dev-i18n'; |
| 38 | +import { DevPlugin } from './dev-plugin'; |
| 39 | + |
| 40 | +const absent = (name: string): Error => |
| 41 | + Object.assign(new Error(`Cannot find package '${name}'`), { code: 'ERR_MODULE_NOT_FOUND' }); |
| 42 | + |
| 43 | +/** The one package that must be PRESENT: the arm under test constructs it. */ |
| 44 | +const i18nConstructions = vi.hoisted(() => [] as unknown[]); |
| 45 | +vi.mock('@objectstack/service-i18n', () => ({ |
| 46 | + I18nServicePlugin: class { |
| 47 | + name = 'com.objectstack.service.i18n'; |
| 48 | + type = 'service' as const; |
| 49 | + version = '1.0.0'; |
| 50 | + constructor(options: unknown) { i18nConstructions.push(options); } |
| 51 | + async init(): Promise<void> { /* the recorder needs no behaviour */ } |
| 52 | + }, |
| 53 | +})); |
| 54 | + |
| 55 | +vi.mock('@objectstack/objectql', () => { throw absent('@objectstack/objectql'); }); |
| 56 | +vi.mock('@objectstack/runtime', () => { throw absent('@objectstack/runtime'); }); |
| 57 | +vi.mock('@objectstack/driver-memory', () => { throw absent('@objectstack/driver-memory'); }); |
| 58 | +vi.mock('@objectstack/service-storage', () => { throw absent('@objectstack/service-storage'); }); |
| 59 | +vi.mock('@objectstack/service-realtime', () => { throw absent('@objectstack/service-realtime'); }); |
| 60 | +vi.mock('@objectstack/plugin-auth', () => { throw absent('@objectstack/plugin-auth'); }); |
| 61 | +vi.mock('@objectstack/plugin-security', () => { throw absent('@objectstack/plugin-security'); }); |
| 62 | +vi.mock('@objectstack/plugin-hono-server', () => { throw absent('@objectstack/plugin-hono-server'); }); |
| 63 | +vi.mock('@objectstack/rest', () => { throw absent('@objectstack/rest'); }); |
| 64 | +vi.mock('@objectstack/setup', () => { throw absent('@objectstack/setup'); }); |
| 65 | +vi.mock('@objectstack/account', () => { throw absent('@objectstack/account'); }); |
| 66 | + |
| 67 | +// ─── The two-package fixture, in both shapes ──────────────────────────────── |
| 68 | + |
| 69 | +const CORE_ID = 'com.example.i18n.core'; |
| 70 | +const MODULE_ID = 'com.example.i18n.orders'; |
| 71 | + |
| 72 | +const corePackage = (): ObjectStackDefinition => |
| 73 | + defineStack({ |
| 74 | + manifest: { |
| 75 | + id: CORE_ID, |
| 76 | + name: 'I18n Probe Core', |
| 77 | + namespace: 'i18nprobe', |
| 78 | + version: '1.0.0', |
| 79 | + type: 'app', |
| 80 | + }, |
| 81 | + objects: [ |
| 82 | + { |
| 83 | + name: 'i18nprobe_account', |
| 84 | + label: 'Account', |
| 85 | + pluralLabel: 'Accounts', |
| 86 | + sharingModel: 'private', |
| 87 | + fields: { name: { name: 'name', type: 'text', label: 'Name', required: true } }, |
| 88 | + }, |
| 89 | + ], |
| 90 | + // The whole point: the app's declared COPY lives in a package. |
| 91 | + translations: [ |
| 92 | + { en: { objects: { i18nprobe_account: { label: 'Account (translated)' } } } }, |
| 93 | + ], |
| 94 | + }); |
| 95 | + |
| 96 | +const modulePackage = (): ObjectStackDefinition => |
| 97 | + defineStack({ |
| 98 | + manifest: { |
| 99 | + id: MODULE_ID, |
| 100 | + name: 'I18n Probe Orders', |
| 101 | + namespace: 'i18nprobe', |
| 102 | + version: '1.0.0', |
| 103 | + type: 'module', |
| 104 | + dependencies: { [CORE_ID]: '^1.0.0' }, |
| 105 | + }, |
| 106 | + objects: [ |
| 107 | + { |
| 108 | + name: 'i18nprobe_order', |
| 109 | + label: 'Order', |
| 110 | + pluralLabel: 'Orders', |
| 111 | + sharingModel: 'private', |
| 112 | + fields: { name: { name: 'name', type: 'text', label: 'Number', required: true } }, |
| 113 | + }, |
| 114 | + ], |
| 115 | + }); |
| 116 | + |
| 117 | +/** Today's emitted shape: flattened top level PLUS `packages[]`. */ |
| 118 | +const additiveProject = (): Record<string, unknown> => |
| 119 | + composeStacks([modulePackage(), corePackage()], { manifest: 'preserve' }) as unknown as Record<string, unknown>; |
| 120 | + |
| 121 | +/** The ruled option-B shape, for the one collection this reader reads. */ |
| 122 | +const optionBProject = (): Record<string, unknown> => { |
| 123 | + const composed = additiveProject(); |
| 124 | + delete composed.translations; |
| 125 | + return composed; |
| 126 | +}; |
| 127 | + |
| 128 | +const mockCtx = () => { |
| 129 | + const registered = new Map<string, unknown>(); |
| 130 | + const info: string[] = []; |
| 131 | + const ctx = { |
| 132 | + logger: { |
| 133 | + info: (line: unknown) => { if (typeof line === 'string') info.push(line); }, |
| 134 | + debug: () => undefined, |
| 135 | + warn: () => undefined, |
| 136 | + error: () => undefined, |
| 137 | + }, |
| 138 | + getService: (name: string) => { |
| 139 | + if (registered.has(name)) return registered.get(name); |
| 140 | + throw new Error(`service not found: ${name}`); |
| 141 | + }, |
| 142 | + getServices: () => new Map(), |
| 143 | + registerService: (name: string, svc: unknown) => { registered.set(name, svc); }, |
| 144 | + hook: () => undefined, |
| 145 | + trigger: () => undefined, |
| 146 | + getKernel: () => undefined, |
| 147 | + }; |
| 148 | + return { ctx, info }; |
| 149 | +}; |
| 150 | + |
| 151 | +describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', () => { |
| 152 | + it('CONTROL — the fixture really carries the translations under `packages[]`', () => { |
| 153 | + // Anti-vacuity: every "detected" below is a READER resolving `packages[]`, |
| 154 | + // never a fixture that quietly kept a flattened copy. |
| 155 | + const optionB = optionBProject(); |
| 156 | + expect(optionB.translations).toBeUndefined(); |
| 157 | + const bodies = (optionB.packages as Array<{ manifest?: { id?: string; translations?: unknown[] } }>); |
| 158 | + expect(bodies.map((p) => p.manifest?.id).sort()).toEqual([CORE_ID, MODULE_ID]); |
| 159 | + expect(bodies.find((p) => p.manifest?.id === CORE_ID)?.manifest?.translations).toHaveLength(1); |
| 160 | + }); |
| 161 | + |
| 162 | + it("BASELINE — today's additive artifact answers exactly as it did before", () => { |
| 163 | + const additive = additiveProject(); |
| 164 | + expect(Array.isArray(additive.translations)).toBe(true); |
| 165 | + expect(devI18nPluginOptions(additive)).toEqual({ defaultLocale: undefined, fallbackLocale: 'en' }); |
| 166 | + }); |
| 167 | + |
| 168 | + it('THE FIX — the option-B artifact is detected through `packages[]`', () => { |
| 169 | + expect(devI18nPluginOptions(optionBProject())).toEqual({ defaultLocale: undefined, fallbackLocale: 'en' }); |
| 170 | + }); |
| 171 | + |
| 172 | + it('the flattened level answers FIRST — `packages[]` is not even traversed', () => { |
| 173 | + // Two things at once, and the malformed `packages` is what proves the |
| 174 | + // first: the original expression short-circuits, so today's additive |
| 175 | + // artifact cannot start refusing anything it accepted before. |
| 176 | + let reads = 0; |
| 177 | + const stack = { |
| 178 | + manifest: { id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' }, |
| 179 | + get translations() { reads += 1; return [{ en: { objects: {} } }]; }, |
| 180 | + packages: 'not an array — this would be refused if it were reached', |
| 181 | + }; |
| 182 | + expect(devI18nPluginOptions(stack)).toEqual({ defaultLocale: undefined, fallbackLocale: 'en' }); |
| 183 | + expect(reads).toBe(1); |
| 184 | + }); |
| 185 | + |
| 186 | + it('a single-package stack reads its `translations` ONCE, and never throws', () => { |
| 187 | + // D4's second branch returns the CALLER'S OWN OBJECT as the single package |
| 188 | + // body, so an unguarded walk would read the same key a second time. The |
| 189 | + // guard is what keeps every single-package stack on exactly the old path. |
| 190 | + let reads = 0; |
| 191 | + const stack = { |
| 192 | + manifest: { id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' }, |
| 193 | + get translations() { reads += 1; return []; }, |
| 194 | + }; |
| 195 | + expect(devI18nPluginOptions(stack)).toBeUndefined(); |
| 196 | + expect(reads).toBe(1); |
| 197 | + }); |
| 198 | + |
| 199 | + it('an EMPTY top-level `translations` is not an answer — `packages[]` supplies it', () => { |
| 200 | + // `[]` is falsy for the original expression (`length > 0`), so this is the |
| 201 | + // case where `packages[]` legitimately supplies what the top level lacks. |
| 202 | + const stack = { ...optionBProject(), translations: [] }; |
| 203 | + expect(devI18nPluginOptions(stack)).toEqual({ defaultLocale: undefined, fallbackLocale: 'en' }); |
| 204 | + }); |
| 205 | + |
| 206 | + it('locales still come from the stack `i18n` config, which option B does not move', () => { |
| 207 | + // `i18n` is an artifact ENVELOPE key, not a package-owned collection, so it |
| 208 | + // stays at the top level in both shapes and this limb loses nothing. |
| 209 | + const stack = { ...optionBProject(), i18n: { defaultLocale: 'zh-CN', fallbackLocale: 'en-US' } }; |
| 210 | + expect(devI18nPluginOptions(stack)).toEqual({ defaultLocale: 'zh-CN', fallbackLocale: 'en-US' }); |
| 211 | + }); |
| 212 | + |
| 213 | + it('a malformed `packages[]` is REFUSED with an ADR-0112 envelope, not skipped', () => { |
| 214 | + // The gate travels with the read: `resolveArtifactPackageOrder` is also |
| 215 | + // what refuses this artifact at registration, so swallowing it here would |
| 216 | + // resolve an i18n posture out of a package list nothing else accepts. |
| 217 | + const stack = { |
| 218 | + manifest: { id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' }, |
| 219 | + // An entry inlined instead of wrapped as `{ manifest: { … } }`. |
| 220 | + packages: [{ id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' }], |
| 221 | + }; |
| 222 | + let caught: (Error & { code?: string; status?: number }) | undefined; |
| 223 | + try { |
| 224 | + devI18nPluginOptions(stack); |
| 225 | + } catch (err) { |
| 226 | + caught = err as Error & { code?: string; status?: number }; |
| 227 | + } |
| 228 | + expect(caught?.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY'); |
| 229 | + expect(caught?.status).toBe(422); |
| 230 | + expect(caught?.message).toContain('packages[0]'); |
| 231 | + }); |
| 232 | + |
| 233 | + // ── What the developer actually gets: the SERVICE ───────────────────────── |
| 234 | + |
| 235 | + const bootWith = async (stack: Record<string, unknown> | undefined) => { |
| 236 | + i18nConstructions.length = 0; |
| 237 | + const { ctx, info } = mockCtx(); |
| 238 | + await new DevPlugin({ |
| 239 | + seedAdminUser: false, |
| 240 | + stack, |
| 241 | + services: { |
| 242 | + objectql: false, driver: false, auth: false, setup: false, server: false, |
| 243 | + rest: false, dispatcher: false, security: false, storage: false, |
| 244 | + 'file-storage': false, realtime: false, |
| 245 | + }, |
| 246 | + }).init(ctx as never); |
| 247 | + return { constructions: [...i18nConstructions], info }; |
| 248 | + }; |
| 249 | + |
| 250 | + it('BOOT — a multi-package app under option B gets the file-based I18nServicePlugin', async () => { |
| 251 | + const { constructions, info } = await bootWith(optionBProject()); |
| 252 | + expect(constructions).toEqual([{ defaultLocale: undefined, fallbackLocale: 'en' }]); |
| 253 | + expect(info.some((l) => l.includes('I18nServicePlugin auto-registered'))).toBe(true); |
| 254 | + }); |
| 255 | + |
| 256 | + it('BOOT — a stack declaring no copy at all still gets no I18nServicePlugin', async () => { |
| 257 | + // The negative control. Without it the assertion above would pass for a |
| 258 | + // detection that fires unconditionally. |
| 259 | + const bare = { manifest: { id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' } }; |
| 260 | + const { constructions, info } = await bootWith(bare); |
| 261 | + expect(constructions).toEqual([]); |
| 262 | + expect(info.some((l) => l.includes('I18nServicePlugin auto-registered'))).toBe(false); |
| 263 | + }); |
| 264 | +}); |
0 commit comments