From 669acedcadeb5890d72b7b4dbc67d07dba093dc5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 10:41:44 +0800 Subject: [PATCH] fix(test): preserve invariant config failures --- scripts/test-invariants.spec.ts | 205 ++++++++++++++++++++++++++------ scripts/test-invariants.ts | 28 ++++- 2 files changed, 190 insertions(+), 43 deletions(-) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 08900b4e1e..2c00bc7439 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { Context, FiberState, Service } from 'cordis' +import { Context, FiberState, Service, ValidationError } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import z from 'schemastery' import InvariantService from '@deepseek-ai/dsh-invariants' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' @@ -32,23 +33,40 @@ function deferred(): { readonly promise: Promise; readonly resolve: () => return { promise, resolve } } -function delayedCompanions( - delayedStarted: ReturnType, - releaseDelayed: ReturnType, -): (_path: string, index: number) => () => Promise { - return (_path, index) => async () => ({ - name: `test-invariant-${index}`, - inject: ['invariants'], - async apply() { - if (index === 0) { - delayedStarted.resolve() - await releaseDelayed.promise - } - return () => {} - }, +function requiredConfig() { + return z.object({ + requiredValue: z.string().required(), }) } +function queuedReadinessConfig( + ctx: Context, + onPublished: (dispose: () => void) => void, +) { + return z.transform(z.any(), () => { + queueMicrotask(() => { + onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true)) + }) + return {} + }, true) +} + +function invalidConfigApply(): never { + throw new Error('invalid plugin apply executed') +} + +async function rejectionOf(fiber: ReturnType): Promise { + return fiber.then( + () => undefined, + (error: unknown) => error, + ) +} + +function expectRequiredConfigValidation(error: unknown): void { + expect(error).toBeInstanceOf(ValidationError) + expect(error).toHaveProperty('message', expect.stringMatching(/requiredValue/)) +} + async function withFakeCompanions( create: (path: string, index: number) => () => Promise, run: () => Promise, @@ -67,6 +85,27 @@ async function withFakeCompanions( } } +async function withDelayedFirstCompanion( + run: (control: { readonly started: Promise; readonly release: () => void }) => Promise, +): Promise { + const started = deferred() + const release = deferred() + await withFakeCompanions( + (_path, index) => async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (index === 0) { + started.resolve() + await release.promise + } + return () => {} + }, + }), + () => run({ started: started.promise, release: release.resolve }), + ) +} + describe('global test invariant host', () => { it('uses one exhaustive topology to reserve every package name with enabled checks', async () => { const ctx = new Context() @@ -132,6 +171,106 @@ describe('global test invariant host', () => { expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false) }) + it('preserves config validation failures without starting the rejected plugin', async () => { + const ctx = new Context() + const apply = vi.fn(invalidConfigApply) + const plugin = { + apply, + Config: requiredConfig(), + } + + const fiber = ctx.plugin(plugin, {}) + const firstError = await rejectionOf(fiber) + expectRequiredConfigValidation(firstError) + await ctx.plugin(TestInvariantProbe) + const secondError = await rejectionOf(fiber) + expect(secondError).toBe(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + }) + + it('disposes invalid config when readiness refresh wins the rejection-handler race', async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { + const ctx = new Context() + const apply = vi.fn(invalidConfigApply) + let disposeQueuedReadiness: (() => void) | undefined + const plugin = { + apply, + Config: z.intersect([ + queuedReadinessConfig(ctx, (dispose) => { + disposeQueuedReadiness = dispose + }), + requiredConfig(), + ]), + } + + const fiber = ctx.plugin(plugin, {}) + const firstError = await rejectionOf(fiber) + expectRequiredConfigValidation(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + + await started + if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') + disposeQueuedReadiness() + release() + await ctx.plugin(TestInvariantProbe) + + const secondError = await rejectionOf(fiber) + expect(secondError).toBe(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + }, + ) + }) + + it('retains a valid plugin failure when readiness wins the initial-probe race', async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { + const ctx = new Context() + const failure = new Error('valid plugin apply failed') + const applied = deferred() + const apply = vi.fn(function validConfigApply() { + applied.resolve() + throw failure + }) + let disposeQueuedReadiness: (() => void) | undefined + const plugin = { + apply, + Config: queuedReadinessConfig(ctx, (dispose) => { + disposeQueuedReadiness = dispose + }), + } + + const fiber = ctx.plugin(plugin, {}) + const returnedError = rejectionOf(fiber) + try { + await Promise.all([started, applied.promise]) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + + if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') + Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) + disposeQueuedReadiness() + release() + + expect(await returnedError).toBe(failure) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + } finally { + Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) + disposeQueuedReadiness?.() + release() + } + }, + ) + }) + it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => { const delayedStarted = deferred() const releaseDelayed = deferred() @@ -208,12 +347,8 @@ describe('global test invariant host', () => { }) it('holds plugins registered on a root-derived context until companion readiness', async () => { - const delayedStarted = deferred() - const releaseDelayed = deferred() - - await withFakeCompanions( - delayedCompanions(delayedStarted, releaseDelayed), - async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { const ctx = new Context() const rootApply = vi.fn(function rootApply() {}) const derivedApply = vi.fn(function derivedApply() {}) @@ -224,7 +359,7 @@ describe('global test invariant host', () => { const rootFiber = ctx.plugin(rootApply) const derivedFiber = derived.plugin(derivedApply) - await delayedStarted.promise + await started await Promise.resolve() await Promise.resolve() expect(rootApply).not.toHaveBeenCalled() @@ -233,7 +368,7 @@ describe('global test invariant host', () => { [TEST_INVARIANT_READY_SERVICE]: null, }) - releaseDelayed.resolve() + release() await Promise.all([rootFiber, derivedFiber]) expect(rootFiber.state).toBe(FiberState.ACTIVE) expect(derivedFiber.state).toBe(FiberState.ACTIVE) @@ -244,12 +379,8 @@ describe('global test invariant host', () => { }) it('holds a child registered externally on a pending target context', async () => { - const delayedStarted = deferred() - const releaseDelayed = deferred() - - await withFakeCompanions( - delayedCompanions(delayedStarted, releaseDelayed), - async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { const ctx = new Context() const targetApply = vi.fn(function targetApply() {}) const childApply = vi.fn(function childApply() {}) @@ -257,7 +388,7 @@ describe('global test invariant host', () => { const targetFiber = ctx.plugin(targetApply) const childFiber = targetFiber.ctx.plugin(childApply) - await delayedStarted.promise + await started await Promise.resolve() await Promise.resolve() expect(targetFiber.state).toBe(FiberState.PENDING) @@ -268,7 +399,7 @@ describe('global test invariant host', () => { [TEST_INVARIANT_READY_SERVICE]: null, }) - releaseDelayed.resolve() + release() await Promise.all([targetFiber, childFiber]) expect(targetFiber.state).toBe(FiberState.ACTIVE) expect(childFiber.state).toBe(FiberState.ACTIVE) @@ -309,22 +440,18 @@ describe('global test invariant host', () => { ) it('disposes a pending target without waiting for companion readiness', async () => { - const delayedStarted = deferred() - const releaseDelayed = deferred() - - await withFakeCompanions( - delayedCompanions(delayedStarted, releaseDelayed), - async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { const ctx = new Context() const targetApply = vi.fn(function targetApply() {}) const targetFiber = ctx.plugin(targetApply) - await delayedStarted.promise + await started await expect(targetFiber.dispose()).resolves.toBeUndefined() expect(targetFiber.state).toBe(FiberState.DISPOSED) expect(targetApply).not.toHaveBeenCalled() - releaseDelayed.resolve() + release() await targetFiber expect(targetApply).not.toHaveBeenCalled() }, diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 8ebf7a6243..9705f590de 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -75,7 +75,9 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge if (hasBarrierOwner(host, this.ctx)) { return originalPlugin.call(this, plugin, config, getOuterStack) } - if (callback === undefined) return originalPlugin.call(this, plugin, config, getOuterStack) + if (callback === undefined) { + return originalPlugin.call(this, plugin, config, getOuterStack) + } const fiber = originalPlugin.call( this, @@ -83,8 +85,9 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge config, getOuterStack, ) + const initiallyPending = fiber.ctx.fiber.state === FiberState.PENDING host.barrierOwners.add(fiber.ctx.fiber) - return joinInvariantStartup(fiber, host.ready) + return joinInvariantStartup(fiber, host.ready, initiallyPending) } /** @@ -204,8 +207,25 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi } } -function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise): PluginFiber { - const readiness = invariantReady.then(() => fiber.await()) +function joinInvariantStartup( + fiber: PluginFiber, + invariantReady: Promise, + disposeInitialFailure = false, +): PluginFiber { + // RegistryService returns a thenable wrapper whose context still points to + // the raw Fiber. Calling inherited await() on the wrapper would return and + // assimilate that thenable, accidentally following later plugin startup. + const rawFiber = fiber.ctx.fiber + const initialized = disposeInitialFailure + ? rawFiber.await().catch(async (error: unknown) => { + // Config validation is the only failure recorded while a gated fiber + // is initially PENDING. Dispose it even if queued readiness publication + // changes its state before this rejection handler runs. + await rawFiber.dispose() + throw error + }) + : Promise.resolve() + const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await()) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined