From 499512f9f0f9ba14f575eef5ae4ff7741f3221d0 Mon Sep 17 00:00:00 2001 From: Morne Alberts Date: Thu, 30 Jul 2026 10:22:08 +0200 Subject: [PATCH 1/3] Test RedHerb's Vue components RedHerb is the live reference for extending NeoWiki, but nothing exercised its frontend, so a change to NeoWiki's public API could rot the examples unnoticed. Mount each of its Vue components against the real ext.neowiki API and assert the behaviour an extension author copies them for. The components are ResourceLoader package files: CommonJS closures that pull vue, ext.neowiki and the CodexModule-generated codex.js and icons.json in with require(). A test-only Vite plugin rewrites those two constructs to ES modules and resolves the provided modules as ext.neowiki would, so the examples share the vue and Codex instances the rest of the suite uses and stay copyable into a real extension. ci-ts also runs on tests/RedHerb changes now, so editing an example runs the tests covering it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci-ts.yml | 2 + .../RedHerb/ColorAttributesEditor.spec.ts | 153 ++++++++++ .../tests/RedHerb/ColorDisplay.spec.ts | 76 +++++ .../tests/RedHerb/ColorInput.spec.ts | 132 +++++++++ .../tests/RedHerb/CreateChildDialog.spec.ts | 235 ++++++++++++++++ .../RedHerb/EditMainSubjectDialog.spec.ts | 262 ++++++++++++++++++ .../tests/RedHerb/RedHerbCard.spec.ts | 228 +++++++++++++++ .../tests/RedHerb/RedHerbRegistration.ts | 70 +++++ .../tests/RedHerb/SubjectFinderPanel.spec.ts | 206 ++++++++++++++ .../tests/RedHerb/redherb-modules.d.ts | 18 ++ .../tests/RedHerb/registration.spec.ts | 86 ++++++ .../tests/RedHerb/resourceLoaderCommonJs.ts | 114 ++++++++ resources/ext.neowiki/vitest.config.ts | 23 ++ 13 files changed, 1605 insertions(+) create mode 100644 resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts create mode 100644 resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts create mode 100644 resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts create mode 100644 resources/ext.neowiki/tests/RedHerb/CreateChildDialog.spec.ts create mode 100644 resources/ext.neowiki/tests/RedHerb/EditMainSubjectDialog.spec.ts create mode 100644 resources/ext.neowiki/tests/RedHerb/RedHerbCard.spec.ts create mode 100644 resources/ext.neowiki/tests/RedHerb/RedHerbRegistration.ts create mode 100644 resources/ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts create mode 100644 resources/ext.neowiki/tests/RedHerb/redherb-modules.d.ts create mode 100644 resources/ext.neowiki/tests/RedHerb/registration.spec.ts create mode 100644 resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts diff --git a/.github/workflows/ci-ts.yml b/.github/workflows/ci-ts.yml index df4285f29..4f5408535 100644 --- a/.github/workflows/ci-ts.yml +++ b/.github/workflows/ci-ts.yml @@ -6,10 +6,12 @@ on: paths: - '.github/workflows/ci-ts.yml' - 'resources/**' + - 'tests/RedHerb/**' pull_request: paths: - '.github/workflows/ci-ts.yml' - 'resources/**' + - 'tests/RedHerb/**' jobs: build: diff --git a/resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts b/resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts new file mode 100644 index 000000000..e1328e5a7 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts @@ -0,0 +1,153 @@ +import './../neowiki-test-env.ts'; +import { DOMWrapper, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Ref } from 'vue'; +import { CdxTextInput } from '@wikimedia/codex'; +import { UseSortableOptions } from '@/composables/useSortable.ts'; +import { createTestWrapper, setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend, newColorProperty } from './RedHerbRegistration.ts'; + +const sortableCalls: UseSortableOptions[] = []; + +vi.mock( '@/composables/useSortable.ts', () => ( { + useSortable: ( _containerRef: Ref, options: UseSortableOptions ): void => { + sortableCalls.push( options ); + }, +} ) ); + +const ITEM = '.ext-redherb-color-attributes__item'; +const INVALID_SWATCH = '.ext-redherb-color-attributes__swatch--invalid'; +const REMOVE_BUTTON = '.ext-redherb-color-attributes__remove'; +const ADD_LABEL = 'redherb-color-add-color'; + +// Imported after vi.mock so the component picks the stubbed composable up. +const ColorAttributesEditor = ( await import( '@redherb/ColorAttributesEditor.vue' ) ).default; + +function newWrapper( allowedColors: string[] ): VueWrapper { + return createTestWrapper( ColorAttributesEditor, { + property: newColorProperty( { allowedColors: allowedColors } ), + } ); +} + +function colorInputs( wrapper: VueWrapper ): string[] { + return wrapper.findAll( 'input' ).map( ( input ) => ( input.element as HTMLInputElement ).value ); +} + +function addButton( wrapper: VueWrapper ): DOMWrapper { + const button = wrapper.findAll( 'button' ).find( ( candidate ) => candidate.text().includes( ADD_LABEL ) ); + + expect( button, 'add-color button' ).toBeDefined(); + return button!; +} + +async function typeInto( wrapper: VueWrapper, index: number, color: string ): Promise { + await colorInputAt( wrapper, index ).vm.$emit( 'update:modelValue', color ); +} + +function colorInputAt( wrapper: VueWrapper, index: number ): VueWrapper { + return wrapper.findAllComponents( CdxTextInput )[ index ] as unknown as VueWrapper; +} + +function lastEmittedPalette( wrapper: VueWrapper ): string[] { + const emitted = wrapper.emitted( 'update:property' ); + expect( emitted, 'update:property' ).toBeTruthy(); + return ( emitted![ emitted!.length - 1 ][ 0 ] as { allowedColors: string[] } ).allowedColors; +} + +describe( 'ColorAttributesEditor', () => { + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + sortableCalls.length = 0; + setupMwMock(); + } ); + + it( 'renders an entry per allowed color', () => { + const wrapper = newWrapper( [ '#ff5733', '#000000' ] ); + + expect( wrapper.findAll( ITEM ) ).toHaveLength( 2 ); + expect( colorInputs( wrapper ) ).toEqual( [ '#ff5733', '#000000' ] ); + } ); + + it( 'renders no entries for an empty palette', () => { + const wrapper = newWrapper( [] ); + + expect( wrapper.findAll( ITEM ) ).toHaveLength( 0 ); + } ); + + it( 'appends an empty entry when a color is added', async () => { + const wrapper = newWrapper( [ '#ff5733' ] ); + + await addButton( wrapper ).trigger( 'click' ); + + expect( colorInputs( wrapper ) ).toEqual( [ '#ff5733', '' ] ); + expect( lastEmittedPalette( wrapper ) ).toEqual( [ '#ff5733', '' ] ); + } ); + + it( 'emits the palette with the edited color', async () => { + const wrapper = newWrapper( [ '#ff5733', '#000000' ] ); + + await typeInto( wrapper, 1, '#ffffff' ); + + expect( lastEmittedPalette( wrapper ) ).toEqual( [ '#ff5733', '#ffffff' ] ); + } ); + + it( 'emits the palette without the removed color', async () => { + const wrapper = newWrapper( [ '#ff5733', '#000000', '#ffffff' ] ); + + await wrapper.findAll( REMOVE_BUTTON )[ 1 ].trigger( 'click' ); + + expect( lastEmittedPalette( wrapper ) ).toEqual( [ '#ff5733', '#ffffff' ] ); + } ); + + it( 'keeps the remaining entries in order after a removal', async () => { + const wrapper = newWrapper( [ '#ff5733', '#000000', '#ffffff' ] ); + + await wrapper.findAll( REMOVE_BUTTON )[ 0 ].trigger( 'click' ); + + expect( colorInputs( wrapper ) ).toEqual( [ '#000000', '#ffffff' ] ); + } ); + + it( 'marks entries that are not a six-digit hex color', () => { + const wrapper = newWrapper( [ '#ff5733', 'rebeccapurple' ] ); + + const items = wrapper.findAll( ITEM ); + expect( items[ 0 ].find( INVALID_SWATCH ).exists() ).toBe( false ); + expect( items[ 1 ].find( INVALID_SWATCH ).exists() ).toBe( true ); + } ); + + it( 'emits the reordered palette when an entry is dragged', () => { + const wrapper = newWrapper( [ '#ff5733', '#000000', '#ffffff' ] ); + + sortableCalls[ 0 ].onReorder!( 0, 2 ); + + expect( lastEmittedPalette( wrapper ) ).toEqual( [ '#000000', '#ffffff', '#ff5733' ] ); + } ); + + it( 'reorders only through the drag handle', () => { + newWrapper( [ '#ff5733' ] ); + + expect( sortableCalls[ 0 ].handle ).toBe( '.ext-redherb-color-attributes__drag-handle' ); + } ); + + it( 'shows a palette replaced from outside the editor', async () => { + const wrapper = newWrapper( [ '#ff5733' ] ); + + await wrapper.setProps( { property: newColorProperty( { allowedColors: [ '#000000', '#ffffff' ] } ) } ); + + expect( colorInputs( wrapper ) ).toEqual( [ '#000000', '#ffffff' ] ); + } ); + + // Rebuilding the entries would give them fresh keys, so Vue would replace the inputs + // and drop the caret out of the one being typed into. + it( 'leaves the entry inputs in place when the property echoes the palette back', async () => { + const wrapper = newWrapper( [ '#ff5733' ] ); + const inputBeforeEcho = wrapper.find( 'input' ).element; + + await wrapper.setProps( { property: newColorProperty( { allowedColors: [ '#ff5733' ] } ) } ); + + expect( wrapper.find( 'input' ).element ).toBe( inputBeforeEcho ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts b/resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts new file mode 100644 index 000000000..bbe8e5ddf --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts @@ -0,0 +1,76 @@ +import './../neowiki-test-env.ts'; +import { mount, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import ColorDisplay from '@redherb/ColorDisplay.vue'; +import { newNumberValue, newStringValue, Value } from '@/domain/Value.ts'; +import { setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend, newColorProperty } from './RedHerbRegistration.ts'; + +const SWATCH = '.ext-redherb-color-display__swatch'; +const HEX = '.ext-redherb-color-display__hex'; + +function newWrapper( value: Value, allowedColors: string[] = [] ): VueWrapper { + return mount( ColorDisplay, { + props: { + value: value, + property: newColorProperty( { allowedColors: allowedColors } ), + }, + } ); +} + +describe( 'ColorDisplay', () => { + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock( { + messages: { + 'redherb-color-invalid-fallback': ( raw: string ) => `not a color: ${ raw }`, + }, + } ); + } ); + + it( 'renders the hex code of a valid color', () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ) ); + + expect( wrapper.find( HEX ).text() ).toBe( '#ff5733' ); + } ); + + it( 'paints the swatch with the color', () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ) ); + + expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toBe( 'background-color: rgb(255, 87, 51);' ); + } ); + + it( 'accepts uppercase hex digits', () => { + const wrapper = newWrapper( newStringValue( '#FF5733' ) ); + + expect( wrapper.find( HEX ).text() ).toBe( '#FF5733' ); + } ); + + it( 'renders the fallback message instead of a swatch for a value that is not a six-digit hex color', () => { + const wrapper = newWrapper( newStringValue( 'rebeccapurple' ) ); + + expect( wrapper.find( SWATCH ).exists() ).toBe( false ); + expect( wrapper.text() ).toContain( 'not a color: rebeccapurple' ); + } ); + + it( 'renders the fallback message for three-digit shorthand', () => { + const wrapper = newWrapper( newStringValue( '#f53' ) ); + + expect( wrapper.text() ).toContain( 'not a color: #f53' ); + } ); + + it( 'renders the fallback message for a value of the wrong type', () => { + const wrapper = newWrapper( newNumberValue( 42 ) ); + + expect( wrapper.find( SWATCH ).exists() ).toBe( false ); + } ); + + it( 'renders a swatch for a stored color the palette no longer allows', () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ), [ '#000000' ] ); + + expect( wrapper.find( HEX ).text() ).toBe( '#ff5733' ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts b/resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts new file mode 100644 index 000000000..04d0a3bdb --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts @@ -0,0 +1,132 @@ +import './../neowiki-test-env.ts'; +import { VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { CdxTextInput } from '@wikimedia/codex'; +import ColorInput from '@redherb/ColorInput.vue'; +import { newStringValue, StringValue, Value } from '@/domain/Value.ts'; +import { PropertyDefinition } from '@/domain/PropertyDefinition.ts'; +import { createTestWrapper, setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend, newColorProperty } from './RedHerbRegistration.ts'; + +const SWATCH = '.ext-redherb-color-input__swatch'; +const EMPTY_SWATCH = '.ext-redherb-color-input__swatch--empty'; + +interface ColorInputExposes { + getCurrentValue: () => Value | undefined; +} + +function newWrapper( + modelValue: Value | undefined = undefined, + property: PropertyDefinition = newColorProperty(), +): VueWrapper { + return createTestWrapper( ColorInput, { + modelValue: modelValue, + property: property, + label: 'Favourite color', + } ); +} + +async function typeColor( wrapper: VueWrapper, color: string ): Promise { + await wrapper.findComponent( CdxTextInput ).vm.$emit( 'update:modelValue', color ); +} + +function lastEmittedValue( wrapper: VueWrapper ): Value | undefined { + const emitted = wrapper.emitted( 'update:modelValue' ); + expect( emitted ).toBeTruthy(); + return emitted![ emitted!.length - 1 ][ 0 ] as Value | undefined; +} + +describe( 'ColorInput', () => { + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock(); + } ); + + it( 'renders the label', () => { + const wrapper = newWrapper(); + + expect( wrapper.text() ).toContain( 'Favourite color' ); + } ); + + it( 'shows the current color in the text input', () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ) ); + + expect( wrapper.findComponent( CdxTextInput ).props( 'modelValue' ) ).toBe( '#ff5733' ); + } ); + + it( 'previews the current color in the swatch', () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ) ); + + expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toBe( 'background-color: rgb(255, 87, 51);' ); + } ); + + it( 'marks the swatch as empty when there is no color yet', () => { + const wrapper = newWrapper(); + + expect( wrapper.find( EMPTY_SWATCH ).exists() ).toBe( true ); + } ); + + it( 'marks the swatch as empty while the typed color is incomplete', async () => { + const wrapper = newWrapper(); + + await typeColor( wrapper, '#ff57' ); + + expect( wrapper.find( EMPTY_SWATCH ).exists() ).toBe( true ); + } ); + + it( 'previews the typed color once it is a complete hex color', async () => { + const wrapper = newWrapper(); + + await typeColor( wrapper, '#ff5733' ); + + expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toBe( 'background-color: rgb(255, 87, 51);' ); + } ); + + it( 'emits the typed color as a string value', async () => { + const wrapper = newWrapper(); + + await typeColor( wrapper, '#ff5733' ); + + expect( lastEmittedValue( wrapper ) ).toEqual( newStringValue( '#ff5733' ) ); + } ); + + it( 'emits a color the palette does not allow, leaving validation to the backend', async () => { + const wrapper = newWrapper( undefined, newColorProperty( { allowedColors: [ '#000000' ] } ) ); + + await typeColor( wrapper, '#ff5733' ); + + expect( lastEmittedValue( wrapper ) ).toEqual( newStringValue( '#ff5733' ) ); + } ); + + it( 'emits no value once the color is cleared', async () => { + const wrapper = newWrapper( newStringValue( '#ff5733' ) ); + + await typeColor( wrapper, '' ); + + expect( lastEmittedValue( wrapper ) ).toBeUndefined(); + } ); + + it( 'exposes the current color to the subject editor', async () => { + const wrapper = newWrapper(); + + await typeColor( wrapper, '#ff5733' ); + + const exposed = ( wrapper.vm as unknown as ColorInputExposes ).getCurrentValue(); + expect( ( exposed as StringValue ).parts ).toEqual( [ '#ff5733' ] ); + } ); + + it( 'marks the field optional when the property is not required', () => { + const wrapper = newWrapper( undefined, newColorProperty( { required: false } ) ); + + expect( wrapper.find( '.cdx-label__label__optional-flag' ).exists() ).toBe( true ); + } ); + + it( 'does not mark the field optional when the property is required', () => { + const wrapper = newWrapper( undefined, newColorProperty( { required: true } ) ); + + expect( wrapper.find( '.cdx-label__label__optional-flag' ).exists() ).toBe( false ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/CreateChildDialog.spec.ts b/resources/ext.neowiki/tests/RedHerb/CreateChildDialog.spec.ts new file mode 100644 index 000000000..b562d47ac --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/CreateChildDialog.spec.ts @@ -0,0 +1,235 @@ +import './../neowiki-test-env.ts'; +import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { Pinia } from 'pinia'; +import { ref, Ref } from 'vue'; +import { CdxDialog, CdxTextInput } from '@wikimedia/codex'; +import CreateChildDialog from '@redherb/createChild/CreateChildDialog.vue'; +import createChildConstants from '@redherb/createChild/constants.js'; +import { createPropertyDefinitionFromJson, PropertyName } from '@/domain/PropertyDefinition.ts'; +import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; +import { Schema } from '@/domain/Schema.ts'; +import { StatementList } from '@/domain/StatementList.ts'; +import { newStringValue } from '@/domain/Value.ts'; +import { TextType } from '@/domain/propertyTypes/Text.ts'; +import { useSchemaStore } from '@/stores/SchemaStore.ts'; +import { useSubjectStore } from '@/stores/SubjectStore.ts'; +import SubjectEditor from '@/components/SubjectEditor/SubjectEditor.vue'; +import { NeoWikiTestServices } from '../NeoWikiTestServices.ts'; +import { createI18nMock, setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend } from './RedHerbRegistration.ts'; + +const DIALOG_OPEN_KEY = createChildConstants.DIALOG_OPEN_KEY; + +const SCHEMA_NAME = 'Company'; +const PAGE_ID = 42; + +const companySchema = new Schema( + SCHEMA_NAME, + 'A company', + new PropertyDefinitionList( [ + createPropertyDefinitionFromJson( 'tradingName', { type: TextType.typeName } ), + ] ), +); + +describe( 'CreateChildDialog', () => { + let pinia: Pinia; + let open: Ref; + + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock( { config: { wgArticleId: PAGE_ID } } ); + ( globalThis as any ).mw.log = { error: vi.fn() }; + + pinia = createPinia(); + setActivePinia( pinia ); + + useSchemaStore().getOrFetchSchema = vi.fn().mockResolvedValue( companySchema ); + useSubjectStore().createChildSubject = vi.fn().mockResolvedValue( undefined ); + + open = ref( false ); + } ); + + function newWrapper(): VueWrapper { + return mount( CreateChildDialog, { + global: { + plugins: [ pinia ], + provide: { + ...NeoWikiTestServices.getServices(), + [ DIALOG_OPEN_KEY ]: open, + }, + directives: { tooltip: {} }, + mocks: { $i18n: createI18nMock() }, + stubs: { teleport: true }, + }, + } ); + } + + async function openDialog(): Promise { + const wrapper = newWrapper(); + open.value = true; + await flushPromises(); + return wrapper; + } + + function textInputAt( wrapper: VueWrapper, index: number ): VueWrapper { + return wrapper.findAllComponents( CdxTextInput )[ index ] as unknown as VueWrapper; + } + + function labelInput( wrapper: VueWrapper ): VueWrapper { + return textInputAt( wrapper, 0 ); + } + + function labelValue( wrapper: VueWrapper ): string { + return ( wrapper.findAll( 'input' )[ 0 ].element as HTMLInputElement ).value; + } + + async function typeLabel( wrapper: VueWrapper, label: string ): Promise { + await labelInput( wrapper ).vm.$emit( 'update:modelValue', label ); + } + + async function save( wrapper: VueWrapper ): Promise { + await wrapper.findComponent( CdxDialog ).vm.$emit( 'primary' ); + } + + it( 'loads the schema of the subjects it creates when opened', async () => { + await openDialog(); + + expect( useSchemaStore().getOrFetchSchema ).toHaveBeenCalledWith( SCHEMA_NAME ); + } ); + + it( 'does not load the schema before it is opened', async () => { + newWrapper(); + await flushPromises(); + + expect( useSchemaStore().getOrFetchSchema ).not.toHaveBeenCalled(); + } ); + + it( 'loads the schema only once across openings', async () => { + const wrapper = await openDialog(); + + open.value = false; + await flushPromises(); + open.value = true; + await flushPromises(); + + expect( useSchemaStore().getOrFetchSchema ).toHaveBeenCalledTimes( 1 ); + expect( wrapper.findComponent( SubjectEditor ).exists() ).toBe( true ); + } ); + + it( 'offers an editor for the blank statements of the schema', async () => { + const wrapper = await openDialog(); + + const editor = wrapper.findComponent( SubjectEditor ); + expect( editor.props( 'statements' ) ).toEqual( companySchema.blankStatements() ); + expect( editor.props( 'schema' ) ).toBe( companySchema ); + } ); + + it( 'creates a child subject of the current page from the label and the edited statements', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, 'Acme' ); + + await save( wrapper ); + await flushPromises(); + + expect( useSubjectStore().createChildSubject ).toHaveBeenCalledWith( + PAGE_ID, + 'Acme', + SCHEMA_NAME, + expect.any( StatementList ), + ); + } ); + + it( 'passes on the values entered into the editor', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, 'Acme' ); + await textInputAt( wrapper, 1 ).vm.$emit( 'update:modelValue', 'Acme Corporation' ); + + await save( wrapper ); + await flushPromises(); + + const statements = vi.mocked( useSubjectStore().createChildSubject ).mock.calls[ 0 ][ 3 ]; + expect( statements.get( new PropertyName( 'tradingName' ) ).value ).toEqual( newStringValue( 'Acme Corporation' ) ); + } ); + + it( 'trims the label', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, ' Acme ' ); + + await save( wrapper ); + await flushPromises(); + + expect( useSubjectStore().createChildSubject ).toHaveBeenCalledWith( + PAGE_ID, + 'Acme', + SCHEMA_NAME, + expect.anything(), + ); + } ); + + it( 'creates nothing when the label is blank', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, ' ' ); + + await save( wrapper ); + await flushPromises(); + + expect( useSubjectStore().createChildSubject ).not.toHaveBeenCalled(); + } ); + + it( 'closes after creating the subject', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, 'Acme' ); + + await save( wrapper ); + await flushPromises(); + + expect( open.value ).toBe( false ); + } ); + + it( 'stays open when creating the subject fails', async () => { + useSubjectStore().createChildSubject = vi.fn().mockRejectedValue( new Error( 'Save failed' ) ); + const wrapper = await openDialog(); + await typeLabel( wrapper, 'Acme' ); + + await save( wrapper ); + await flushPromises(); + + expect( open.value ).toBe( true ); + expect( mw.notify ).toHaveBeenCalledWith( 'redherb-create-child-error', { type: 'error' } ); + } ); + + it( 'closes and reports when the schema cannot be loaded', async () => { + useSchemaStore().getOrFetchSchema = vi.fn().mockRejectedValue( new Error( 'No such schema' ) ); + + const wrapper = await openDialog(); + + expect( wrapper.findComponent( SubjectEditor ).exists() ).toBe( false ); + expect( open.value ).toBe( false ); + expect( mw.notify ).toHaveBeenCalledWith( 'No such schema', { type: 'error' } ); + } ); + + it( 'closes when cancelled', async () => { + const wrapper = await openDialog(); + + await wrapper.findComponent( CdxDialog ).vm.$emit( 'default' ); + + expect( open.value ).toBe( false ); + } ); + + it( 'forgets the previous label when reopened', async () => { + const wrapper = await openDialog(); + await typeLabel( wrapper, 'Acme' ); + + open.value = false; + await flushPromises(); + open.value = true; + await flushPromises(); + + expect( labelValue( wrapper ) ).toBe( '' ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/EditMainSubjectDialog.spec.ts b/resources/ext.neowiki/tests/RedHerb/EditMainSubjectDialog.spec.ts new file mode 100644 index 000000000..fe06288f9 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/EditMainSubjectDialog.spec.ts @@ -0,0 +1,262 @@ +import './../neowiki-test-env.ts'; +import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { Pinia } from 'pinia'; +import { reactive } from 'vue'; +import { CdxDialog, CdxTextInput } from '@wikimedia/codex'; +import EditMainSubjectDialog from '@redherb/editMainSubject/EditMainSubjectDialog.vue'; +import editMainSubjectConstants from '@redherb/editMainSubject/constants.js'; +import { createPropertyDefinitionFromJson, PropertyName } from '@/domain/PropertyDefinition.ts'; +import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; +import { Schema } from '@/domain/Schema.ts'; +import { Statement } from '@/domain/Statement.ts'; +import { StatementList } from '@/domain/StatementList.ts'; +import { Subject } from '@/domain/Subject.ts'; +import { SubjectId } from '@/domain/SubjectId.ts'; +import { newStringValue } from '@/domain/Value.ts'; +import { TextType } from '@/domain/propertyTypes/Text.ts'; +import { useSchemaStore } from '@/stores/SchemaStore.ts'; +import { useSubjectStore } from '@/stores/SubjectStore.ts'; +import SubjectEditor from '@/components/SubjectEditor/SubjectEditor.vue'; +import { NeoWikiTestServices } from '../NeoWikiTestServices.ts'; +import { createI18nMock, setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend } from './RedHerbRegistration.ts'; + +const DIALOG_STATE_KEY = editMainSubjectConstants.DIALOG_STATE_KEY; + +const SCHEMA_NAME = 'Company'; +const SUBJECT_ID = 's1demo5sssssss1'; + +const companySchema = new Schema( + SCHEMA_NAME, + 'A company', + new PropertyDefinitionList( [ + createPropertyDefinitionFromJson( 'tradingName', { type: TextType.typeName } ), + ] ), +); + +const company = new Subject( + new SubjectId( SUBJECT_ID ), + 'Acme', + SCHEMA_NAME, + new StatementList( [ + new Statement( new PropertyName( 'tradingName' ), TextType.typeName, newStringValue( 'Acme Corporation' ) ), + ] ), +); + +function pendingForever(): Promise { + return new Promise( () => { + // Never settles, so the caller stays in its loading state. + } ); +} + +interface DialogState { + open: boolean; + subjectId: string | null; +} + +describe( 'EditMainSubjectDialog', () => { + let pinia: Pinia; + let state: DialogState; + + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock(); + ( globalThis as any ).mw.log = { error: vi.fn() }; + + pinia = createPinia(); + setActivePinia( pinia ); + + useSubjectStore().getOrFetchSubject = vi.fn().mockResolvedValue( company ); + useSubjectStore().updateSubject = vi.fn().mockResolvedValue( undefined ); + useSchemaStore().getOrFetchSchema = vi.fn().mockResolvedValue( companySchema ); + + state = reactive( { open: false, subjectId: null } ); + } ); + + function newWrapper(): VueWrapper { + return mount( EditMainSubjectDialog, { + global: { + plugins: [ pinia ], + provide: { + ...NeoWikiTestServices.getServices(), + [ DIALOG_STATE_KEY ]: state, + }, + directives: { tooltip: {} }, + mocks: { $i18n: createI18nMock() }, + stubs: { teleport: true }, + }, + } ); + } + + async function openForSubject(): Promise { + const wrapper = newWrapper(); + state.subjectId = SUBJECT_ID; + state.open = true; + await flushPromises(); + return wrapper; + } + + function textInputAt( wrapper: VueWrapper, index: number ): VueWrapper { + return wrapper.findAllComponents( CdxTextInput )[ index ] as unknown as VueWrapper; + } + + function labelInput( wrapper: VueWrapper ): VueWrapper { + return textInputAt( wrapper, 0 ); + } + + function labelValue( wrapper: VueWrapper ): string { + return ( wrapper.findAll( 'input' )[ 0 ].element as HTMLInputElement ).value; + } + + async function typeLabel( wrapper: VueWrapper, label: string ): Promise { + await labelInput( wrapper ).vm.$emit( 'update:modelValue', label ); + } + + async function save( wrapper: VueWrapper ): Promise { + await wrapper.findComponent( CdxDialog ).vm.$emit( 'primary' ); + } + + function savedSubject(): Subject { + return vi.mocked( useSubjectStore().updateSubject ).mock.calls[ 0 ][ 0 ]; + } + + it( 'loads the subject it was opened for', async () => { + await openForSubject(); + + expect( useSubjectStore().getOrFetchSubject ).toHaveBeenCalledWith( new SubjectId( SUBJECT_ID ) ); + } ); + + it( 'loads the schema of the subject', async () => { + await openForSubject(); + + expect( useSchemaStore().getOrFetchSchema ).toHaveBeenCalledWith( SCHEMA_NAME ); + } ); + + it( 'loads nothing while no subject is set', async () => { + newWrapper(); + state.open = true; + await flushPromises(); + + expect( useSubjectStore().getOrFetchSubject ).not.toHaveBeenCalled(); + } ); + + it( 'prefills the label of the subject', async () => { + const wrapper = await openForSubject(); + + expect( labelValue( wrapper ) ).toBe( 'Acme' ); + } ); + + it( 'offers an editor holding the current statements of the subject', async () => { + const wrapper = await openForSubject(); + + const editor = wrapper.findComponent( SubjectEditor ); + expect( editor.props( 'statements' ) ).toEqual( companySchema.statementsFrom( company.getStatements() ) ); + } ); + + it( 'saves the edited label', async () => { + const wrapper = await openForSubject(); + await typeLabel( wrapper, 'Acme Holdings' ); + + await save( wrapper ); + await flushPromises(); + + expect( savedSubject().getLabel() ).toBe( 'Acme Holdings' ); + } ); + + it( 'saves the edited statements', async () => { + const wrapper = await openForSubject(); + await textInputAt( wrapper, 1 ).vm.$emit( 'update:modelValue', 'Acme Limited' ); + + await save( wrapper ); + await flushPromises(); + + expect( savedSubject().getStatements().get( new PropertyName( 'tradingName' ) ).value ) + .toEqual( newStringValue( 'Acme Limited' ) ); + } ); + + it( 'keeps the identity of the subject it saves', async () => { + const wrapper = await openForSubject(); + + await save( wrapper ); + await flushPromises(); + + expect( savedSubject().getId() ).toEqual( new SubjectId( SUBJECT_ID ) ); + } ); + + it( 'trims the label', async () => { + const wrapper = await openForSubject(); + await typeLabel( wrapper, ' Acme Holdings ' ); + + await save( wrapper ); + await flushPromises(); + + expect( savedSubject().getLabel() ).toBe( 'Acme Holdings' ); + } ); + + it( 'saves nothing when the label is blank', async () => { + const wrapper = await openForSubject(); + await typeLabel( wrapper, ' ' ); + + await save( wrapper ); + await flushPromises(); + + expect( useSubjectStore().updateSubject ).not.toHaveBeenCalled(); + } ); + + it( 'closes after saving', async () => { + const wrapper = await openForSubject(); + + await save( wrapper ); + await flushPromises(); + + expect( state.open ).toBe( false ); + expect( state.subjectId ).toBeNull(); + } ); + + it( 'stays open when saving fails', async () => { + useSubjectStore().updateSubject = vi.fn().mockRejectedValue( new Error( 'Save failed' ) ); + const wrapper = await openForSubject(); + + await save( wrapper ); + await flushPromises(); + + expect( state.open ).toBe( true ); + expect( mw.notify ).toHaveBeenCalledWith( 'Save failed', { type: 'error' } ); + } ); + + it( 'closes and reports when the subject cannot be loaded', async () => { + useSubjectStore().getOrFetchSubject = vi.fn().mockRejectedValue( new Error( 'No such subject' ) ); + + const wrapper = await openForSubject(); + + expect( wrapper.findComponent( SubjectEditor ).exists() ).toBe( false ); + expect( state.open ).toBe( false ); + expect( mw.notify ).toHaveBeenCalledWith( 'No such subject', { type: 'error' } ); + } ); + + it( 'closes when cancelled', async () => { + const wrapper = await openForSubject(); + + await wrapper.findComponent( CdxDialog ).vm.$emit( 'default' ); + + expect( state.open ).toBe( false ); + expect( state.subjectId ).toBeNull(); + } ); + + it( 'shows no previously loaded subject while the next one is loading', async () => { + const wrapper = await openForSubject(); + await wrapper.findComponent( CdxDialog ).vm.$emit( 'default' ); + + useSubjectStore().getOrFetchSubject = vi.fn().mockReturnValue( pendingForever() ); + state.subjectId = 's1demo5sssssss2'; + state.open = true; + await flushPromises(); + + expect( wrapper.findComponent( SubjectEditor ).exists() ).toBe( false ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/RedHerbCard.spec.ts b/resources/ext.neowiki/tests/RedHerb/RedHerbCard.spec.ts new file mode 100644 index 000000000..b6eaec027 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/RedHerbCard.spec.ts @@ -0,0 +1,228 @@ +import './../neowiki-test-env.ts'; +import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { Pinia } from 'pinia'; +import RedHerbCard from '@redherb/RedHerbCard.vue'; +import { Layout } from '@/domain/Layout.ts'; +import { PropertyName, createPropertyDefinitionFromJson } from '@/domain/PropertyDefinition.ts'; +import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; +import { Schema } from '@/domain/Schema.ts'; +import { Statement } from '@/domain/Statement.ts'; +import { StatementList } from '@/domain/StatementList.ts'; +import { Subject } from '@/domain/Subject.ts'; +import { SubjectId } from '@/domain/SubjectId.ts'; +import { newStringValue } from '@/domain/Value.ts'; +import { TextType } from '@/domain/propertyTypes/Text.ts'; +import { useLayoutStore } from '@/stores/LayoutStore.ts'; +import { useSchemaStore } from '@/stores/SchemaStore.ts'; +import { useSubjectStore } from '@/stores/SubjectStore.ts'; +import SubjectEditorDialog from '@/components/SubjectEditor/SubjectEditorDialog.vue'; +import { NeoWikiTestServices } from '../NeoWikiTestServices.ts'; +import { createI18nMock, setupMwMock } from '../VueTestHelpers.ts'; +import { COLOR_TYPE_NAME, loadRedHerbFrontend } from './RedHerbRegistration.ts'; + +const SCHEMA_NAME = 'Herb'; +const LAYOUT_NAME = 'HerbCard'; +const SUBJECT_ID = new SubjectId( 's1demo5sssssss1' ); + +const TERM = '.ext-redherb-card__term'; +const VALUE = '.ext-redherb-card__value'; +const COLUMN = '.ext-redherb-card__grid'; +const WIDE_FIELD = '.ext-redherb-card__wide-field'; + +const schema = new Schema( + SCHEMA_NAME, + 'A herb', + new PropertyDefinitionList( [ + createPropertyDefinitionFromJson( 'commonName', { type: TextType.typeName } ), + createPropertyDefinitionFromJson( 'flowerColor', { type: COLOR_TYPE_NAME } ), + createPropertyDefinitionFromJson( 'notes', { type: TextType.typeName } ), + ] ), +); + +const subject = new Subject( + SUBJECT_ID, + 'Red Clover', + SCHEMA_NAME, + new StatementList( [ + new Statement( new PropertyName( 'commonName' ), TextType.typeName, newStringValue( 'Red Clover' ) ), + new Statement( new PropertyName( 'flowerColor' ), COLOR_TYPE_NAME, newStringValue( '#ff5733' ) ), + new Statement( new PropertyName( 'notes' ), TextType.typeName, newStringValue( 'Grows in meadows' ) ), + ] ), +); + +function newLayout( settings: Record ): Layout { + return new Layout( LAYOUT_NAME, SCHEMA_NAME, 'redherb-card', '', [], settings ); +} + +describe( 'RedHerbCard', () => { + let pinia: Pinia; + + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock( { functions: [ 'message', 'msg', 'config', 'notify', 'util' ] } ); + + pinia = createPinia(); + setActivePinia( pinia ); + + useSchemaStore().setSchema( SCHEMA_NAME, schema ); + useSubjectStore().setSubject( subject ); + } ); + + function newWrapper( canEditSubject = false, layoutName: string | undefined = undefined ): VueWrapper { + return mount( RedHerbCard, { + props: { + subjectId: SUBJECT_ID, + canEditSubject: canEditSubject, + layoutName: layoutName, + }, + global: { + plugins: [ pinia ], + provide: NeoWikiTestServices.getServices(), + directives: { tooltip: {} }, + mocks: { $i18n: createI18nMock() }, + }, + } ); + } + + function termsIn( wrapper: VueWrapper, selector: string ): string[] { + return wrapper.findAll( `${ selector } ${ TERM }` ).map( ( term ) => term.text() ); + } + + it( 'renders the subject label', () => { + const wrapper = newWrapper(); + + expect( wrapper.find( '.ext-redherb-card__label' ).text() ).toBe( 'Red Clover' ); + } ); + + it( 'renders a term for every property with a value', () => { + const wrapper = newWrapper(); + + expect( wrapper.findAll( TERM ).map( ( term ) => term.text() ) ) + .toEqual( [ 'commonName', 'flowerColor', 'notes' ] ); + } ); + + it( 'renders each value with the display component of its property type', () => { + const wrapper = newWrapper(); + + const values = wrapper.findAll( VALUE ); + expect( values[ 0 ].text() ).toBe( 'Red Clover' ); + expect( values[ 1 ].find( '.ext-redherb-color-display__swatch' ).exists() ).toBe( true ); + expect( values[ 1 ].text() ).toBe( '#ff5733' ); + } ); + + it( 'omits properties the subject has no value for', () => { + const withoutNotes = new Subject( + SUBJECT_ID, + 'Red Clover', + SCHEMA_NAME, + new StatementList( [ + new Statement( new PropertyName( 'commonName' ), TextType.typeName, newStringValue( 'Red Clover' ) ), + ] ), + ); + useSubjectStore().setSubject( withoutNotes ); + + const wrapper = newWrapper(); + + expect( wrapper.findAll( TERM ).map( ( term ) => term.text() ) ).toEqual( [ 'commonName' ] ); + } ); + + it( 'spreads the properties over two columns', () => { + const wrapper = newWrapper(); + + const columns = wrapper.findAll( COLUMN ); + expect( columns ).toHaveLength( 2 ); + expect( termsIn( wrapper, COLUMN ) ).toEqual( [ 'commonName', 'flowerColor', 'notes' ] ); + expect( columns[ 0 ].findAll( TERM ) ).toHaveLength( 2 ); + } ); + + it( 'renders the properties the layout marks full width outside the columns', () => { + useLayoutStore().setLayout( LAYOUT_NAME, newLayout( { fullWidthProperties: [ 'notes' ] } ) ); + + const wrapper = newWrapper( false, LAYOUT_NAME ); + + expect( termsIn( wrapper, WIDE_FIELD ) ).toEqual( [ 'notes' ] ); + expect( termsIn( wrapper, COLUMN ) ).toEqual( [ 'commonName', 'flowerColor' ] ); + } ); + + it( 'renders no full-width section when the layout does not ask for one', () => { + useLayoutStore().setLayout( LAYOUT_NAME, newLayout( {} ) ); + + const wrapper = newWrapper( false, LAYOUT_NAME ); + + expect( wrapper.findAll( WIDE_FIELD ) ).toHaveLength( 0 ); + expect( termsIn( wrapper, COLUMN ) ).toEqual( [ 'commonName', 'flowerColor', 'notes' ] ); + } ); + + it( 'ignores a full-width setting that is not a list of property names', () => { + useLayoutStore().setLayout( LAYOUT_NAME, newLayout( { fullWidthProperties: 'notes' } ) ); + + const wrapper = newWrapper( false, LAYOUT_NAME ); + + expect( wrapper.findAll( WIDE_FIELD ) ).toHaveLength( 0 ); + } ); + + it( 'links to the schema page of the subject', () => { + const wrapper = newWrapper(); + + expect( wrapper.find( `a[href="/wiki/Schema:${ SCHEMA_NAME }"]` ).exists() ).toBe( true ); + } ); + + it( 'links to the layout page when rendered through a layout', () => { + useLayoutStore().setLayout( LAYOUT_NAME, newLayout( {} ) ); + + const wrapper = newWrapper( false, LAYOUT_NAME ); + + expect( wrapper.find( `a[href="/wiki/Layout:${ LAYOUT_NAME }"]` ).exists() ).toBe( true ); + } ); + + it( 'does not link to a layout page when rendered without a layout', () => { + const wrapper = newWrapper(); + + expect( wrapper.find( 'a[href^="/wiki/Layout:"]' ).exists() ).toBe( false ); + } ); + + it( 'offers no editor to a user who may not edit the subject', () => { + const wrapper = newWrapper( false ); + + expect( wrapper.findComponent( SubjectEditorDialog ).exists() ).toBe( false ); + } ); + + it( 'keeps the editor closed until the edit button is used', () => { + const wrapper = newWrapper( true ); + + expect( wrapper.findComponent( SubjectEditorDialog ).props( 'open' ) ).toBe( false ); + } ); + + it( 'refetches the subject and its schema before opening the editor', async () => { + const subjectStore = useSubjectStore(); + const schemaStore = useSchemaStore(); + subjectStore.fetchSubject = vi.fn().mockResolvedValue( undefined ); + schemaStore.fetchSchema = vi.fn().mockResolvedValue( undefined ); + + const wrapper = newWrapper( true ); + await wrapper.find( '.ext-redherb-card__actions button' ).trigger( 'click' ); + await flushPromises(); + + expect( subjectStore.fetchSubject ).toHaveBeenCalledWith( SUBJECT_ID ); + expect( schemaStore.fetchSchema ).toHaveBeenCalledWith( SCHEMA_NAME ); + expect( wrapper.findComponent( SubjectEditorDialog ).props( 'open' ) ).toBe( true ); + } ); + + it( 'reports a failure to load the latest data instead of opening the editor', async () => { + const subjectStore = useSubjectStore(); + subjectStore.fetchSubject = vi.fn().mockRejectedValue( new Error( 'Network is down' ) ); + useSchemaStore().fetchSchema = vi.fn().mockResolvedValue( undefined ); + + const wrapper = newWrapper( true ); + await wrapper.find( '.ext-redherb-card__actions button' ).trigger( 'click' ); + await flushPromises(); + + expect( mw.notify ).toHaveBeenCalledWith( 'Network is down', { type: 'error' } ); + expect( wrapper.findComponent( SubjectEditorDialog ).props( 'open' ) ).toBe( false ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/RedHerbRegistration.ts b/resources/ext.neowiki/tests/RedHerb/RedHerbRegistration.ts new file mode 100644 index 000000000..cedee718f --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/RedHerbRegistration.ts @@ -0,0 +1,70 @@ +import { FrontendRegistrar } from '@/presentation/FrontendRegistrar.ts'; +import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; +import { createPropertyDefinitionFromJson, PropertyDefinition } from '@/domain/PropertyDefinition.ts'; + +export const COLOR_TYPE_NAME = 'color'; +export const CARD_VIEW_TYPE_NAME = 'redherb-card'; + +/** + * Runs RedHerb's registration package file the way ResourceLoader and NeoWiki do: the module + * subscribes to neowiki.registration on load, and NeoWiki fires it with a registrar wired to + * the live registries. Specs that mount RedHerb components need this, because the components + * look their own property type up in the registry. + * + * mw.hook replays its last fire to handlers added afterwards, so the order of the two halves + * does not matter here any more than it does in production. + */ +export async function loadRedHerbFrontend(): Promise { + stubMwHook(); + + await import( '@redherb/init.js' ); + + const extension = NeoWikiExtension.getInstance(); + mw.hook( 'neowiki.registration' ).fire( + new FrontendRegistrar( + extension.getTypeSpecificComponentRegistry(), + extension.getPropertyTypeRegistry(), + extension.getViewTypeRegistry(), + ), + ); +} + +/** + * Builds a color PropertyDefinition through the deserializer, so it passes through the + * createPropertyDefinitionFromJson that RedHerb registered. + */ +export function newColorProperty( json: Record = {} ): PropertyDefinition { + return createPropertyDefinitionFromJson( + ( json.name as string ) ?? 'favouriteColor', + { type: COLOR_TYPE_NAME, ...json }, + ); +} + +interface HookState { + firedArguments: unknown[] | null; + handlers: ( ( ...args: unknown[] ) => void )[]; +} + +function stubMwHook(): void { + const hooks: Record = {}; + + ( globalThis as any ).mw = { + hook: ( name: string ) => { + hooks[ name ] ??= { firedArguments: null, handlers: [] }; + const hook = hooks[ name ]; + + return { + add: ( handler: ( ...args: unknown[] ) => void ): void => { + hook.handlers.push( handler ); + if ( hook.firedArguments !== null ) { + handler( ...hook.firedArguments ); + } + }, + fire: ( ...args: unknown[] ): void => { + hook.firedArguments = args; + hook.handlers.forEach( ( handler ) => handler( ...args ) ); + }, + }; + }, + }; +} diff --git a/resources/ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts b/resources/ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts new file mode 100644 index 000000000..557fb9826 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts @@ -0,0 +1,206 @@ +import './../neowiki-test-env.ts'; +import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { Pinia } from 'pinia'; +import { CdxTextInput } from '@wikimedia/codex'; +import SubjectFinderPanel from '@redherb/subjectFinder/SubjectFinderPanel.vue'; +import Infobox from '@/components/Views/Infobox.vue'; +import SubjectLookup from '@/components/common/SubjectLookup.vue'; +import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; +import { createPropertyDefinitionFromJson, PropertyName } from '@/domain/PropertyDefinition.ts'; +import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; +import { Schema } from '@/domain/Schema.ts'; +import { Statement } from '@/domain/Statement.ts'; +import { StatementList } from '@/domain/StatementList.ts'; +import { Subject } from '@/domain/Subject.ts'; +import { SubjectId } from '@/domain/SubjectId.ts'; +import { newStringValue } from '@/domain/Value.ts'; +import { TextType } from '@/domain/propertyTypes/Text.ts'; +import { useSchemaStore } from '@/stores/SchemaStore.ts'; +import { useSubjectStore } from '@/stores/SubjectStore.ts'; +import { NeoWikiTestServices } from '../NeoWikiTestServices.ts'; +import { createI18nMock, setupMwMock } from '../VueTestHelpers.ts'; +import { loadRedHerbFrontend } from './RedHerbRegistration.ts'; + +const SCHEMA_NAME = 'Company'; +const SUBJECT_ID = 's1demo5sssssss1'; + +const companySchema = new Schema( + SCHEMA_NAME, + 'A company', + new PropertyDefinitionList( [ + createPropertyDefinitionFromJson( 'tradingName', { type: TextType.typeName } ), + ] ), +); + +const company = new Subject( + new SubjectId( SUBJECT_ID ), + 'Acme', + SCHEMA_NAME, + new StatementList( [ + new Statement( new PropertyName( 'tradingName' ), TextType.typeName, newStringValue( 'Acme Corporation' ) ), + ] ), +); + +function pendingForever(): Promise { + return new Promise( () => { + // Never settles, so the caller stays in its loading state. + } ); +} + +describe( 'SubjectFinderPanel', () => { + let pinia: Pinia; + let loadSubjectsAndSchemas: ReturnType; + + beforeAll( async () => { + await loadRedHerbFrontend(); + } ); + + beforeEach( () => { + setupMwMock( { functions: [ 'message', 'msg', 'config', 'notify', 'util' ] } ); + ( globalThis as any ).mw.log = { error: vi.fn() }; + + pinia = createPinia(); + setActivePinia( pinia ); + + useSchemaStore().setSchema( SCHEMA_NAME, companySchema ); + useSubjectStore().setSubject( company ); + + loadSubjectsAndSchemas = vi.fn().mockResolvedValue( undefined ); + vi.spyOn( NeoWikiExtension.getInstance(), 'getStoreStateLoader' ) + .mockReturnValue( { loadSubjectsAndSchemas: loadSubjectsAndSchemas } as any ); + } ); + + function newWrapper(): VueWrapper { + return mount( SubjectFinderPanel, { + global: { + plugins: [ pinia ], + provide: NeoWikiTestServices.getServices(), + directives: { tooltip: {} }, + mocks: { $i18n: createI18nMock() }, + stubs: { teleport: true }, + }, + } ); + } + + async function typeSchemaName( wrapper: VueWrapper, name: string ): Promise { + await ( wrapper.findAllComponents( CdxTextInput )[ 0 ] as unknown as VueWrapper ).vm.$emit( 'update:modelValue', name ); + } + + async function selectSubject( wrapper: VueWrapper, subjectId: string | null ): Promise { + await wrapper.findComponent( SubjectLookup ).vm.$emit( 'update:selected', subjectId ); + } + + it( 'offers no subject lookup before a schema is named', () => { + const wrapper = newWrapper(); + + expect( wrapper.findComponent( SubjectLookup ).exists() ).toBe( false ); + } ); + + it( 'offers a subject lookup once a schema is named', async () => { + const wrapper = newWrapper(); + + await typeSchemaName( wrapper, SCHEMA_NAME ); + + expect( wrapper.findComponent( SubjectLookup ).exists() ).toBe( true ); + } ); + + it( 'limits the lookup to subjects of the named schema', async () => { + const wrapper = newWrapper(); + + await typeSchemaName( wrapper, SCHEMA_NAME ); + + expect( wrapper.findComponent( SubjectLookup ).props( 'targetSchema' ) ).toBe( SCHEMA_NAME ); + } ); + + it( 'ignores surrounding whitespace in the schema name', async () => { + const wrapper = newWrapper(); + + await typeSchemaName( wrapper, ` ${ SCHEMA_NAME } ` ); + + expect( wrapper.findComponent( SubjectLookup ).props( 'targetSchema' ) ).toBe( SCHEMA_NAME ); + } ); + + it( 'offers no subject lookup for a schema name of only whitespace', async () => { + const wrapper = newWrapper(); + + await typeSchemaName( wrapper, ' ' ); + + expect( wrapper.findComponent( SubjectLookup ).exists() ).toBe( false ); + } ); + + it( 'renders nothing until a subject is selected', async () => { + const wrapper = newWrapper(); + + await typeSchemaName( wrapper, SCHEMA_NAME ); + + expect( wrapper.findComponent( Infobox ).exists() ).toBe( false ); + } ); + + it( 'loads the data of the selected subject', async () => { + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + expect( loadSubjectsAndSchemas ).toHaveBeenCalledWith( new Set( [ SUBJECT_ID ] ) ); + } ); + + it( 'renders the selected subject once its data is loaded', async () => { + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + expect( wrapper.findComponent( Infobox ).exists() ).toBe( true ); + expect( wrapper.find( '.ext-redherb-subject-finder__rendered' ).text() ).toContain( 'Acme' ); + } ); + + it( 'renders the subject as read-only', async () => { + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + expect( wrapper.findComponent( Infobox ).props( 'canEditSubject' ) ).toBe( false ); + } ); + + it( 'waits for the data before rendering the subject', async () => { + loadSubjectsAndSchemas.mockReturnValue( pendingForever() ); + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + expect( wrapper.findComponent( Infobox ).exists() ).toBe( false ); + } ); + + it( 'stops rendering a subject when the selection is cleared', async () => { + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + await selectSubject( wrapper, null ); + await flushPromises(); + + expect( wrapper.findComponent( Infobox ).exists() ).toBe( false ); + } ); + + it( 'reports a failure to load the subject instead of rendering it', async () => { + loadSubjectsAndSchemas.mockRejectedValue( new Error( 'Network is down' ) ); + const wrapper = newWrapper(); + await typeSchemaName( wrapper, SCHEMA_NAME ); + + await selectSubject( wrapper, SUBJECT_ID ); + await flushPromises(); + + expect( wrapper.findComponent( Infobox ).exists() ).toBe( false ); + expect( mw.notify ).toHaveBeenCalledWith( 'Network is down', { type: 'error' } ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/redherb-modules.d.ts b/resources/ext.neowiki/tests/RedHerb/redherb-modules.d.ts new file mode 100644 index 000000000..5308865e6 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/redherb-modules.d.ts @@ -0,0 +1,18 @@ +/** + * RedHerb's package files are plain JavaScript, so they carry no types of their own. + * Declaring them here keeps the specs importing them by their @redherb alias without + * dragging their ResourceLoader-flavoured CommonJS through vue-tsc. + * + * Each package file is reachable as a default export, because that is what a CommonJS + * module.exports becomes once resourceLoaderCommonJs has rewritten it. + */ +declare module '@redherb/*.vue' { + import type { Component } from 'vue'; + const component: Component; + export default component; +} + +declare module '@redherb/*.js' { + const moduleExports: any; + export default moduleExports; +} diff --git a/resources/ext.neowiki/tests/RedHerb/registration.spec.ts b/resources/ext.neowiki/tests/RedHerb/registration.spec.ts new file mode 100644 index 000000000..3c49d0fd8 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/registration.spec.ts @@ -0,0 +1,86 @@ +import './../neowiki-test-env.ts'; +import { mount } from '@vue/test-utils'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; +import { PropertyType } from '@/domain/PropertyType.ts'; +import { newStringValue, ValueType } from '@/domain/Value.ts'; +import { TypeSpecificComponentRegistry } from '@/TypeSpecificComponentRegistry.ts'; +import { ViewTypeRegistry } from '@/ViewTypeRegistry.ts'; +import { setupMwMock } from '../VueTestHelpers.ts'; +import { CARD_VIEW_TYPE_NAME, COLOR_TYPE_NAME, loadRedHerbFrontend, newColorProperty } from './RedHerbRegistration.ts'; + +describe( 'RedHerb registration', () => { + let colorType: PropertyType; + let componentRegistry: TypeSpecificComponentRegistry; + let viewTypeRegistry: ViewTypeRegistry; + + beforeAll( async () => { + await loadRedHerbFrontend(); + + const extension = NeoWikiExtension.getInstance(); + colorType = extension.getPropertyTypeRegistry().getType( COLOR_TYPE_NAME ); + componentRegistry = extension.getTypeSpecificComponentRegistry(); + viewTypeRegistry = extension.getViewTypeRegistry(); + } ); + + beforeEach( () => { + setupMwMock(); + } ); + + it( 'registers the color property type as string-valued', () => { + expect( colorType.getValueType() ).toBe( ValueType.String ); + } ); + + it( 'registers a display component that renders a color', () => { + const wrapper = mount( componentRegistry.getValueDisplayComponent( COLOR_TYPE_NAME ), { + props: { + value: newStringValue( '#ff5733' ), + property: newColorProperty(), + }, + } ); + + expect( wrapper.text() ).toContain( '#ff5733' ); + } ); + + it( 'registers an input component for the color type', () => { + expect( componentRegistry.getValueEditingComponent( COLOR_TYPE_NAME ) ).toBeDefined(); + } ); + + it( 'registers an attributes editor for the color type', () => { + expect( componentRegistry.getAttributesEditor( COLOR_TYPE_NAME ) ).toBeDefined(); + } ); + + it( 'registers a label and icon for the color type', () => { + expect( componentRegistry.getLabel( COLOR_TYPE_NAME ) ).toBe( 'redherb-property-type-color' ); + expect( componentRegistry.getIcon( COLOR_TYPE_NAME ) ).toBeDefined(); + } ); + + it( 'registers the card view type', () => { + expect( viewTypeRegistry.hasType( CARD_VIEW_TYPE_NAME ) ).toBe( true ); + } ); + + it( 'offers an example value the display component renders as a color', () => { + const example = colorType.getExampleValue( newColorProperty() ); + + expect( example ).toEqual( newStringValue( '#ff5733' ) ); + } ); + + it( 'keeps the palette from the property JSON', () => { + const property = newColorProperty( { allowedColors: [ '#ff5733', '#000000' ] } ); + + expect( property ).toHaveProperty( 'allowedColors', [ '#ff5733', '#000000' ] ); + } ); + + it( 'falls back to an empty palette when the property JSON has no usable one', () => { + const property = newColorProperty( { allowedColors: 'not a list' } ); + + expect( property ).toHaveProperty( 'allowedColors', [] ); + } ); + + it( 'keeps the shared property attributes alongside the palette', () => { + const property = newColorProperty( { description: 'Pick a color', required: true } ); + + expect( property.description ).toBe( 'Pick a color' ); + expect( property.required ).toBe( true ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts b/resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts new file mode 100644 index 000000000..6d97d34e6 --- /dev/null +++ b/resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts @@ -0,0 +1,114 @@ +import type { Plugin } from 'vite'; + +export interface ResourceLoaderCommonJsOptions { + /** Directory holding the ResourceLoader package files to transform. */ + sourceDir: string; + /** + * What ResourceLoader hands those package files, keyed by the module name they require. + * Module names absent from this map are left alone and resolve as siblings in sourceDir. + */ + providedModules: Record; + /** + * File to resolve the provided modules as if it had imported them. Resolving from + * sourceDir instead would miss ext.neowiki's node_modules, since RedHerb's package files + * live outside it and have no dependencies of their own. It also keeps the package files + * on the vue, Codex and ext.neowiki instances the rest of the suite uses. + */ + resolveFrom: string; +} + +/** + * Lets Vite load ResourceLoader package files, so the RedHerb example extension can be + * tested with the same toolchain as ext.neowiki itself. + * + * ResourceLoader wraps each package file in a CommonJS closure, so RedHerb's modules pull + * their dependencies in with `require()` and expose themselves through `module.exports`. + * Vite only understands ES modules, so this rewrites those two constructs. + * + * A namespace whose only export is `default` is a rewritten package file, and gets unwrapped + * to whatever it assigned to `module.exports`. The modules ResourceLoader provides have no + * default export, so they stay namespaces. + */ +export function resourceLoaderCommonJs( options: ResourceLoaderCommonJsOptions ): Plugin { + return { + name: 'resource-loader-commonjs', + enforce: 'pre', + async transform( code: string, id: string ): Promise<{ code: string; map: null } | undefined > { + const path = id.split( '?' )[ 0 ]; + if ( !path.startsWith( options.sourceDir ) ) { + return undefined; + } + + const resolveModule = async ( name: string ): Promise => { + const target = options.providedModules[ name ]; + if ( target === undefined ) { + return name; + } + const resolved = await this.resolve( target, options.resolveFrom ); + if ( resolved === null ) { + throw new Error( `Cannot resolve ${ target }, provided to ResourceLoader package files as ${ name }` ); + } + return resolved.id; + }; + + if ( path.endsWith( '.vue' ) ) { + return { code: await transformScriptBlock( code, resolveModule ), map: null }; + } + + if ( path.endsWith( '.js' ) ) { + return { code: await toEsModule( code, resolveModule ), map: null }; + } + + return undefined; + }, + }; +} + +type ModuleResolver = ( name: string ) => Promise; + +const SCRIPT_BLOCK = /(]*>)([\s\S]*?)(<\/script>)/; + +async function transformScriptBlock( sfc: string, resolveModule: ModuleResolver ): Promise { + const match = SCRIPT_BLOCK.exec( sfc ); + if ( match === null ) { + return sfc; + } + + const [ block, open, script, close ] = match; + + return sfc.replace( block, open + await toEsModule( script, resolveModule ) + close ); +} + +const REQUIRE_CALL = /require\(\s*(['"])(.+?)\1\s*\)/g; + +async function toEsModule( code: string, resolveModule: ModuleResolver ): Promise { + const moduleNames: string[] = []; + + const body = code.replace( REQUIRE_CALL, ( _match, _quote: string, name: string ) => { + if ( !moduleNames.includes( name ) ) { + moduleNames.push( name ); + } + return interopName( moduleNames.indexOf( name ) ); + } ); + + const imports = await Promise.all( moduleNames.map( async ( name, index ) => + `import * as ${ namespaceName( index ) } from '${ await resolveModule( name ) }';\n` + + `const ${ interopName( index ) } = ${ namespaceName( index ) }.default ?? ${ namespaceName( index ) };`, + ) ); + + return [ + ...imports, + 'const module = { exports: {} };', + 'let exports = module.exports;', + body, + 'export default module.exports;', + ].join( '\n' ); +} + +function namespaceName( index: number ): string { + return `__rlNamespace${ index }`; +} + +function interopName( index: number ): string { + return `__rlModule${ index }`; +} diff --git a/resources/ext.neowiki/vitest.config.ts b/resources/ext.neowiki/vitest.config.ts index 0eebd637a..cca448c05 100644 --- a/resources/ext.neowiki/vitest.config.ts +++ b/resources/ext.neowiki/vitest.config.ts @@ -1,7 +1,30 @@ import { defineConfig, mergeConfig } from 'vitest/config'; +import { fileURLToPath, URL } from 'node:url'; import viteConfig from './vite.config'; +import { resourceLoaderCommonJs } from './tests/RedHerb/resourceLoaderCommonJs'; + +const redHerbResources = fileURLToPath( new URL( '../../tests/RedHerb/resources', import.meta.url ) ); +const publicApi = fileURLToPath( new URL( './src/public-api.ts', import.meta.url ) ); export default mergeConfig( viteConfig, defineConfig( { + plugins: [ + resourceLoaderCommonJs( { + sourceDir: redHerbResources, + resolveFrom: publicApi, + providedModules: { + vue: 'vue', + 'ext.neowiki': publicApi, + // Generated by CodexModule, so they exist only at runtime. + './codex.js': '@wikimedia/codex', + './icons.json': '@wikimedia/codex-icons', + }, + } ), + ], + resolve: { + alias: { + '@redherb': redHerbResources, + }, + }, test: { environment: 'jsdom', globals: true, From b23015f2db1e63fd6e358a8a793e988f027a48c6 Mon Sep 17 00:00:00 2001 From: Morne Alberts Date: Thu, 30 Jul 2026 10:27:31 +0200 Subject: [PATCH 2/3] Cover dismissing the RedHerb dialogs Closing a dialog through Escape or its close button goes through a different handler than its cancel action, and has to clear the shared state the mount point holds rather than only hide the dialog. Assert the optional state of the color field through CdxField rather than the Codex class name that renders it, and stop pinning the whole style attribute of a swatch when only the color it paints matters. Co-Authored-By: Claude Opus 5 (1M context) --- .../ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts | 2 +- resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts | 10 +++++----- .../tests/RedHerb/CreateChildDialog.spec.ts | 8 ++++++++ .../tests/RedHerb/EditMainSubjectDialog.spec.ts | 9 +++++++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts b/resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts index bbe8e5ddf..4927bb353 100644 --- a/resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts +++ b/resources/ext.neowiki/tests/RedHerb/ColorDisplay.spec.ts @@ -40,7 +40,7 @@ describe( 'ColorDisplay', () => { it( 'paints the swatch with the color', () => { const wrapper = newWrapper( newStringValue( '#ff5733' ) ); - expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toBe( 'background-color: rgb(255, 87, 51);' ); + expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toContain( 'background-color: rgb(255, 87, 51)' ); } ); it( 'accepts uppercase hex digits', () => { diff --git a/resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts b/resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts index 04d0a3bdb..812085afb 100644 --- a/resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts +++ b/resources/ext.neowiki/tests/RedHerb/ColorInput.spec.ts @@ -1,7 +1,7 @@ import './../neowiki-test-env.ts'; import { VueWrapper } from '@vue/test-utils'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { CdxTextInput } from '@wikimedia/codex'; +import { CdxField, CdxTextInput } from '@wikimedia/codex'; import ColorInput from '@redherb/ColorInput.vue'; import { newStringValue, StringValue, Value } from '@/domain/Value.ts'; import { PropertyDefinition } from '@/domain/PropertyDefinition.ts'; @@ -60,7 +60,7 @@ describe( 'ColorInput', () => { it( 'previews the current color in the swatch', () => { const wrapper = newWrapper( newStringValue( '#ff5733' ) ); - expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toBe( 'background-color: rgb(255, 87, 51);' ); + expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toContain( 'background-color: rgb(255, 87, 51)' ); } ); it( 'marks the swatch as empty when there is no color yet', () => { @@ -82,7 +82,7 @@ describe( 'ColorInput', () => { await typeColor( wrapper, '#ff5733' ); - expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toBe( 'background-color: rgb(255, 87, 51);' ); + expect( wrapper.find( SWATCH ).attributes( 'style' ) ).toContain( 'background-color: rgb(255, 87, 51)' ); } ); it( 'emits the typed color as a string value', async () => { @@ -121,12 +121,12 @@ describe( 'ColorInput', () => { it( 'marks the field optional when the property is not required', () => { const wrapper = newWrapper( undefined, newColorProperty( { required: false } ) ); - expect( wrapper.find( '.cdx-label__label__optional-flag' ).exists() ).toBe( true ); + expect( wrapper.findComponent( CdxField ).props( 'optional' ) ).toBe( true ); } ); it( 'does not mark the field optional when the property is required', () => { const wrapper = newWrapper( undefined, newColorProperty( { required: true } ) ); - expect( wrapper.find( '.cdx-label__label__optional-flag' ).exists() ).toBe( false ); + expect( wrapper.findComponent( CdxField ).props( 'optional' ) ).toBe( false ); } ); } ); diff --git a/resources/ext.neowiki/tests/RedHerb/CreateChildDialog.spec.ts b/resources/ext.neowiki/tests/RedHerb/CreateChildDialog.spec.ts index b562d47ac..3bc7c933e 100644 --- a/resources/ext.neowiki/tests/RedHerb/CreateChildDialog.spec.ts +++ b/resources/ext.neowiki/tests/RedHerb/CreateChildDialog.spec.ts @@ -221,6 +221,14 @@ describe( 'CreateChildDialog', () => { expect( open.value ).toBe( false ); } ); + it( 'closes when dismissed', async () => { + const wrapper = await openDialog(); + + await wrapper.findComponent( CdxDialog ).vm.$emit( 'update:open', false ); + + expect( open.value ).toBe( false ); + } ); + it( 'forgets the previous label when reopened', async () => { const wrapper = await openDialog(); await typeLabel( wrapper, 'Acme' ); diff --git a/resources/ext.neowiki/tests/RedHerb/EditMainSubjectDialog.spec.ts b/resources/ext.neowiki/tests/RedHerb/EditMainSubjectDialog.spec.ts index fe06288f9..29070a872 100644 --- a/resources/ext.neowiki/tests/RedHerb/EditMainSubjectDialog.spec.ts +++ b/resources/ext.neowiki/tests/RedHerb/EditMainSubjectDialog.spec.ts @@ -248,6 +248,15 @@ describe( 'EditMainSubjectDialog', () => { expect( state.subjectId ).toBeNull(); } ); + it( 'closes when dismissed', async () => { + const wrapper = await openForSubject(); + + await wrapper.findComponent( CdxDialog ).vm.$emit( 'update:open', false ); + + expect( state.open ).toBe( false ); + expect( state.subjectId ).toBeNull(); + } ); + it( 'shows no previously loaded subject while the next one is loading', async () => { const wrapper = await openForSubject(); await wrapper.findComponent( CdxDialog ).vm.$emit( 'default' ); From 049c347adc7ee89d33e7691627b0980b73fd2708 Mon Sep 17 00:00:00 2001 From: Morne Alberts Date: Thu, 30 Jul 2026 10:34:46 +0200 Subject: [PATCH 3/3] Splice the rewritten script into a package file Building the transformed single-file component with String.replace gave $& and its siblings their substitution meaning, so a script containing one would have been rewritten into something that no longer parses. Narrow the directory check to the contents of the source directory rather than any path starting with its name. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/RedHerb/ColorAttributesEditor.spec.ts | 7 +++++++ .../ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts | 4 +++- .../ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts | 9 ++++++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts b/resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts index e1328e5a7..07e0ff3c0 100644 --- a/resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts +++ b/resources/ext.neowiki/tests/RedHerb/ColorAttributesEditor.spec.ts @@ -83,6 +83,13 @@ describe( 'ColorAttributesEditor', () => { await addButton( wrapper ).trigger( 'click' ); expect( colorInputs( wrapper ) ).toEqual( [ '#ff5733', '' ] ); + } ); + + it( 'emits the palette with the added entry', async () => { + const wrapper = newWrapper( [ '#ff5733' ] ); + + await addButton( wrapper ).trigger( 'click' ); + expect( lastEmittedPalette( wrapper ) ).toEqual( [ '#ff5733', '' ] ); } ); diff --git a/resources/ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts b/resources/ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts index 557fb9826..dc7e3595e 100644 --- a/resources/ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts +++ b/resources/ext.neowiki/tests/RedHerb/SubjectFinderPanel.spec.ts @@ -85,7 +85,9 @@ describe( 'SubjectFinderPanel', () => { } async function typeSchemaName( wrapper: VueWrapper, name: string ): Promise { - await ( wrapper.findAllComponents( CdxTextInput )[ 0 ] as unknown as VueWrapper ).vm.$emit( 'update:modelValue', name ); + const schemaInput = wrapper.findComponent( CdxTextInput ); + + await schemaInput.vm.$emit( 'update:modelValue', name ); } async function selectSubject( wrapper: VueWrapper, subjectId: string | null ): Promise { diff --git a/resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts b/resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts index 6d97d34e6..ce9cc1512 100644 --- a/resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts +++ b/resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts @@ -33,9 +33,9 @@ export function resourceLoaderCommonJs( options: ResourceLoaderCommonJsOptions ) return { name: 'resource-loader-commonjs', enforce: 'pre', - async transform( code: string, id: string ): Promise<{ code: string; map: null } | undefined > { + async transform( code: string, id: string ): Promise<{ code: string; map: null } | undefined> { const path = id.split( '?' )[ 0 ]; - if ( !path.startsWith( options.sourceDir ) ) { + if ( !path.startsWith( options.sourceDir + '/' ) ) { return undefined; } @@ -75,8 +75,11 @@ async function transformScriptBlock( sfc: string, resolveModule: ModuleResolver } const [ block, open, script, close ] = match; + const rewritten = open + await toEsModule( script, resolveModule ) + close; - return sfc.replace( block, open + await toEsModule( script, resolveModule ) + close ); + // Spliced rather than replaced, because a replacement string gives $& and friends + // their substitution meaning, which would mangle any script containing them. + return sfc.slice( 0, match.index ) + rewritten + sfc.slice( match.index + block.length ); } const REQUIRE_CALL = /require\(\s*(['"])(.+?)\1\s*\)/g;