Skip to content

Commit 2609f4f

Browse files
NullVoxPopuliclaude
andcommitted
Scenario test: loading and error substates with strict resolver
Adds smoke-tests/scenarios/strict-resolver-substates-test.ts — a sibling to basic-test.ts that builds a full v2 app wired up with the strict resolver (modules: { ...import.meta.glob(...) }) and exercises Ember's auto-generated loading/error substates through real route transitions. Covers: - visiting / (plain route + template) - visiting /slow (pending model, renders templates/slow-loading.hbs while the gate service holds the promise) - visiting /broken (rejected model, renders templates/broken-error.hbs) - visiting /posts and /posts/:id (nested and dynamic sub-routes, same as the existing scenario but under this flavor of the app setup) To make the loading/error substates actually kick in under the strict resolver, flip StrictResolver.moduleBasedResolver = true. Router._ buildDSL() reads that flag via _hasModuleBasedResolver() and only creates the synthetic ${name}_loading / ${name}_error routes when it's set. Without the flag, Ember never asks the resolver for the substate templates, so this test couldn't observe them. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 578bd7b commit 2609f4f

2 files changed

Lines changed: 269 additions & 0 deletions

File tree

packages/@ember/engine/lib/strict-resolver.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import type { Factory, Resolver } from '@ember/owner';
22
import { dasherize } from './strict-resolver/string';
33

