diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts index 1ad3490c..0cff33ca 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -27,7 +27,9 @@ import { fileURLToPath } from "node:url"; import type { Options } from "tsup"; import tsupConfig, { INK_FORM_INCOMPLETE_HINT, + INK_FORM_SUBMIT_BUTTON, fixInkFormIncompleteHint, + inkFormLabelPatch, } from "../tsup.config.js"; const clientDir = path.resolve( @@ -86,6 +88,15 @@ function reactRenderingDependencies(): string[] { ); } +/** The real `ink-form` module the label patch targets. */ +const submitButtonPath = path.join( + clientDir, + "node_modules", + "ink-form", + "lib", + "SubmitButton.js", +); + const config = singleConfig(tsupConfig); const noExternal = stringEntries(config.noExternal); const external = stringEntries(config.external); @@ -127,22 +138,64 @@ describe("tui tsup config", () => { // Read the real dependency, so an `ink-form` upgrade that fixes or rewords // the label fails here — the patch must then be removed, not left silently // matching nothing. - const submitButton = readFileSync( - path.join( - clientDir, - "node_modules", - "ink-form", - "lib", - "SubmitButton.js", - ), - "utf8", + const patched = fixInkFormIncompleteHint( + readFileSync(submitButtonPath, "utf8"), + submitButtonPath, ); - - const patched = fixInkFormIncompleteHint(submitButton, "SubmitButton.js"); expect(patched).toContain(INK_FORM_INCOMPLETE_HINT.fixed); expect(patched).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); }); + it("routes the real module through the plugin, not just the helper", async () => { + // The helper is only reached if the plugin's onLoad filter matches. Drive + // the plugin as esbuild would — register, then invoke — so a filter that + // stops matching the real module's path fails here instead of no-opping + // through a green build (the exact silence this patch exists to prevent). + type OnLoadCallback = (args: { + path: string; + }) => Promise<{ contents: string }>; + const registered: { filter: RegExp; callback: OnLoadCallback }[] = []; + + const build = { + onLoad: (options: { filter: RegExp }, callback: OnLoadCallback) => + registered.push({ filter: options.filter, callback }), + }; + // esbuild's `PluginBuild` carries far more than this patch touches, and the + // stub above deliberately implements only the one hook it registers — so + // the double cast is bridging a real structural gap, not hiding a mismatch. + // A hook the patch called but the stub lacks fails as undefined here rather + // than passing silently. + inkFormLabelPatch.setup( + build as unknown as Parameters[0], + ); + + expect(registered).toHaveLength(1); + const [{ filter, callback }] = registered; + expect(filter.test(submitButtonPath)).toBe(true); + + const { contents } = await callback({ path: submitButtonPath }); + expect(contents).toContain(INK_FORM_INCOMPLETE_HINT.fixed); + expect(contents).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); + }); + + it("scopes the patch to ink-form's SubmitButton and nothing else", () => { + expect(INK_FORM_SUBMIT_BUTTON.test(submitButtonPath)).toBe(true); + // A Windows-style path must match too — the filter runs against whatever + // esbuild resolved, and its separator is the platform's. + expect( + INK_FORM_SUBMIT_BUTTON.test( + "C:\\repo\\node_modules\\ink-form\\lib\\SubmitButton.js", + ), + ).toBe(true); + for (const other of [ + "/repo/node_modules/ink-form/lib/Form.js", + "/repo/node_modules/ink-select-input/build/SubmitButton.js", + "/repo/src/SubmitButton.jsx", + ]) { + expect(INK_FORM_SUBMIT_BUTTON.test(other), other).toBe(false); + } + }); + it("fails loudly rather than silently skipping a label it cannot find", () => { expect(() => fixInkFormIncompleteHint("no such label here", "SubmitButton.js"), diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 3a4c66f7..b75dd86e 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -1,5 +1,4 @@ -import { defineConfig } from "tsup"; -import type { Plugin } from "esbuild"; +import { defineConfig, type Options } from "tsup"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -45,11 +44,25 @@ export function fixInkFormIncompleteHint(source: string, file: string): string { ); } -const inkFormLabelPatch: Plugin = { +/** + * Which module the patch above is applied to. + * + * Exported so the tests can check it against the *resolved* path of the real + * `ink-form` module — a filter that stops matching is the one way this patch + * can silently no-op, since `fixInkFormIncompleteHint` would then never run. + */ +export const INK_FORM_SUBMIT_BUTTON = /ink-form[\\/]lib[\\/]SubmitButton\.js$/; + +// The plugin type is derived from tsup rather than imported from `esbuild`: +// `esbuild` is tsup's transitive dependency, not a declared one of this client, +// so a direct import typechecks only while npm happens to hoist it. +type EsbuildPlugin = NonNullable[number]; + +export const inkFormLabelPatch: EsbuildPlugin = { name: "ink-form-label-patch", setup(build) { build.onLoad( - { filter: /ink-form[\\/]lib[\\/]SubmitButton\.js$/ }, + { filter: INK_FORM_SUBMIT_BUTTON }, async ({ path: file }) => ({ contents: fixInkFormIncompleteHint(await readFile(file, "utf8"), file), loader: "js",