Compare commits

...
Author SHA1 Message Date
Niels Lyngsø 076fbec89f unit tests for extension registry 2026-05-08 14:34:20 +02:00
7 changed files with 731 additions and 0 deletions
@@ -0,0 +1,84 @@
import { UmbConditionBase } from './condition-base.controller.js';
import { expect, fixture } from '@open-wc/testing';
import { UmbControllerHostElementMixin, type UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import type { UmbConditionConfigBase } from '@umbraco-cms/backoffice/extension-api';
@customElement('umb-test-condition-base-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestConditionBaseHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
class UmbTestCondition extends UmbConditionBase<UmbConditionConfigBase> {}
describe('UmbConditionBase', () => {
let host: UmbControllerHostElement;
const config: UmbConditionConfigBase = { alias: 'Umb.Test.Condition' };
beforeEach(async () => {
host = await fixture(html`<umb-test-condition-base-host></umb-test-condition-base-host>`);
});
it('exposes the config it was constructed with', () => {
const condition = new UmbTestCondition(host, { config, onChange: () => {} });
expect(condition.config).to.equal(config);
});
it('initializes with permitted=false', () => {
const condition = new UmbTestCondition(host, { config, onChange: () => {} });
expect(condition.permitted).to.be.false;
});
it('invokes onChange when permitted transitions to a new value', () => {
let calls: Array<boolean> = [];
const condition = new UmbTestCondition(host, {
config,
onChange: (permitted) => calls.push(permitted),
});
condition.permitted = true;
expect(calls).to.eql([true]);
condition.permitted = false;
expect(calls).to.eql([true, false]);
});
it('does NOT invoke onChange when permitted is set to the current value', () => {
let callCount = 0;
const condition = new UmbTestCondition(host, {
config,
onChange: () => callCount++,
});
// Same as initial value (false) — should not fire.
condition.permitted = false;
expect(callCount).to.equal(0);
// Real transition — should fire once.
condition.permitted = true;
expect(callCount).to.equal(1);
// Same value again — should not fire.
condition.permitted = true;
expect(callCount).to.equal(1);
});
it('does not invoke onChange after destroy()', () => {
let callCount = 0;
const condition = new UmbTestCondition(host, {
config,
onChange: () => callCount++,
});
condition.destroy();
// After destroy the internal onChange reference is cleared, so further
// permitted writes must not trigger any callback.
condition.permitted = true;
expect(callCount).to.equal(0);
});
it('clears its config reference on destroy', () => {
const condition = new UmbTestCondition(host, { config, onChange: () => {} });
condition.destroy();
expect(condition.config).to.be.undefined;
});
});
@@ -0,0 +1,79 @@
import { UmbDelayCondition, type DelayConditionConfig } from './delay.condition.js';
import { aTimeout, expect, fixture } from '@open-wc/testing';
import { UmbControllerHostElementMixin, type UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
@customElement('umb-test-delay-condition-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestDelayConditionHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
const baseConfig = (offset: string): DelayConditionConfig => ({
alias: 'Umb.Condition.Delay',
offset,
});
describe('UmbDelayCondition', () => {
let host: UmbControllerHostElement;
beforeEach(async () => {
host = await fixture(html`<umb-test-delay-condition-host></umb-test-delay-condition-host>`);
});
it('starts not permitted', () => {
const condition = new UmbDelayCondition(host, {
config: baseConfig('30'),
onChange: () => {},
});
expect(condition.permitted).to.be.false;
condition.destroy();
});
it('becomes permitted after the configured offset has elapsed', async () => {
const offsetMs = 30;
const transitions: Array<boolean> = [];
const condition = new UmbDelayCondition(host, {
config: baseConfig(String(offsetMs)),
onChange: (permitted) => transitions.push(permitted),
});
// Not yet — well before the timer fires.
await aTimeout(5);
expect(condition.permitted).to.be.false;
expect(transitions).to.eql([]);
// Wait long enough for the timer (with a generous margin to keep the test stable).
await aTimeout(offsetMs + 50);
expect(condition.permitted).to.be.true;
expect(transitions).to.eql([true]);
condition.destroy();
});
it('does not fire onChange after destroy() when destroyed before the timer elapses', async () => {
let callCount = 0;
const condition = new UmbDelayCondition(host, {
config: baseConfig('30'),
onChange: () => callCount++,
});
condition.destroy();
// Wait past the original offset — no callback should run.
await aTimeout(60);
expect(callCount).to.equal(0);
});
it('throws when offset is not a positive number', () => {
expect(
() => new UmbDelayCondition(host, { config: baseConfig('0'), onChange: () => {} }),
).to.throw(/Offset must be a positive number/);
expect(
() => new UmbDelayCondition(host, { config: baseConfig('-5'), onChange: () => {} }),
).to.throw(/Offset must be a positive number/);
expect(
() => new UmbDelayCondition(host, { config: baseConfig('not-a-number'), onChange: () => {} }),
).to.throw(/Offset must be a positive number/);
});
});
@@ -0,0 +1,85 @@
import { UmbSwitchCondition, type SwitchConditionConfig } from './switch.condition.js';
import { aTimeout, expect, fixture } from '@open-wc/testing';
import { UmbControllerHostElementMixin, type UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
@customElement('umb-test-switch-condition-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestSwitchConditionHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
const baseConfig = (frequency: string): SwitchConditionConfig => ({
alias: 'Umb.Condition.Switch',
frequency,
});
describe('UmbSwitchCondition', () => {
let host: UmbControllerHostElement;
beforeEach(async () => {
host = await fixture(html`<umb-test-switch-condition-host></umb-test-switch-condition-host>`);
});
it('starts not permitted', () => {
const condition = new UmbSwitchCondition(host, {
config: baseConfig('30'),
onChange: () => {},
});
expect(condition.permitted).to.be.false;
condition.destroy();
});
it('flips between permitted=true and permitted=false at the configured frequency', async () => {
// Wait for two transitions (false→true→false) and assert the sequence.
// We listen to onChange instead of polling `permitted` at fixed times,
// since cumulative wait drift would otherwise make assertions racy.
const frequencyMs = 30;
const transitions: Array<boolean> = [];
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Switch condition did not flip twice in time')), 1000);
const condition = new UmbSwitchCondition(host, {
config: baseConfig(String(frequencyMs)),
onChange: (permitted) => {
transitions.push(permitted);
if (transitions.length === 2) {
clearTimeout(timeout);
condition.destroy();
resolve();
}
},
});
});
expect(transitions).to.eql([true, false]);
});
it('does not flip after destroy()', async () => {
const frequencyMs = 30;
let callCount = 0;
const condition = new UmbSwitchCondition(host, {
config: baseConfig(String(frequencyMs)),
onChange: () => callCount++,
});
// Destroy before the first transition fires.
condition.destroy();
await aTimeout(frequencyMs * 3 + 50);
expect(callCount).to.equal(0);
});
it('throws when frequency is not a positive number', () => {
expect(
() => new UmbSwitchCondition(host, { config: baseConfig('0'), onChange: () => {} }),
).to.throw(/Frequency must be a positive number/);
expect(
() => new UmbSwitchCondition(host, { config: baseConfig('-5'), onChange: () => {} }),
).to.throw(/Frequency must be a positive number/);
expect(
() => new UmbSwitchCondition(host, { config: baseConfig('NaN-string'), onChange: () => {} }),
).to.throw(/Frequency must be a positive number/);
});
});
@@ -0,0 +1,120 @@
import { UmbAppEntryPointExtensionInitializer } from './app-entry-point-extension-initializer.js';
import type { ManifestAppEntryPoint } from '../extensions/app-entry-point.extension.js';
import { aTimeout, expect, fixture } from '@open-wc/testing';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin, type UmbElement } from '@umbraco-cms/backoffice/element-api';
import { UmbExtensionRegistry } from '@umbraco-cms/backoffice/extension-api';
@customElement('umb-test-app-entry-point-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestAppEntryPointHostElement extends UmbElementMixin(HTMLElement) {}
const ALIAS = 'Umb.Test.AppEntryPoint';
const waitLoaded = (initializer: UmbAppEntryPointExtensionInitializer) =>
new Promise<void>((resolve) => {
const sub = initializer.loaded.subscribe((value) => {
if (value === true) {
resolve();
queueMicrotask(() => sub.unsubscribe());
}
});
});
describe('UmbAppEntryPointExtensionInitializer', () => {
let host: UmbElement;
let registry: UmbExtensionRegistry<ManifestAppEntryPoint>;
beforeEach(async () => {
host = (await fixture(html`<umb-test-app-entry-point-host></umb-test-app-entry-point-host>`)) as UmbElement;
registry = new UmbExtensionRegistry();
});
afterEach(() => {
registry.clear();
});
it('only observes manifests of type "appEntryPoint"', async () => {
let called = false;
const initializer = new UmbAppEntryPointExtensionInitializer(host, registry);
// Register a non-appEntryPoint manifest — must NOT trigger onInit.
registry.register({
type: 'backofficeEntryPoint',
alias: 'Umb.Test.NotMe',
name: 'should-not-fire',
js: { onInit: () => (called = true) } as never,
} as never);
await aTimeout(20);
expect(called).to.be.false;
initializer.destroy();
});
it('calls onInit on the resolved module with (host, registry) when an appEntryPoint is registered', async () => {
const initializer = new UmbAppEntryPointExtensionInitializer(host, registry);
const onInitArgs: Array<[unknown, unknown]> = [];
const moduleInstance = {
onInit: (h: unknown, r: unknown) => onInitArgs.push([h, r]),
onUnload: () => {},
};
registry.register({
type: 'appEntryPoint',
alias: ALIAS,
name: 'test-app-entry-point',
js: moduleInstance,
} as ManifestAppEntryPoint);
await waitLoaded(initializer);
expect(onInitArgs.length).to.equal(1);
expect(onInitArgs[0][0]).to.equal(host);
expect(onInitArgs[0][1]).to.equal(registry);
initializer.destroy();
});
it('calls onUnload when an appEntryPoint manifest is unregistered', async () => {
const initializer = new UmbAppEntryPointExtensionInitializer(host, registry);
let unloadCount = 0;
const moduleInstance = {
onInit: () => {},
onUnload: () => unloadCount++,
};
registry.register({
type: 'appEntryPoint',
alias: ALIAS,
name: 'test-app-entry-point',
js: moduleInstance,
} as ManifestAppEntryPoint);
await waitLoaded(initializer);
expect(unloadCount).to.equal(0);
registry.unregister(ALIAS);
// Give the observer a tick to react.
await aTimeout(20);
expect(unloadCount).to.equal(1);
initializer.destroy();
});
it('skips instantiation when the manifest has no js property', async () => {
const initializer = new UmbAppEntryPointExtensionInitializer(host, registry);
// Without `js`, instantiateExtension is a no-op but the observer still
// flips `loaded` to true.
registry.register({
type: 'appEntryPoint',
alias: ALIAS,
name: 'no-js-app-entry-point',
} as ManifestAppEntryPoint);
await waitLoaded(initializer);
// Reaching here without throwing is the whole assertion.
initializer.destroy();
});
});
@@ -0,0 +1,117 @@
import { UmbBackofficeEntryPointExtensionInitializer } from './backoffice-entry-point-extension-initializer.js';
import type { ManifestBackofficeEntryPoint } from '../extensions/backoffice-entry-point.extension.js';
import { aTimeout, expect, fixture } from '@open-wc/testing';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin, type UmbElement } from '@umbraco-cms/backoffice/element-api';
import { UmbExtensionRegistry } from '@umbraco-cms/backoffice/extension-api';
@customElement('umb-test-backoffice-entry-point-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestBackofficeEntryPointHostElement extends UmbElementMixin(HTMLElement) {}
const ALIAS = 'Umb.Test.BackofficeEntryPoint';
const waitLoaded = (initializer: UmbBackofficeEntryPointExtensionInitializer) =>
new Promise<void>((resolve) => {
const sub = initializer.loaded.subscribe((value) => {
if (value === true) {
resolve();
queueMicrotask(() => sub.unsubscribe());
}
});
});
describe('UmbBackofficeEntryPointExtensionInitializer', () => {
let host: UmbElement;
let registry: UmbExtensionRegistry<ManifestBackofficeEntryPoint>;
beforeEach(async () => {
host = (await fixture(
html`<umb-test-backoffice-entry-point-host></umb-test-backoffice-entry-point-host>`,
)) as UmbElement;
registry = new UmbExtensionRegistry();
});
afterEach(() => {
registry.clear();
});
it('only observes manifests of type "backofficeEntryPoint"', async () => {
let called = false;
const initializer = new UmbBackofficeEntryPointExtensionInitializer(host, registry);
registry.register({
type: 'appEntryPoint',
alias: 'Umb.Test.NotMe',
name: 'should-not-fire',
js: { onInit: () => (called = true) } as never,
} as never);
await aTimeout(20);
expect(called).to.be.false;
initializer.destroy();
});
it('calls onInit with (host, registry) when a backofficeEntryPoint is registered', async () => {
const initializer = new UmbBackofficeEntryPointExtensionInitializer(host, registry);
const onInitArgs: Array<[unknown, unknown]> = [];
const moduleInstance = {
onInit: (h: unknown, r: unknown) => onInitArgs.push([h, r]),
onUnload: () => {},
};
registry.register({
type: 'backofficeEntryPoint',
alias: ALIAS,
name: 'test-backoffice-entry-point',
js: moduleInstance,
} as ManifestBackofficeEntryPoint);
await waitLoaded(initializer);
expect(onInitArgs.length).to.equal(1);
expect(onInitArgs[0][0]).to.equal(host);
expect(onInitArgs[0][1]).to.equal(registry);
initializer.destroy();
});
it('calls onUnload when a backofficeEntryPoint manifest is unregistered', async () => {
const initializer = new UmbBackofficeEntryPointExtensionInitializer(host, registry);
let unloadCount = 0;
const moduleInstance = {
onInit: () => {},
onUnload: () => unloadCount++,
};
registry.register({
type: 'backofficeEntryPoint',
alias: ALIAS,
name: 'test-backoffice-entry-point',
js: moduleInstance,
} as ManifestBackofficeEntryPoint);
await waitLoaded(initializer);
expect(unloadCount).to.equal(0);
registry.unregister(ALIAS);
await aTimeout(20);
expect(unloadCount).to.equal(1);
initializer.destroy();
});
it('skips instantiation when the manifest has no js property', async () => {
const initializer = new UmbBackofficeEntryPointExtensionInitializer(host, registry);
registry.register({
type: 'backofficeEntryPoint',
alias: ALIAS,
name: 'no-js-backoffice-entry-point',
} as ManifestBackofficeEntryPoint);
await waitLoaded(initializer);
initializer.destroy();
});
});
@@ -0,0 +1,133 @@
import { UmbEntryPointExtensionInitializer } from './entry-point-extension-initializer.js';
import type { ManifestEntryPoint } from '../extensions/entry-point.extension.js';
import { aTimeout, expect, fixture } from '@open-wc/testing';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin, type UmbElement } from '@umbraco-cms/backoffice/element-api';
import { UmbExtensionRegistry } from '@umbraco-cms/backoffice/extension-api';
@customElement('umb-test-entry-point-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestEntryPointHostElement extends UmbElementMixin(HTMLElement) {}
const ALIAS = 'Umb.Test.EntryPoint';
const waitLoaded = (initializer: UmbEntryPointExtensionInitializer) =>
new Promise<void>((resolve) => {
const sub = initializer.loaded.subscribe((value) => {
if (value === true) {
resolve();
queueMicrotask(() => sub.unsubscribe());
}
});
});
// The (deprecated) entry-point initializer logs an error on every instantiation.
// Suppress it for the duration of each test so the noise doesn't pollute output,
// but capture the calls so we can also verify the deprecation warning fires.
let originalConsoleError: typeof console.error;
let consoleErrors: Array<unknown[]>;
describe('UmbEntryPointExtensionInitializer', () => {
let host: UmbElement;
let registry: UmbExtensionRegistry<ManifestEntryPoint>;
beforeEach(async () => {
host = (await fixture(html`<umb-test-entry-point-host></umb-test-entry-point-host>`)) as UmbElement;
registry = new UmbExtensionRegistry();
consoleErrors = [];
originalConsoleError = console.error;
console.error = (...args: unknown[]) => consoleErrors.push(args);
});
afterEach(() => {
console.error = originalConsoleError;
registry.clear();
});
it('logs a deprecation error when a manifest is instantiated', async () => {
const initializer = new UmbEntryPointExtensionInitializer(host, registry);
registry.register({
type: 'entryPoint',
alias: ALIAS,
name: 'test-entry-point',
js: { onInit: () => {}, onUnload: () => {} } as never,
} as ManifestEntryPoint);
await waitLoaded(initializer);
const deprecationLogged = consoleErrors.some((args) =>
typeof args[0] === 'string' && args[0].includes('`entryPoint` extension-type is deprecated'),
);
expect(deprecationLogged).to.be.true;
initializer.destroy();
});
it('calls onInit with (host, registry) when an entryPoint is registered', async () => {
const initializer = new UmbEntryPointExtensionInitializer(host, registry);
const onInitArgs: Array<[unknown, unknown]> = [];
const moduleInstance = {
onInit: (h: unknown, r: unknown) => onInitArgs.push([h, r]),
onUnload: () => {},
};
registry.register({
type: 'entryPoint',
alias: ALIAS,
name: 'test-entry-point',
js: moduleInstance,
} as ManifestEntryPoint);
await waitLoaded(initializer);
expect(onInitArgs.length).to.equal(1);
expect(onInitArgs[0][0]).to.equal(host);
expect(onInitArgs[0][1]).to.equal(registry);
initializer.destroy();
});
it('calls onUnload when an entryPoint manifest is unregistered', async () => {
const initializer = new UmbEntryPointExtensionInitializer(host, registry);
let unloadCount = 0;
const moduleInstance = {
onInit: () => {},
onUnload: () => unloadCount++,
};
registry.register({
type: 'entryPoint',
alias: ALIAS,
name: 'test-entry-point',
js: moduleInstance,
} as ManifestEntryPoint);
await waitLoaded(initializer);
expect(unloadCount).to.equal(0);
registry.unregister(ALIAS);
await aTimeout(20);
expect(unloadCount).to.equal(1);
initializer.destroy();
});
it('only observes manifests of type "entryPoint"', async () => {
let called = false;
const initializer = new UmbEntryPointExtensionInitializer(host, registry);
registry.register({
type: 'backofficeEntryPoint',
alias: 'Umb.Test.NotMe',
name: 'should-not-fire',
js: { onInit: () => (called = true) } as never,
} as never);
await aTimeout(20);
expect(called).to.be.false;
initializer.destroy();
});
});
@@ -0,0 +1,113 @@
import { umbExtensionsRegistry } from './registry.js';
import { expect } from '@open-wc/testing';
import { UmbExtensionRegistry } from '@umbraco-cms/backoffice/extension-api';
import type { ManifestKind } from '@umbraco-cms/backoffice/extension-api';
// Aliases scoped to this test file so we don't collide with anything else
// that may already be registered in the shared singleton.
const SECTION_ALIAS_A = 'Umb.Test.Registry.Section.A';
const SECTION_ALIAS_B = 'Umb.Test.Registry.Section.B';
const KIND_ALIAS = 'Umb.Test.Registry.Kind';
describe('umbExtensionsRegistry (singleton)', () => {
afterEach(() => {
// Conservative cleanup: remove only the aliases we added, so the singleton
// is left in the same state the suite found it in.
[SECTION_ALIAS_A, SECTION_ALIAS_B, KIND_ALIAS].forEach((alias) => {
if (umbExtensionsRegistry.isRegistered(alias)) {
umbExtensionsRegistry.unregister(alias);
}
});
});
it('is an instance of UmbExtensionRegistry', () => {
expect(umbExtensionsRegistry).to.be.instanceOf(UmbExtensionRegistry);
});
it('registers and reports an extension as registered, then unregisters it', () => {
expect(umbExtensionsRegistry.isRegistered(SECTION_ALIAS_A)).to.be.false;
umbExtensionsRegistry.register({
type: 'section',
name: 'Test Section A',
alias: SECTION_ALIAS_A,
meta: { label: 'Test Section A', pathname: 'test-a' },
} as UmbExtensionManifest);
expect(umbExtensionsRegistry.isRegistered(SECTION_ALIAS_A)).to.be.true;
const ext = umbExtensionsRegistry.getByAlias(SECTION_ALIAS_A);
expect(ext?.alias).to.equal(SECTION_ALIAS_A);
umbExtensionsRegistry.unregister(SECTION_ALIAS_A);
expect(umbExtensionsRegistry.isRegistered(SECTION_ALIAS_A)).to.be.false;
});
it('ignores duplicate registration of the same alias', () => {
const manifest = {
type: 'section',
name: 'Test Section B (first)',
alias: SECTION_ALIAS_B,
meta: { label: 'first', pathname: 'b' },
} as UmbExtensionManifest;
umbExtensionsRegistry.register(manifest);
// Suppress the expected console.error from the duplicate-registration check.
const originalError = console.error;
const errors: Array<unknown[]> = [];
console.error = (...args) => errors.push(args);
try {
umbExtensionsRegistry.register({
...manifest,
name: 'Test Section B (second)',
});
} finally {
console.error = originalError;
}
// The first registration is preserved, the second is rejected.
const ext = umbExtensionsRegistry.getByAlias(SECTION_ALIAS_B) as { name: string };
expect(ext?.name).to.equal('Test Section B (first)');
expect(errors.length).to.equal(1);
});
it('merges a kind manifest into a registered extension that references it', () => {
const kind: ManifestKind<UmbExtensionManifest> = {
type: 'kind',
alias: KIND_ALIAS,
matchType: 'section',
matchKind: 'test-kind',
manifest: {
type: 'section',
name: 'kind-default-name',
alias: 'kind-default-alias',
meta: {
label: 'label-from-kind',
pathname: 'pathname-from-kind',
},
} as UmbExtensionManifest,
};
umbExtensionsRegistry.register(kind);
umbExtensionsRegistry.register({
type: 'section',
kind: 'test-kind',
name: 'consumer',
alias: SECTION_ALIAS_A,
weight: 10,
meta: {
// `label` deliberately omitted — should fall back to the kind's value.
pathname: 'pathname-from-extension',
},
} as unknown as UmbExtensionManifest);
const merged = umbExtensionsRegistry.getByAlias(SECTION_ALIAS_A) as {
meta: { label: string; pathname: string };
};
// Kind contributes the missing field; consumer wins where it sets a value.
expect(merged.meta.label).to.equal('label-from-kind');
expect(merged.meta.pathname).to.equal('pathname-from-extension');
});
});