44
export class StrictResolver implements Resolver {
5+
// Ember's router uses this flag to decide whether to auto-generate
6+
// `${name}_loading` and `${name}_error` substates for routes defined in
7+
// `Router.map(...)`. Since we always resolve against an ES module registry,
8+
// we unconditionally opt in.
9+
moduleBasedResolver = true;
10+
511
#modules = new Map<string, unknown>();
612
#plurals = new Map<string, string>();
713
original: any;
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { strictAppScenarios } from './scenarios';
2+
import type { PreparedApp } from 'scenario-tester';
3+
import * as QUnit from 'qunit';
4+
const { module: Qmodule, test } = QUnit;
5+
6+
// Companion to `basic-test.ts`: builds a full v2 app wired up with the
7+
// strict resolver (modules: { ...import.meta.glob(...) }) and exercises
8+
// Ember's auto-generated loading/error substates through real route
9+
// transitions. Covers:
10+
//
11+
// - visiting / (plain route + template)
12+
// - visiting /slow (async model -> loading substate)
13+
// - visiting /broken (rejected model -> error substate)
14+
// - visiting a nested dynamic route (posts/:post_id) to prove the
15+
// resolver handles sub-route templates via default lookup
16+
strictAppScenarios
17+
.map('strict-resolver-substates', (project) => {
18+
project.mergeFiles({
19+
app: {
20+
'app.js': `
21+
import Application from '@ember/application';
22+
import Router from './router';
23+
import config from 'v2-app-template/config/environment';
24+
25+
export default class App extends Application {
26+
modulePrefix = config.modulePrefix;
27+
28+
modules = {
29+
'./router': { default: Router },
30+
...import.meta.glob('./services/**/*.{js,ts}', { eager: true }),
31+
...import.meta.glob('./routes/**/*.{js,ts}', { eager: true }),
32+
...import.meta.glob('./controllers/**/*.{js,ts}', { eager: true }),
33+
...import.meta.glob('./templates/**/*.hbs', { eager: true }),
34+
};
35+
}
36+
`,
37+
'router.js': `
38+
import EmberRouter from '@embroider/router';
39+
import config from 'v2-app-template/config/environment';
40+
41+
export default class Router extends EmberRouter {
42+
location = config.locationType;
43+
rootURL = config.rootURL;
44+
}
45+
46+
Router.map(function () {
47+
this.route('slow');
48+
this.route('broken');
49+
this.route('posts', function () {
50+
this.route('show', { path: '/:post_id' });
51+
});
52+
});
53+
`,
54+
services: {
55+
// A controllable gate for /slow's model. Tests call hold() to
56+
// receive a promise and release() to resolve it — that lets the
57+
// loading substate observably appear before the main template.
58+
'gate.js': `
59+
import Service from '@ember/service';
60+
61+
export default class GateService extends Service {
62+
_resolve = null;
63+
64+
hold() {
65+
return new Promise((resolve) => {
66+
this._resolve = resolve;
67+
});
68+
}
69+
70+
release() {
71+
if (this._resolve) {
72+
this._resolve();
73+
this._resolve = null;
74+
}
75+
}
76+
}
77+
`,
78+
},
79+
routes: {
80+
'index.js': `
81+
import Route from '@ember/routing/route';
82+
export default class extends Route {
83+
model() {
84+
return { welcome: 'Welcome to the strict app' };
85+
}
86+
}
87+
`,
88+
'slow.js': `
89+
import Route from '@ember/routing/route';
90+
import { service } from '@ember/service';
91+
92+
export default class extends Route {
93+
@service gate;
94+
95+
async model() {
96+
await this.gate.hold();
97+
return { message: 'Slow route ready' };
98+
}
99+
}
100+
`,
101+
'broken.js': `
102+
import Route from '@ember/routing/route';
103+
export default class extends Route {
104+
model() {
105+
return Promise.reject(new Error('intentional model failure'));
106+
}
107+
}
108+
`,
109+
'posts.js': `
110+
import Route from '@ember/routing/route';
111+
export default class extends Route {
112+
model() {
113+
return [
114+
{ id: 1, title: 'First Post' },
115+
{ id: 2, title: 'Second Post' },
116+
];
117+
}
118+
}
119+
`,
120+
'posts': {
121+
'show.js': `
122+
import Route from '@ember/routing/route';
123+
export default class extends Route {
124+
model(params) {
125+
return { id: params.post_id, title: 'Post ' + params.post_id };
126+
}
127+
}
128+
`,
129+
},
130+
},
131+
templates: {
132+
'application.hbs': `
133+
<div data-test="app-shell">
134+
{{outlet}}
135+
</div>
136+
`,
137+
'index.hbs': `
138+
<div data-test="index-welcome">{{@model.welcome}}</div>
139+
`,
140+
'slow.hbs': `
141+
<div data-test="slow-ready">{{@model.message}}</div>
142+
`,
143+
// Auto-generated loading substate for /slow. Ember will resolve
144+
// template:slow_loading -> slow-loading -> templates/slow-loading.
145+
'slow-loading.hbs': `
146+
<div data-test="slow-loading">Loading slow route...</div>
147+
`,
148+
// Auto-generated error substate for /broken. The error model is
149+
// the thrown value, so we render its .message to prove the
150+
// template received it.
151+
'broken-error.hbs': `
152+
<div data-test="broken-error">Caught error: {{@model.message}}</div>
153+
`,
154+
'posts.hbs': `
155+
<div data-test="posts-list">
156+
{{#each @model as |post|}}
157+
<div data-test="post-card">{{post.title}}</div>
158+
{{/each}}
159+
</div>
160+
{{outlet}}
161+
`,
162+
'posts': {
163+
'show.hbs': `
164+
<div data-test="post-detail">{{@model.title}}</div>
165+
`,
166+
},
167+
},
168+
},
169+
tests: {
170+
acceptance: {
171+
'strict-resolver-substates-test.js': `
172+
import { module, test } from 'qunit';
173+
import { visit, currentURL, waitUntil } from '@ember/test-helpers';
174+
import { setupApplicationTest } from 'v2-app-template/tests/helpers';
175+
176+
module('Acceptance | strict resolver substates', function (hooks) {
177+
setupApplicationTest(hooks);
178+
179+
test('visiting / renders the index template', async function (assert) {
180+
await visit('/');
181+
assert.strictEqual(currentURL(), '/');
182+
assert.dom('[data-test="app-shell"]').exists();
183+
assert.dom('[data-test="index-welcome"]').hasText(
184+
'Welcome to the strict app'
185+
);
186+
});
187+
188+
test('visiting /slow shows the loading substate while the model is pending', async function (assert) {
189+
let gate = this.owner.lookup('service:gate');
190+
191+
// Don't await — the model is blocked on gate.release(),
192+
// so awaiting visit directly would hang.
193+
let visitPromise = visit('/slow');
194+
195+
// Poll the DOM (doesn't depend on settled) until the
196+
// loading substate appears. If the resolver can't find
197+
// ./templates/slow-loading.hbs this will time out.
198+
await waitUntil(
199+
() => document.querySelector('[data-test="slow-loading"]'),
200+
{ timeout: 2000 }
201+
);
202+
203+
assert.dom('[data-test="slow-loading"]').exists(
204+
'loading substate template is rendered'
205+
);
206+
assert.dom('[data-test="slow-ready"]').doesNotExist(
207+
'main template is not yet rendered'
208+
);
209+
210+
gate.release();
211+
await visitPromise;
212+
213+
assert.strictEqual(currentURL(), '/slow');
214+
assert.dom('[data-test="slow-ready"]').hasText(
215+
'Slow route ready'
216+
);
217+
assert.dom('[data-test="slow-loading"]').doesNotExist(
218+
'loading substate is replaced once the model resolves'
219+
);
220+
});
221+
222+
test('visiting /broken shows the error substate when the model rejects', async function (assert) {
223+
await visit('/broken');
224+
225+
assert.dom('[data-test="broken-error"]').exists(
226+
'error substate template is rendered'
227+
);
228+
assert.dom('[data-test="broken-error"]').hasText(
229+
'Caught error: intentional model failure'
230+
);
231+
});
232+
233+
test('visiting /posts renders the list', async function (assert) {
234+
await visit('/posts');
235+
assert.strictEqual(currentURL(), '/posts');
236+
assert.dom('[data-test="posts-list"]').exists();
237+
assert.dom('[data-test="post-card"]').exists({ count: 2 });
238+
});
239+
240+
test('visiting a nested dynamic sub-route renders the detail template', async function (assert) {
241+
await visit('/posts/42');
242+
assert.strictEqual(currentURL(), '/posts/42');
243+
assert.dom('[data-test="post-detail"]').hasText('Post 42');
244+
});
245+
});
246+
`,
247+
},
248+
},
249+
});
250+
})
251+
.forEachScenario((scenario) => {
252+
Qmodule(scenario.name, function (hooks) {
253+
let app: PreparedApp;
254+
hooks.before(async () => {
255+
app = await scenario.prepare();
256+
});
257+
258+
test(`ember test`, async function (assert) {
259+
let result = await app.execute(`pnpm test`);
260+
assert.equal(result.exitCode, 0, result.output);
261+
});
262+
});
263+
});

0 commit comments

Comments
 (0)