Skip to content

Commit c557ce6

Browse files
Merge pull request #21322 from NullVoxPopuli-ai-agent/scenario-loading-error-states
Scenario test: loading and error substates with strict resolver
2 parents 578bd7b + 1302135 commit c557ce6

2 files changed

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

0 commit comments

Comments
 (0)