From 611b1dffec09922e933a8c082dbb8a03ec2e0f2b Mon Sep 17 00:00:00 2001 From: Kevin Kucharczyk Date: Sat, 23 Mar 2024 19:26:49 +1100 Subject: [PATCH] Add context provider test helpers (#20) --- .changeset/serious-lemons-pull.md | 5 + README.md | 77 +++++++++++++++ ember-provide-consume-context/package.json | 8 +- .../rollup.config.mjs | 1 + .../provide-consume-context-container.ts | 53 +++++++++-- .../src/test-support/index.ts | 57 +++++++++++ package-lock.json | 18 +++- test-app/package.json | 4 +- .../components/test-support-test.ts | 95 +++++++++++++++++++ 9 files changed, 303 insertions(+), 15 deletions(-) create mode 100644 .changeset/serious-lemons-pull.md create mode 100644 ember-provide-consume-context/src/test-support/index.ts create mode 100644 test-app/tests/integration/components/test-support-test.ts diff --git a/.changeset/serious-lemons-pull.md b/.changeset/serious-lemons-pull.md new file mode 100644 index 0000000..fc2da81 --- /dev/null +++ b/.changeset/serious-lemons-pull.md @@ -0,0 +1,5 @@ +--- +"ember-provide-consume-context": patch +--- + +Add context provider test helpers diff --git a/README.md b/README.md index 602877f..80b52ec 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,83 @@ export default class MyChildComponent extends Component { } ``` +### Testing +The addon includes two test helpers for setting up context providers in integration tests: +- `provide` +- `setupRenderWrapper` + +#### `provide` +The `provide` helper can be used to set up a global context provider for a test, by calling +it with a context key and its value, for example: + +```ts +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { provide } from 'ember-provide-consume-context/test-support'; + +module('component tests', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function (this) { + provide('my-test-context', { + count: 1, + }); + }); + + test('can read context', async function (assert) { + await render(hbs` + +
{{data.count}}
+
+ `); + + assert.dom('#content').hasText('1'); + }); +}); +``` + +#### `setupRenderWrapper` +`setupRenderWrapper` can be used to define a template to wrap contents rendered via `render` in a test. + +This is useful, for example, when it's necessary to wrap content in a context provider exposed by +and addon, or an internal context provider from the application. For example: + +```ts +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupRenderWrapper } from 'ember-provide-consume-context/test-support'; + +module('component tests', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function (this) { + setupRenderWrapper(hbs` + + {{outlet}} + + `); + }); + + test('can read context', async function (assert) { + await render(hbs` + Button + `); + + // renders in dark mode + }); +}); +``` + +> [!IMPORTANT] +> The render wrapper must use `{{outlet}}` rather than `{{yield}}` to render the wrapped content. +> +> Internally, `setupRenderWrapper` overrrides the outlet template defined by `@ember/test-helpers`: https://github.com/emberjs/ember-test-helpers/blob/9cec68dc6aa9c0a7a449eb89797eb81299fa727f/addon/addon-test-support/%40ember/test-helpers/setup-rendering-context.ts#L68-L69 + + ## Inspiration The idea was to create an API similar to the Context API in React - [`React Context API`](https://react.dev/reference/react/createContext): The diff --git a/ember-provide-consume-context/package.json b/ember-provide-consume-context/package.json index 2eac5f2..a85633f 100644 --- a/ember-provide-consume-context/package.json +++ b/ember-provide-consume-context/package.json @@ -16,6 +16,10 @@ "types": "./declarations/index.d.ts", "default": "./dist/index.js" }, + "./test-support": { + "types": "./declarations/test-support/index.d.ts", + "default": "./dist/test-support/index.js" + }, "./*": { "types": "./declarations/*.d.ts", "default": "./dist/*.js" @@ -56,7 +60,8 @@ "@glimmer/component": "^1.1.2" }, "peerDependencies": { - "ember-source": "^4.8.0 || ^5.0.0" + "ember-source": "^4.8.0 || ^5.0.0", + "@ember/test-helpers": "^2.9.1 || ^3.0.0" }, "devDependencies": { "@babel/core": "^7.17.0", @@ -65,6 +70,7 @@ "@babel/plugin-transform-class-static-block": "^7.20.0", "@babel/preset-typescript": "^7.18.6", "@babel/runtime": "^7.17.0", + "@ember/test-helpers": "^3.3.0", "@embroider/addon-dev": "^4.1.0", "@glimmer/interfaces": "^0.84.3", "@glimmer/runtime": "^0.84.3", diff --git a/ember-provide-consume-context/rollup.config.mjs b/ember-provide-consume-context/rollup.config.mjs index 05080f0..0aead6c 100644 --- a/ember-provide-consume-context/rollup.config.mjs +++ b/ember-provide-consume-context/rollup.config.mjs @@ -27,6 +27,7 @@ export default { 'index.js', 'template-registry.js', 'context-registry.js', + 'test-support/**/*.js', ]), // These are the modules that should get reexported into the traditional diff --git a/ember-provide-consume-context/src/-private/provide-consume-context-container.ts b/ember-provide-consume-context/src/-private/provide-consume-context-container.ts index 79dd73a..ec1be13 100644 --- a/ember-provide-consume-context/src/-private/provide-consume-context-container.ts +++ b/ember-provide-consume-context/src/-private/provide-consume-context-container.ts @@ -35,7 +35,7 @@ interface Contexts { } interface ContextEntry { - // instance is an instance of a Glimmer component + // instance is an instance of a Glimmer component, or a "mock provider" from test-support helpers instance: any; // the property to read from the provider instance key: string; @@ -54,6 +54,10 @@ export class ProvideConsumeContextContainer { // component instance. contexts = new WeakMap(); + // Global contexts are registered by test-support helpers to allow easily + // providing context values in tests. + #globalContexts: Contexts | null = null; + begin(): void { this.reset(); } @@ -70,6 +74,34 @@ export class ProvideConsumeContextContainer { } } + registerMockProvider = < + T extends keyof ContextRegistry, + U extends ContextRegistry[T], + >( + name: T, + value: U, + ) => { + const mockProviderContext = { + instance: { + get value() { + return value; + }, + }, + key: 'value', + }; + + if (this.#globalContexts?.[name] != null) { + console.warn( + `A context provider with name "${name}" is already defined, and will be overwritten.`, + ); + } + + this.#globalContexts = { + ...this.#globalContexts, + [name]: mockProviderContext, + }; + }; + enter(instance: ComponentInstance): void { const actualComponentInstance = (instance?.state as any)?.component; @@ -99,12 +131,18 @@ export class ProvideConsumeContextContainer { const { current } = this; let providerContexts: Contexts = {}; + + // If global contexts are defined, make sure providers can read them + if (this.#globalContexts != null) { + providerContexts = { ...this.#globalContexts }; + } + if (this.contexts.has(current)) { // If a provider is nested within another provider, we merge their // contexts const context = this.contexts.get(current); if (context != null) { - providerContexts = { ...context }; + providerContexts = { ...providerContexts, ...context }; } } @@ -128,12 +166,13 @@ export class ProvideConsumeContextContainer { private registerComponent(component: any) { const { current } = this; - // If a current context reference exists, register the component to it - if (this.contexts.has(current)) { + const globalContexts = this.#globalContexts ?? {}; + + // If a current context reference or global contexts exist, register them to the component + if (this.contexts.has(current) || Object.keys(globalContexts).length > 0) { const context = this.contexts.get(current); - if (context != null) { - this.contexts.set(component, context); - } + const mergedContexts = { ...globalContexts, ...context }; + this.contexts.set(component, mergedContexts); } } } diff --git a/ember-provide-consume-context/src/test-support/index.ts b/ember-provide-consume-context/src/test-support/index.ts new file mode 100644 index 0000000..784e2ce --- /dev/null +++ b/ember-provide-consume-context/src/test-support/index.ts @@ -0,0 +1,57 @@ +import { getContext } from '@ember/test-helpers'; +import type { TestContext } from '@ember/test-helpers'; +import type { ProvideConsumeContextContainer } from '../-private/provide-consume-context-container'; +import type ContextRegistry from '../context-registry'; + +export function setupRenderWrapper(templateFactory: object) { + const context = getContext() as TestContext | undefined; + if (context == null) { + throw new Error('Could not find test context'); + } + + if (context.owner == null) { + throw new Error('Could not find owner on test context'); + } + + const { owner } = context; + + // Registers a custom outlet to use in the test, similar to how test-helpers does it: + // https://github.com/emberjs/ember-test-helpers/blob/9cec68dc6aa9c0a7a449eb89797eb81299fa727f/addon/addon-test-support/%40ember/test-helpers/setup-rendering-context.ts#L68 + // Casting "as any" because "unregister" isn't defined on the Owner type, but it does exist. + (owner as any).unregister('template:-outlet'); + owner.register('template:-outlet', templateFactory); +} + +export function provide< + T extends keyof ContextRegistry, + U extends ContextRegistry[T], +>(name: T, value: U) { + const context = getContext() as TestContext | undefined; + if (context?.owner != null) { + const { owner } = context; + + // https://github.com/emberjs/ember.js/blob/57073a0e9751d036d4bcfc11d5367e3f6ae751d2/packages/%40ember/-internals/glimmer/lib/renderer.ts#L284 + // We cast to "any", because Renderer is a private API and isn't easily accessible. + // Even if we imported the type, "_runtime" is marked as private, + // so we wouldn't be able to access the current runtime or its type. + // If Context was implemented in Ember proper, it would have access to those private + // APIs, and this wouldn't look quite as illegal anymore. + const renderer = owner.lookup('renderer:-dom') as any; + + if (renderer == null) { + throw new Error('Could not find "renderer:-dom" on owner'); + } + + const container = renderer._runtime?.env?.provideConsumeContextContainer as + | ProvideConsumeContextContainer + | undefined; + + if (container == null) { + throw new Error( + 'Could not find "provideConsumeContextContainer" instance in runtime environment', + ); + } + + container.registerMockProvider(name, value); + } +} diff --git a/package-lock.json b/package-lock.json index 93841c7..b7e2573 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@babel/plugin-transform-class-static-block": "^7.20.0", "@babel/preset-typescript": "^7.18.6", "@babel/runtime": "^7.17.0", + "@ember/test-helpers": "^3.3.0", "@embroider/addon-dev": "^4.1.0", "@glimmer/interfaces": "^0.84.3", "@glimmer/runtime": "^0.84.3", @@ -78,6 +79,7 @@ "typescript": "^5.0.4" }, "peerDependencies": { + "@ember/test-helpers": "^2.9.1 || ^3.0.0", "ember-source": "^4.8.0 || ^5.0.0" } }, @@ -2352,9 +2354,9 @@ } }, "node_modules/@ember/test-helpers": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@ember/test-helpers/-/test-helpers-3.2.1.tgz", - "integrity": "sha512-DvJSihJPV4xshwEgBrFN4aUVc9m/Y/hVzwcslfSVq/h3dMWCyAj4+agkkdJPQrwBaE+H4IyGNzr555S7bTErEA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@ember/test-helpers/-/test-helpers-3.3.0.tgz", + "integrity": "sha512-HEI28wtjnQuEj9+DstHUEEKPtqPAEVN9AAVr4EifVCd3DyEDy0m6hFT4qbap1WxAIktLja2QXGJg50lVWzZc5g==", "dev": true, "dependencies": { "@ember/test-waiters": "^3.0.2", @@ -2362,6 +2364,7 @@ "@simple-dom/interface": "^1.4.0", "broccoli-debug": "^0.6.5", "broccoli-funnel": "^3.0.8", + "dom-element-descriptors": "^0.5.0", "ember-auto-import": "^2.6.0", "ember-cli-babel": "^7.26.11", "ember-cli-htmlbars": "^6.2.0" @@ -10093,6 +10096,12 @@ "node": ">=6.0.0" } }, + "node_modules/dom-element-descriptors": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/dom-element-descriptors/-/dom-element-descriptors-0.5.0.tgz", + "integrity": "sha512-CVzntLid1oFVHTKdTp/Qu7Kz+wSm8uO30TSQyAJ6n4Dz09yTzVQn3S1oRhVhUubxdMuKs1DjDqt88pubHagbPw==", + "dev": true + }, "node_modules/domexception": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", @@ -13489,7 +13498,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -24540,7 +24548,7 @@ "devDependencies": { "@ember/optional-features": "^2.0.0", "@ember/string": "^3.0.1", - "@ember/test-helpers": "^3.2.1", + "@ember/test-helpers": "^3.3.0", "@embroider/test-setup": "^3.0.1", "@glimmer/component": "^1.1.2", "@glimmer/tracking": "^1.1.2", diff --git a/test-app/package.json b/test-app/package.json index 2399ce2..046dff0 100644 --- a/test-app/package.json +++ b/test-app/package.json @@ -29,10 +29,9 @@ "test:ember": "ember test" }, "devDependencies": { - "ember-provide-consume-context": "*", "@ember/optional-features": "^2.0.0", "@ember/string": "^3.0.1", - "@ember/test-helpers": "^3.2.1", + "@ember/test-helpers": "^3.3.0", "@embroider/test-setup": "^3.0.1", "@glimmer/component": "^1.1.2", "@glimmer/tracking": "^1.1.2", @@ -78,6 +77,7 @@ "ember-load-initializers": "^2.1.2", "ember-modifier": "^4.1.0", "ember-page-title": "^7.0.0", + "ember-provide-consume-context": "*", "ember-qunit": "^8.0.2", "ember-resolver": "^10.0.0", "ember-source": "~4.12.2", diff --git a/test-app/tests/integration/components/test-support-test.ts b/test-app/tests/integration/components/test-support-test.ts new file mode 100644 index 0000000..96cde24 --- /dev/null +++ b/test-app/tests/integration/components/test-support-test.ts @@ -0,0 +1,95 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { + setupRenderWrapper, + provide, +} from 'ember-provide-consume-context/test-support'; + +module('Integration | Provider test helpers', function (hooks) { + setupRenderingTest(hooks); + + module('provide in beforeEach', function (hooks) { + hooks.beforeEach(function (this) { + provide('my-test-context', '1'); + }); + + test('can read context provided by "provide" test helper', async function (assert) { + await render(hbs` + +
{{count}}
+
+ `); + + assert.dom('#content').hasText('1'); + }); + + test('can override "provide" context with a provider component', async function (assert) { + await render(hbs` + + +
{{count}}
+
+
+ `); + + assert.dom('#content').hasText('2'); + }); + }); + + module('setupRenderWrapper in beforeEach', function (hooks) { + hooks.beforeEach(function (this) { + setupRenderWrapper( + hbs`{{outlet}}`, + ); + }); + + test('can read context provided by "setupRenderWrapper" test helper', async function (assert) { + await render(hbs` + +
{{count}}
+
+ `); + + assert.dom('#content').hasText('3'); + }); + + test('can override "setupRenderWrapper" context with a provider component', async function (assert) { + await render(hbs` + + +
{{count}}
+
+
+ `); + + assert.dom('#content').hasText('4'); + }); + }); + + test('can read context provided by "provide" test helper', async function (assert) { + provide('my-test-context', '5'); + await render(hbs` + +
{{count}}
+
+ `); + + assert.dom('#content').hasText('5'); + }); + + test('can read context provided by "setupRenderWrapper" test helper', async function (assert) { + setupRenderWrapper( + hbs`{{outlet}}`, + ); + + await render(hbs` + +
{{count}}
+
+ `); + + assert.dom('#content').hasText('6'); + }); +});