Skip to content

Commit

Permalink
Implement testing RFC (#1211)
Browse files Browse the repository at this point in the history
  • Loading branch information
cafreeman authored Apr 21, 2022
1 parent d17d27c commit 52714fd
Show file tree
Hide file tree
Showing 16 changed files with 1,954 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface SettledState {
hasPendingLegacyWaiters: boolean;
hasPendingTestWaiters: boolean;
hasPendingRequests: boolean;
isRenderPending: boolean;
}

interface SummaryInfo {
Expand All @@ -39,6 +40,7 @@ interface SummaryInfo {
pendingScheduledQueueItemCount: Number;
pendingScheduledQueueItemStackTraces: (string | undefined)[];
hasRunLoop: boolean;
isRenderPending: boolean;
}

export default interface DebugInfo {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Ember from 'ember';
import {
macroCondition,
importSync,
dependencySatisfies,
} from '@embroider/macros';
import type { InternalComponentManager } from '@glimmer/interfaces';

let getComponentManager: (
definition: object,
owner: object
) => InternalComponentManager | null;

if (macroCondition(dependencySatisfies('ember-source', '>=3.27.0-alpha.1'))) {
let _getComponentManager =
//@ts-ignore
importSync('@glimmer/manager').getInternalComponentManager;

getComponentManager = (definition: object, owner: object) => {
return _getComponentManager(definition, true);
};
} else if (
macroCondition(dependencySatisfies('ember-source', '>=3.25.0-beta.1'))
) {
let _getComponentManager = (Ember as any).__loader.require(
'@glimmer/manager'
).getInternalComponentManager;

getComponentManager = (definition: object, owner: object) => {
return _getComponentManager(definition, true);
};
} else {
let _getComponentManager = (Ember as any).__loader.require(
'@glimmer/runtime'
).getComponentManager;

getComponentManager = (definition: object, owner: object) => {
return _getComponentManager(owner, definition);
};
}

export default getComponentManager;
26 changes: 26 additions & 0 deletions addon-test-support/@ember/test-helpers/-internal/is-component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { macroCondition, dependencySatisfies } from '@embroider/macros';

import getComponentManager from './get-component-manager';

/**
* We should ultimately get a new API from @glimmer/runtime that provides this functionality
* (see https://github.com/emberjs/rfcs/pull/785 for more info).
* @private
* @param {Object} maybeComponent The thing you think might be a component
* @param {Object} owner Owner, we need this for old versions of getComponentManager
* @returns {boolean} True if it's a component, false if not
*/
function isComponent(maybeComponent: object, owner: object): boolean {
if (macroCondition(dependencySatisfies('ember-source', '>=3.25.0-beta.1'))) {
return !!getComponentManager(maybeComponent, owner);
} else {
return (
!!getComponentManager(maybeComponent, owner) ||
['@ember/component', '@ember/component/template-only'].includes(
maybeComponent.toString()
)
);
}
}

export default isComponent;
31 changes: 31 additions & 0 deletions addon-test-support/@ember/test-helpers/-internal/render-settled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Ember from 'ember';
import {
macroCondition,
importSync,
dependencySatisfies,
} from '@embroider/macros';

let renderSettled: () => Promise<void>;

// TODO need to add the actual version `@ember/renderer` landed once we know it
if (macroCondition(dependencySatisfies('ember-source', '>=4.9999999.0'))) {
//@ts-ignore
renderSettled = importSync('@ember/renderer').renderSettled;
} else if (
macroCondition(dependencySatisfies('ember-source', '>=3.27.0-alpha.1'))
) {
//@ts-ignore
renderSettled = importSync('@ember/-internals/glimmer').renderSettled;
} else if (
macroCondition(dependencySatisfies('ember-source', '>=3.6.0-alpha.1'))
) {
renderSettled = (Ember as any).__loader.require(
'@ember/-internals/glimmer'
).renderSettled;
} else {
renderSettled = (Ember as any).__loader.require(
'ember-glimmer'
).renderSettled;
}

export default renderSettled;
1 change: 1 addition & 0 deletions addon-test-support/@ember/test-helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
render,
clearRender,
} from './setup-rendering-context';
export { default as rerender } from './rerender';
export {
default as setupApplicationContext,
visit,
Expand Down
21 changes: 21 additions & 0 deletions addon-test-support/@ember/test-helpers/rerender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import renderSettled from './-internal/render-settled';

/**
Returns a promise which will resolve when rendering has completed. In
this context, rendering is completed when all auto-tracked state that is
consumed in the template (including any tracked state in models, services,
etc. that are then used in a template) has been updated in the DOM.
For example, in a test you might want to update some tracked state and
then run some assertions after rendering has completed. You _could_ use
`await settled()` in that location, but in some contexts you don't want to
wait for full settledness (which includes test waiters, pending AJAX/fetch,
run loops, etc) but instead only want to know when that updated value has
been rendered in the DOM. **THAT** is what `await rerender()` is _perfect_
for.
@public
@returns {Promise<void>} a promise which fulfills when rendering has completed
*/
export default function rerender() {
return renderSettled();
}
12 changes: 11 additions & 1 deletion addon-test-support/@ember/test-helpers/settled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export interface SettledState {
hasPendingWaiters: boolean;
hasPendingRequests: boolean;
hasPendingTransitions: boolean | null;
isRenderPending: boolean;
pendingRequestCount: number;
debugInfo?: DebugInfo;
}
Expand All @@ -215,6 +216,8 @@ export interface SettledState {
- `pendingRequestCount` - The count of pending AJAX requests.
- `debugInfo` - Debug information that's combined with info return from backburner's
getDebugInfo method.
- `isRenderPending` - Checks if there are any pending render operations. This will be true as long
as there are tracked values in the template that have not been rerendered yet.
@public
@returns {Object} object with properties for each of the metrics used to determine settledness
Expand All @@ -226,20 +229,25 @@ export function getSettledState(): SettledState {
let hasPendingTestWaiters = hasPendingWaiters();
let pendingRequestCount = pendingRequests();
let hasPendingRequests = pendingRequestCount > 0;
// TODO: Ideally we'd have a function in Ember itself that can synchronously identify whether
// or not there are any pending render operations, but this will have to suffice for now
let isRenderPending = !!hasRunLoop;

return {
hasPendingTimers,
hasRunLoop,
hasPendingWaiters: hasPendingLegacyWaiters || hasPendingTestWaiters,
hasPendingRequests,
hasPendingTransitions: hasPendingTransitions(),
isRenderPending,
pendingRequestCount,
debugInfo: new TestDebugInfo({
hasPendingTimers,
hasRunLoop,
hasPendingLegacyWaiters,
hasPendingTestWaiters,
hasPendingRequests,
isRenderPending,
}),
};
}
Expand All @@ -261,14 +269,16 @@ export function isSettled(): boolean {
hasPendingRequests,
hasPendingWaiters,
hasPendingTransitions,
isRenderPending,
} = getSettledState();

if (
hasPendingTimers ||
hasRunLoop ||
hasPendingRequests ||
hasPendingWaiters ||
hasPendingTransitions
hasPendingTransitions ||
isRenderPending
) {
return false;
}
Expand Down
34 changes: 34 additions & 0 deletions addon-test-support/@ember/test-helpers/setup-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,11 @@ export function getWarningsDuringCallback(
return getWarningsDuringCallbackForContext(context, callback);
}

// This WeakMap is used to track whenever a component is rendered in a test so that we can throw
// assertions when someone uses `this.{set,setProperties}` while rendering a component.
export const ComponentRenderMap = new WeakMap<BaseContext, true>();
export const SetUsage = new WeakMap<BaseContext, Array<string>>();

/**
Used by test framework addons to setup the provided context for testing.
Expand Down Expand Up @@ -414,6 +419,21 @@ export default function setupContext(
enumerable: true,
value(key: string, value: any): any {
let ret = run(function () {
if (ComponentRenderMap.has(context)) {
assert(
'You cannot call `this.set` when passing a component to `render()` (the rendered component does not have access to the test context).'
);
} else {
let setCalls = SetUsage.get(context);

if (setCalls === undefined) {
setCalls = [];
SetUsage.set(context, setCalls);
}

setCalls?.push(key);
}

return set(context, key, value);
});

Expand All @@ -427,6 +447,20 @@ export default function setupContext(
enumerable: true,
value(hash: { [key: string]: any }): { [key: string]: any } {
let ret = run(function () {
if (ComponentRenderMap.has(context)) {
assert(
'You cannot call `this.setProperties` when passing a component to `render()` (the rendered component does not have access to the test context)'
);
} else {
let setCalls = SetUsage.get(context);

if (SetUsage.get(context) === undefined) {
setCalls = [];
SetUsage.set(context, setCalls);
}

setCalls?.push(...Object.keys(hash));
}
return setProperties(context, hash);
});

Expand Down
92 changes: 84 additions & 8 deletions addon-test-support/@ember/test-helpers/setup-rendering-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@ import { hbs, TemplateFactory } from 'ember-cli-htmlbars';
import getRootElement from './dom/get-root-element';
import { Owner } from './build-owner';
import getTestMetadata, { ITestMetadata } from './test-metadata';
import { deprecate } from '@ember/debug';
import { assert, deprecate } from '@ember/debug';
import { runHooks } from './-internal/helper-hooks';
import hasEmberVersion from './has-ember-version';
import isComponent from './-internal/is-component';
import { macroCondition, dependencySatisfies } from '@embroider/macros';
import { ComponentRenderMap, SetUsage } from './setup-context';
import type { ComponentInstance } from '@glimmer/interfaces';

const OUTLET_TEMPLATE = hbs`{{outlet}}`;
const EMPTY_TEMPLATE = hbs``;
const INVOKE_PROVIDED_COMPONENT = hbs`<this.ProvidedComponent />`;
const DYNAMIC_INVOKE_PROVIDED_COMPONENT = hbs`{{component this.ProvidedComponent}}`;

export interface RenderingTestContext extends TestContext {
render(template: TemplateFactory): Promise<void>;
Expand Down Expand Up @@ -84,17 +90,17 @@ export interface RenderOptions {
Renders the provided template and appends it to the DOM.
@public
@param {CompiledTemplate} template the template to render
@param {Template|Component} templateOrComponent the component (or template) to render
@param {RenderOptions} options options hash containing engine owner ({ owner: engineOwner })
@returns {Promise<void>} resolves when settled
*/
export function render(
template: TemplateFactory,
templateOrComponent: TemplateFactory | ComponentInstance,
options?: RenderOptions
): Promise<void> {
let context = getContext();

if (!template) {
if (!templateOrComponent) {
throw new Error('you must pass a template to `render()`');
}

Expand All @@ -115,9 +121,79 @@ export function render(
let OutletTemplate = lookupOutletTemplate(owner);
let ownerToRenderFrom = options?.owner || owner;

templateId += 1;
let templateFullName = `template:-undertest-${templateId}`;
ownerToRenderFrom.register(templateFullName, template);
if (macroCondition(dependencySatisfies('ember-source', '<3.24.0'))) {
// Pre 3.24, we just don't support rendering components at all, so we error
// if we find anything that isn't a template.
const isTemplate =
('__id' in templateOrComponent && '__meta' in templateOrComponent) ||
('id' in templateOrComponent && 'meta' in templateOrComponent);

if (!isTemplate) {
throw new Error(
`Using \`render\` with something other than a pre-compiled template is not supported until Ember 3.24 (you are on ${Ember.VERSION}).`
);
}

templateId += 1;
let templateFullName = `template:-undertest-${templateId}`;
ownerToRenderFrom.register(templateFullName, templateOrComponent);
templateOrComponent = lookupTemplate(
ownerToRenderFrom,
templateFullName
);
} else {
if (isComponent(templateOrComponent, owner)) {
// We use this to track when `render` is used with a component so that we can throw an
// assertion if `this.{set,setProperty} is used in the same test
ComponentRenderMap.set(context, true);

const setCalls = SetUsage.get(context);

if (setCalls !== undefined) {
assert(
`You cannot call \`this.set\` or \`this.setProperties\` when passing a component to \`render\`, but they were called for the following properties:\n${setCalls
.map((key) => ` - ${key}`)
.join('\n')}`
);
}

if (
macroCondition(
dependencySatisfies('ember-source', '>=3.25.0-beta.1')
)
) {
// In 3.25+, we can treat components as one big object and just pass them around/invoke them
// wherever, so we just assign the component to the `ProvidedComponent` property and invoke it
// in the test's template
context = {
ProvidedComponent: templateOrComponent,
};
templateOrComponent = INVOKE_PROVIDED_COMPONENT;
} else {
// Below 3.25, however, we *cannot* treat components as one big object and instead have to
// register their class and template independently and then invoke them with the `component`
// helper so they can actually be found by the resolver and rendered
templateId += 1;
let name = `-undertest-${templateId}`;
let componentFullName = `component:${name}`;
let templateFullName = `template:components/${name}`;
context = {
ProvidedComponent: name,
};
ownerToRenderFrom.register(componentFullName, templateOrComponent);
templateOrComponent = DYNAMIC_INVOKE_PROVIDED_COMPONENT;
ownerToRenderFrom.register(templateFullName, templateOrComponent);
}
} else {
templateId += 1;
let templateFullName = `template:-undertest-${templateId}`;
ownerToRenderFrom.register(templateFullName, templateOrComponent);
templateOrComponent = lookupTemplate(
ownerToRenderFrom,
templateFullName
);
}
}

let outletState = {
render: {
Expand All @@ -139,7 +215,7 @@ export function render(
name: 'index',
controller: context,
ViewClass: undefined,
template: lookupTemplate(ownerToRenderFrom, templateFullName),
template: templateOrComponent,
outlets: {},
},
outlets: {},
Expand Down
Loading

0 comments on commit 52714fd

Please sign in to comment.