Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement testing RFC #1211

Merged
merged 23 commits into from
Apr 21, 2022
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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;
25 changes: 25 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,25 @@
import { macroCondition, dependencySatisfies } from '@embroider/macros';

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

/**
*
* @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
*/
cafreeman marked this conversation as resolved.
Show resolved Hide resolved
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'))) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😆 🤣 😢

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, now that the Ember change has landed for realz let's update this to 4.5.0-beta.1.

//@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;
cafreeman marked this conversation as resolved.
Show resolved Hide resolved

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
14 changes: 14 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,10 @@ 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();
rwjblue marked this conversation as resolved.
Show resolved Hide resolved

/**
Used by test framework addons to setup the provided context for testing.

Expand Down Expand Up @@ -414,6 +418,11 @@ 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 you have passed a component to `render()` (the rendered component does not have access to the test context).'
);
}
rwjblue marked this conversation as resolved.
Show resolved Hide resolved
return set(context, key, value);
});

Expand All @@ -427,6 +436,11 @@ 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 you have passed a component to `render()` (the rendered component does not have access to the test context)'
);
}
return setProperties(context, hash);
});

Expand Down
80 changes: 73 additions & 7 deletions addon-test-support/@ember/test-helpers/setup-rendering-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ import getTestMetadata, { ITestMetadata } from './test-metadata';
import { 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 } 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 />`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chriskrycho here is this mystic this.ProvidedComponent that fails ember-optimized scenario

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,69 @@ 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);

rwjblue marked this conversation as resolved.
Show resolved Hide resolved
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 +205,7 @@ export function render(
name: 'index',
controller: context,
ViewClass: undefined,
template: lookupTemplate(ownerToRenderFrom, templateFullName),
template: templateOrComponent,
outlets: {},
},
outlets: {},
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@
"test": "ember test",
"test:all": "ember try:each"
},
"peerDependencies": {
"ember-source": ">=3.8.0"
},
"dependencies": {
"@ember/test-waiters": "^3.0.0",
"@embroider/macros": "^1.6.0",
"broccoli-debug": "^0.6.5",
"broccoli-funnel": "^3.0.8",
"ember-cli-babel": "^7.26.6",
Expand All @@ -48,6 +52,9 @@
"@babel/cli": "^7.14.8",
"@babel/preset-typescript": "^7.15.0",
"@ember/optional-features": "^2.0.0",
"@glimmer/component": "^1.0.4",
"@glimmer/interfaces": "^0.84.1",
"@glimmer/reference": "^0.84.1",
Comment on lines +55 to +57
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

someday we'll detangle that circular reference and then not need all these extra packages 😭

"@types/ember": "^3.16.5",
"@types/ember-testing-helpers": "^0.0.4",
"@types/rsvp": "^4.0.4",
Expand Down