|
9 | 9 | composeForDeclarations, |
10 | 10 | buildSchemaMigrationPlugins, |
11 | 11 | measureComposedCoverage, |
| 12 | + describeUnloadableHostConfig, |
| 13 | + type SchemaMigrationComposition, |
12 | 14 | } from './schema-migration-plugins.js'; |
13 | 15 |
|
14 | 16 | /** |
@@ -185,15 +187,104 @@ describe('buildSchemaMigrationPlugins', () => { |
185 | 187 |
|
186 | 188 | const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); |
187 | 189 |
|
188 | | - // Not fatal — this command worked before without ever reading the config, |
189 | | - // and a plan that stops working is a worse regression than a reduced one. |
| 190 | + // The composition still COMPLETES — the reduced set is composed and |
| 191 | + // returned. What changed with #12953 is the verdict the COMMANDS draw from |
| 192 | + // it (a non-zero exit), not whether this function throws. |
190 | 193 | expect(out.hostConfigPath).toBe(join(dir, 'objectstack.config.ts')); |
191 | | - // …but it must be DISTINGUISHABLE. `managedTables` alone cannot say this: |
| 194 | + // …and it must be DISTINGUISHABLE. `managedTables` alone cannot say this: |
192 | 195 | // the platform floor still lands, so the count rises either way. |
193 | 196 | expect(out.hostConfigLoaded).toBe(false); |
194 | 197 | const said = out.notes.join(' '); |
195 | 198 | expect(said).toContain('could not be loaded'); |
196 | 199 | expect(said).toContain('UNMEASURED'); |
| 200 | + // [#12953] The underlying failure, carried structurally so the refusal can |
| 201 | + // NAME it rather than re-parsing the prose above. |
| 202 | + expect(out.hostConfigError).toContain('OS_SOME_SECRET is required'); |
| 203 | + }); |
| 204 | + |
| 205 | + it('leaves hostConfigError null when there is no host config at all', async () => { |
| 206 | + const none = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: tempProject() }); |
| 207 | + expect(none.hostConfigError).toBeNull(); |
| 208 | + }); |
| 209 | + |
| 210 | + it('leaves hostConfigError null when the host config LOADS', async () => { |
| 211 | + const dir = tempProject(); |
| 212 | + writeFileSync(join(dir, 'objectstack.config.ts'), 'export default { objects: [] };\n'); |
| 213 | + const loaded = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); |
| 214 | + expect(loaded.hostConfigLoaded).toBe(true); |
| 215 | + expect(loaded.hostConfigError).toBeNull(); |
| 216 | + // Loading a real config runs `bundle-require`/esbuild — well past the 5 s |
| 217 | + // default on a cold, shared box. |
| 218 | + }, 60_000); |
| 219 | +}); |
| 220 | + |
| 221 | +/** |
| 222 | + * #12953 — the predicate behind the non-zero exit, in all three directions. |
| 223 | + * |
| 224 | + * Maintainer ruling 2026-08-29 (verbatim 「同意」): a host config that EXISTS |
| 225 | + * and could not be loaded makes `os migrate plan` / `apply` exit non-zero, |
| 226 | + * because a green exit over an UNMEASURED partial metadata set is the |
| 227 | + * false-green a migration tool must never emit. The ruling pinned the OTHER |
| 228 | + * two directions just as hard — config absent, and config loadable, both keep |
| 229 | + * today's behaviour — so all three are pinned here. |
| 230 | + * |
| 231 | + * ⚠️ The trap this file exists to hold: `hostConfigLoaded` is `false` on the |
| 232 | + * config-ABSENT shape too (nothing loaded, because there was nothing to load). |
| 233 | + * A predicate written as `!hostConfigLoaded` therefore turns the untouched |
| 234 | + * population red, and every assertion about direction 1 still passes while it |
| 235 | + * does. The second case below is the one that fails if anyone writes it that |
| 236 | + * way. |
| 237 | + * |
| 238 | + * The exit STATUS itself is pinned over a real child process in |
| 239 | + * `packages/cli/test/migrate-unloadable-host-config-exit.e2e.test.ts` — a |
| 240 | + * `process.exitCode` set inside a vitest worker is not an exit status. |
| 241 | + */ |
| 242 | +describe('describeUnloadableHostConfig (#12953)', () => { |
| 243 | + function composition(over: Partial<SchemaMigrationComposition>): SchemaMigrationComposition { |
| 244 | + return { |
| 245 | + plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null, |
| 246 | + notes: [], coverage: null, ...over, |
| 247 | + }; |
| 248 | + } |
| 249 | + |
| 250 | + it('direction 1 — config PRESENT and unloadable: names the config, the cause and the remedy', () => { |
| 251 | + const said = describeUnloadableHostConfig(composition({ |
| 252 | + hostConfigPath: '/srv/app/objectstack.config.ts', |
| 253 | + hostConfigLoaded: false, |
| 254 | + hostConfigError: 'Missing required environment variable AUTH_SECRET', |
| 255 | + })); |
| 256 | + |
| 257 | + expect(said).not.toBeNull(); |
| 258 | + // The three things the ruling requires the error to name. |
| 259 | + expect(said).toContain('/srv/app/objectstack.config.ts'); |
| 260 | + expect(said).toContain('Missing required environment variable AUTH_SECRET'); |
| 261 | + expect(said).toMatch(/Remedy:/); |
| 262 | + // And that it is a FAILURE, not another warning — the whole point. |
| 263 | + expect(said).toContain('UNMEASURED'); |
| 264 | + }); |
| 265 | + |
| 266 | + it('direction 2 — config ABSENT: null, even though hostConfigLoaded is false', () => { |
| 267 | + // `hostConfigPath === null` with `hostConfigLoaded === false` is the |
| 268 | + // untouched population. If this ever answers non-null, every project with |
| 269 | + // no config starts failing `os migrate plan`. |
| 270 | + expect(describeUnloadableHostConfig(composition({ |
| 271 | + hostConfigPath: null, hostConfigLoaded: false, |
| 272 | + }))).toBeNull(); |
| 273 | + }); |
| 274 | + |
| 275 | + it('direction 3 — config PRESENT and loadable: null', () => { |
| 276 | + expect(describeUnloadableHostConfig(composition({ |
| 277 | + hostConfigPath: '/srv/app/objectstack.config.ts', hostConfigLoaded: true, |
| 278 | + }))).toBeNull(); |
| 279 | + }); |
| 280 | + |
| 281 | + it('still names something when the load threw without a message', () => { |
| 282 | + const said = describeUnloadableHostConfig(composition({ |
| 283 | + hostConfigPath: '/srv/app/objectstack.config.mjs', hostConfigLoaded: false, |
| 284 | + hostConfigError: null, |
| 285 | + })); |
| 286 | + expect(said).toContain('/srv/app/objectstack.config.mjs'); |
| 287 | + expect(said).toContain('the load threw without a message'); |
197 | 288 | }); |
198 | 289 | }); |
199 | 290 |
|
|
0 commit comments