diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 8119b2acd..9792bc373 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -1076,7 +1076,19 @@ export class ConfigItem { } const dataType = this.effectiveDataType; - if (!dataType) throw new Error('expected dataType to be set'); + // finishLoad() returns early when any source is invalid or a plugin failed + // to load, leaving items unprocessed (dataType unset) and with no schema + // error. Resolving such an item — typically because the key is also in + // process.env, which varlock treats as an override — used to throw + // `expected dataType to be set` from an un-awaited resolveItem(), which + // becomes an unhandledRejection while resolveEnvValues() hangs (dead + // `_reject`). Record it as a ResolutionError so callers can fail cleanly. + if (!dataType) { + this.resolutionError = new ResolutionError( + 'expected dataType to be set — this item was never typed because env-graph loading did not finish. Check .env / .env.schema / .env.local for parse errors, and that plugins loaded.', + ); + return; + } // COERCE VALUE - often will do nothing, but gives us a chance to convert strings to numbers, etc try { diff --git a/packages/varlock/src/env-graph/test/unprocessed-item-resolve.test.ts b/packages/varlock/src/env-graph/test/unprocessed-item-resolve.test.ts new file mode 100644 index 000000000..3856e31ce --- /dev/null +++ b/packages/varlock/src/env-graph/test/unprocessed-item-resolve.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { EnvGraph } from '../index'; +import { DotEnvFileDataSource, MultiplePathsContainerDataSource } from '../lib/data-source'; +import { ResolutionError } from '../lib/errors'; + +/** + * finishLoad() returns early when a sibling source fails to parse, leaving + * schema items unprocessed (dataType unset). If that key is also in + * process.env / overrideValues, ConfigItem.resolve used to throw + * `expected dataType to be set` from an un-awaited resolveItem — an + * unhandledRejection while resolveEnvValues() hung forever. + */ +describe('unprocessed item resolve', () => { + it('records a ResolutionError instead of throwing expected dataType to be set', async () => { + const g = new EnvGraph(); + g.overrideValues = { REPRO_VAR: 'fixture-token-not-a-real-secret' }; + // Virtual files must be registered before setRootDataSource so the + // container can feed overrideContents into each DotEnvFileDataSource. + g.setVirtualImports('/virtual', { + '.env.schema': 'REPRO_VAR=\n', + '.env.local': 'VALID=ok\n@#$%^& this is not valid env syntax !!!\n', + }); + await g.setRootDataSource(new MultiplePathsContainerDataSource([ + '/virtual/.env.schema', + '/virtual/.env.local', + ])); + await g.finishLoad(); + + const item = g.configSchema.REPRO_VAR; + expect(item).toBeDefined(); + expect(item.dataType).toBeUndefined(); + + const forwarded: Array = []; + const onRejection = (reason: unknown) => { + forwarded.push(reason); + }; + process.on('unhandledRejection', onRejection); + try { + const settled = g.resolveEnvValues().then( + () => 'RESOLVED' as const, + (e: unknown) => e, + ); + const result = await Promise.race([ + settled, + new Promise<'STILL_PENDING'>((r) => setTimeout(() => r('STILL_PENDING'), 200)), + ]); + expect(result).toBe('RESOLVED'); + expect( + forwarded.some((r) => String((r as Error)?.message ?? r).includes('expected dataType to be set')), + ).toBe(false); + expect(item.resolutionError).toBeInstanceOf(ResolutionError); + expect(item.resolutionError?.message).toMatch(/expected dataType to be set/); + expect(item.errors.length).toBeGreaterThan(0); + } finally { + process.removeListener('unhandledRejection', onRejection); + } + }); +});