From 721b8814844763e79bdd6eee46ebf5a41dece962 Mon Sep 17 00:00:00 2001 From: John <150083550+jd-paul@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:08:23 +0100 Subject: [PATCH 1/3] fix: correct button auxclick type, improve error handling and messages - Fix onauxclick passing ButtonClickType.LEFT instead of MIDDLE - Show user-facing notice in ButtonComponent when actions throw - Replace generic Error with MetaBindInternalError for unknown field types - Fix validator error messages: typo, swapped args, empty description --- packages/core/src/api/API.ts | 7 +- .../validators/ButtonConfigValidators.ts | 13 +- .../core/src/fields/button/ButtonField.ts | 2 +- .../utils/components/ButtonComponent.svelte | 3 + tests/api.test.ts | 54 +++++++- tests/fields/Button.test.ts | 117 ++++++++++++++++- tests/fields/ButtonValidators.test.ts | 121 ++++++++++++++++++ 7 files changed, 305 insertions(+), 12 deletions(-) create mode 100644 tests/fields/ButtonValidators.test.ts diff --git a/packages/core/src/api/API.ts b/packages/core/src/api/API.ts index 592e3b77..3bc9f171 100644 --- a/packages/core/src/api/API.ts +++ b/packages/core/src/api/API.ts @@ -119,8 +119,11 @@ export abstract class API { expectType(type); - // TODO: Nice error message - throw new Error(`Unknown field type: ${type}`); + throw new MetaBindInternalError({ + errorLevel: ErrorLevel.CRITICAL, + effect: 'failed to create field', + cause: `Unknown field type "${type}". Valid types are: ${Object.values(FieldType).join(', ')}`, + }); } /** diff --git a/packages/core/src/config/validators/ButtonConfigValidators.ts b/packages/core/src/config/validators/ButtonConfigValidators.ts index cf0724c1..953ef3e8 100644 --- a/packages/core/src/config/validators/ButtonConfigValidators.ts +++ b/packages/core/src/config/validators/ButtonConfigValidators.ts @@ -27,7 +27,7 @@ function actionFieldNumber(action: string, field: string, description: string) { if (issue.input === undefined) { return `The ${action} action requires a specified ${description} with the '${field}' field.`; } else { - return `The ${action} action requires the value of the '${field}' fields to be a number, but got ${typeof issue.input}.`; + return `The ${action} action requires the value of the '${field}' field to be a number, but got ${typeof issue.input}.`; } }, }); @@ -40,7 +40,7 @@ function actionFieldString(action: string, field: string, description: string) { if (issue.input === undefined) { return `The ${action} action requires a specified ${description} with the '${field}' field.`; } else { - return `The ${action} action requires the value of the '${field}' fields to be a string, but got ${typeof issue.input}.`; + return `The ${action} action requires the value of the '${field}' field to be a string, but got ${typeof issue.input}.`; } }, }); @@ -53,7 +53,7 @@ function actionFieldCoerceString(action: string, field: string, description: str if (issue.input === undefined) { return `The ${action} action requires a specified ${description} with the '${field}' field.`; } else { - return `The ${action} action requires the value of the '${field}' fields to be a string, but got ${typeof issue.input}.`; + return `The ${action} action requires the value of the '${field}' field to be a string, but got ${typeof issue.input}.`; } }, }); @@ -66,7 +66,7 @@ function actionFieldBool(action: string, field: string, description: string) { if (issue.input === undefined) { return `The ${action} action requires a specified ${description} with the '${field}' field.`; } else { - return `The ${action} action requires the value of the '${field}' fields to be a boolean, but got ${typeof issue.input}.`; + return `The ${action} action requires the value of the '${field}' field to be a boolean, but got ${typeof issue.input}.`; } }, }); @@ -91,7 +91,7 @@ export const V_OpenButtonAction = schemaForType()( z.object({ type: z.literal(ButtonActionType.OPEN), link: actionFieldString('open', 'link', 'link to open'), - newTab: actionFieldBool('open', 'newTab', '').optional(), + newTab: actionFieldBool('open', 'newTab', 'flag for whether to open in a new tab').optional(), }), ); @@ -109,7 +109,6 @@ export const V_SleepButtonAction = schemaForType()( }), ); -// TODO: more better error messages export const V_TemplaterCreateNoteButtonAction = schemaForType()( z.object({ type: z.literal(ButtonActionType.TEMPLATER_CREATE_NOTE), @@ -141,7 +140,7 @@ export const V_UpdateMetadataButtonAction = schemaForType { let plugin = new TestMetaBind(); @@ -129,6 +131,56 @@ action: expect(field).toBeInstanceOf(ExcludedMountable); }); + + describe('unknown field type', () => { + let validateSpy: ReturnType | undefined; + let thrownError: MetaBindInternalError | undefined; + + beforeEach(() => { + validateSpy = spyOn(ZodUtils, 'validateAPIArgs').mockImplementation(() => {}); + + const garbageType = 'GARBAGE_TYPE' as FieldType; + + try { + plugin.api.createField( + garbageType, + '', + { + declaration: 'INPUT[toggle:foo]', + renderChildType: RenderChildType.BLOCK, + }, + true, + ); + } catch (e) { + thrownError = e as MetaBindInternalError; + } + }); + + afterEach(() => { + validateSpy?.mockRestore(); + }); + + test('throws MetaBindInternalError', () => { + expect(thrownError).toBeInstanceOf(MetaBindInternalError); + }); + + test('errorLevel is CRITICAL', () => { + expect(thrownError!.errorLevel).toBe(ErrorLevel.CRITICAL); + }); + + test('cause mentions the invalid type', () => { + expect(thrownError!.cause).toContain('GARBAGE_TYPE'); + }); + + test('cause lists valid field types', () => { + expect(thrownError!.cause).toContain('INPUT'); + expect(thrownError!.cause).toContain('BUTTON'); + }); + + test('effect is correct', () => { + expect(thrownError!.effect).toBe('failed to create field'); + }); + }); }); describe('createInlineFieldFromString', () => { diff --git a/tests/fields/Button.test.ts b/tests/fields/Button.test.ts index 09f86bb8..07dd16f9 100644 --- a/tests/fields/Button.test.ts +++ b/tests/fields/Button.test.ts @@ -1,10 +1,15 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import { type ButtonAction, ButtonActionType, ButtonClickContext, ButtonClickType, + ButtonStyleType, } from 'packages/core/src/config/ButtonConfig'; +import { RenderChildType } from 'packages/core/src/config/APIConfigs'; +import { ButtonField } from 'packages/core/src/fields/button/ButtonField'; +import ButtonComponent from 'packages/core/src/utils/components/ButtonComponent.svelte'; +import { mount, unmount } from 'svelte'; import { TestMetaBind } from 'tests/__mocks__/TestPlugin'; let testPlugin: TestMetaBind; @@ -410,4 +415,114 @@ describe('Button', () => { }); } }); + + describe('ButtonClickContext', () => { + test('returns true for MIDDLE click', () => { + const ctx = new ButtonClickContext(ButtonClickType.MIDDLE, false, false, false); + expect(ctx.openInNewTab()).toBe(true); + }); + + test('returns false for LEFT click without ctrlKey', () => { + const ctx = new ButtonClickContext(ButtonClickType.LEFT, false, false, false); + expect(ctx.openInNewTab()).toBe(false); + }); + + test('returns true for LEFT click with ctrlKey', () => { + const ctx = new ButtonClickContext(ButtonClickType.LEFT, false, true, false); + expect(ctx.openInNewTab()).toBe(true); + }); + }); + + describe('ButtonField auxclick', () => { + let container: HTMLDivElement; + let buttonField: ButtonField; + + beforeEach(() => { + testPlugin = new TestMetaBind(); + container = document.createElement('div'); + document.body.appendChild(container); + + buttonField = new ButtonField( + testPlugin, + { label: 'Test', style: ButtonStyleType.DEFAULT }, + testFilePath, + RenderChildType.BLOCK, + undefined, + false, + false, + ); + buttonField.mount(container); + }); + + afterEach(() => { + buttonField?.unmount(); + if (container.parentNode) { + document.body.removeChild(container); + } + }); + + test('dispatches MIDDLE click type on auxclick', async () => { + const spy = spyOn(testPlugin.buttonActionRunner, 'runButtonActions').mockResolvedValue(undefined); + + const buttonEl = container.querySelector('button'); + expect(buttonEl).not.toBeNull(); + + buttonEl!.dispatchEvent(new MouseEvent('auxclick', { bubbles: true })); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(spy).toHaveBeenCalledTimes(1); + const clickContext = spy.mock.calls[0][3] as ButtonClickContext; + expect(clickContext.type).toBe(ButtonClickType.MIDDLE); + }); + }); + + describe('ButtonComponent', () => { + let container: HTMLDivElement; + let consoleWarnSpy: ReturnType | undefined; + + beforeEach(() => { + testPlugin = new TestMetaBind(); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + consoleWarnSpy?.mockRestore(); + if (container.parentNode) { + document.body.removeChild(container); + } + }); + + test('shows a notice when onclick throws', async () => { + const showNoticeSpy = spyOn(testPlugin.internal, 'showNotice'); + consoleWarnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + const component = mount(ButtonComponent, { + target: container, + props: { + mb: testPlugin, + label: 'Test', + onclick: async () => { + throw new Error('intentional test error'); + }, + }, + }); + + const buttonEl = container.querySelector('button'); + expect(buttonEl).not.toBeNull(); + + buttonEl!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(showNoticeSpy).toHaveBeenCalledTimes(1); + expect(showNoticeSpy).toHaveBeenCalledWith( + 'meta-bind | Error while running button action. Check the console for details.', + ); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + + unmount(component); + }); + }); }); diff --git a/tests/fields/ButtonValidators.test.ts b/tests/fields/ButtonValidators.test.ts new file mode 100644 index 00000000..6d1fa164 --- /dev/null +++ b/tests/fields/ButtonValidators.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'bun:test'; +import { + V_CommandButtonAction, + V_OpenButtonAction, + V_SleepButtonAction, + V_TemplaterCreateNoteButtonAction, + V_UpdateMetadataButtonAction, +} from 'packages/core/src/config/validators/ButtonConfigValidators'; + +/** A value that cannot be coerced to string, forcing Zod to invoke the error map. */ +function uncoercible(): unknown { + return { + [Symbol.toPrimitive](): string { + throw new Error('Cannot coerce'); + }, + }; +} + +describe('ButtonConfigValidators', () => { + describe('V_SleepButtonAction', () => { + test('missing ms field uses singular "field" and names the description', () => { + const result = V_SleepButtonAction.safeParse({}); + expect(result.success).toBe(false); + if (result.success) { + throw new Error('Expected parse to fail'); + } + const issue = result.error.issues.find(i => i.path[0] === 'ms'); + expect(issue).toBeDefined(); + expect(issue!.message).toContain('sleep'); + expect(issue!.message).toContain('ms'); + expect(issue!.message).toContain('duration'); + expect(issue!.message).not.toContain('fields'); + }); + + test('wrong type for ms reports number requirement', () => { + const result = V_SleepButtonAction.safeParse({ type: 'sleep', ms: 'not a number' }); + expect(result.success).toBe(false); + if (result.success) { + throw new Error('Expected parse to fail'); + } + const issue = result.error.issues.find(i => i.path[0] === 'ms'); + expect(issue).toBeDefined(); + expect(issue!.message).toContain('sleep'); + expect(issue!.message).toContain('ms'); + expect(issue!.message).toContain('number'); + expect(issue!.message).not.toContain('fields'); + }); + }); + + describe('V_UpdateMetadataButtonAction', () => { + test('invalid value field refers to the field as "value"', () => { + // z.coerce.string coerces undefined, so we force a coercion failure + // to exercise the error-map message. + const result = V_UpdateMetadataButtonAction.safeParse({ + type: 'updateMetadata', + bindTarget: 'foo', + evaluate: false, + value: uncoercible(), + }); + expect(result.success).toBe(false); + if (result.success) { + throw new Error('Expected parse to fail'); + } + expect(result.error.issues[0].message).toContain('updateMetadata'); + expect(result.error.issues[0].message).toContain("'value'"); + expect(result.error.issues[0].message).not.toContain("'value for the update'"); + }); + }); + + describe('V_OpenButtonAction', () => { + test('missing optional newTab succeeds', () => { + const result = V_OpenButtonAction.safeParse({ + type: 'open', + link: '[[test.md]]', + }); + expect(result.success).toBe(true); + }); + + test('invalid newTab type reports boolean requirement without double-space', () => { + const result = V_OpenButtonAction.safeParse({ + type: 'open', + link: '[[test.md]]', + newTab: 'not a boolean', + }); + expect(result.success).toBe(false); + if (result.success) { + throw new Error('Expected parse to fail'); + } + expect(result.error.issues[0].message).toContain('open'); + expect(result.error.issues[0].message).toContain('newTab'); + expect(result.error.issues[0].message).toContain('boolean'); + expect(result.error.issues[0].message).not.toContain(' '); + }); + }); + + describe('V_CommandButtonAction', () => { + test('missing command field names the action and description', () => { + const result = V_CommandButtonAction.safeParse({ type: 'command' }); + expect(result.success).toBe(false); + if (result.success) { + throw new Error('Expected parse to fail'); + } + expect(result.error.issues[0].message).toContain('command'); + expect(result.error.issues[0].message).toContain("'command'"); + expect(result.error.issues[0].message).toContain('command to run'); + }); + }); + + describe('V_TemplaterCreateNoteButtonAction', () => { + test('missing templateFile names the action and description', () => { + const result = V_TemplaterCreateNoteButtonAction.safeParse({ type: 'templaterCreateNote' }); + expect(result.success).toBe(false); + if (result.success) { + throw new Error('Expected parse to fail'); + } + expect(result.error.issues[0].message).toContain('templaterCreateNote'); + expect(result.error.issues[0].message).toContain('templateFile'); + expect(result.error.issues[0].message).toContain('template file path'); + }); + }); +}); From eb445725ee06443ec032c21bd18d62bc5b366ba6 Mon Sep 17 00:00:00 2001 From: John <150083550+jd-paul@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:04:14 +0100 Subject: [PATCH 2/3] fix: apply formatting fixes --- .gitignore | 12 +++++++++++- .../core/src/utils/components/ButtonComponent.svelte | 4 +--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 8665d837..2157ecc8 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,14 @@ exampleVault/.obsidian/plugins/obsidian-meta-bind-plugin/* coverage/ -dist/ \ No newline at end of file +dist/ +# Geth collective intelligence — session logs and working memory +geth/geth-memory/ +geth/programs/*/memory.md +geth/programs/*/legacy-memory.md +geth/legacy-memory/ +geth/core/lattice.md +geth/core/feelings.md + +# Envoy debriefs — consumed artifacts, not permanent records +envoys/debriefs/ diff --git a/packages/core/src/utils/components/ButtonComponent.svelte b/packages/core/src/utils/components/ButtonComponent.svelte index 1475f6d7..9d136c34 100644 --- a/packages/core/src/utils/components/ButtonComponent.svelte +++ b/packages/core/src/utils/components/ButtonComponent.svelte @@ -42,9 +42,7 @@ } } catch (e) { console.warn('failed to run button component on click', e); - mb.internal.showNotice( - 'meta-bind | Error while running button action. Check the console for details.', - ); + mb.internal.showNotice('meta-bind | Error while running button action. Check the console for details.'); } finally { disabled = false; } From 16ebd7b2155773813a2b78536825eb0148f95227 Mon Sep 17 00:00:00 2001 From: John Paul <150083550+jd-paul@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:45:20 +0100 Subject: [PATCH 3/3] Reverted gitignore to original --- .gitignore | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.gitignore b/.gitignore index 2157ecc8..a8f96466 100644 --- a/.gitignore +++ b/.gitignore @@ -40,13 +40,3 @@ exampleVault/.obsidian/plugins/obsidian-meta-bind-plugin/* coverage/ dist/ -# Geth collective intelligence — session logs and working memory -geth/geth-memory/ -geth/programs/*/memory.md -geth/programs/*/legacy-memory.md -geth/legacy-memory/ -geth/core/lattice.md -geth/core/feelings.md - -# Envoy debriefs — consumed artifacts, not permanent records -envoys/debriefs/