diff --git a/package.json b/package.json index 8cf0f30d3d4..f1ac50823d6 100644 --- a/package.json +++ b/package.json @@ -229,6 +229,7 @@ "@ember/engine/index.js": "ember-source/@ember/engine/index.js", "@ember/engine/instance.js": "ember-source/@ember/engine/instance.js", "@ember/engine/lib/engine-parent.js": "ember-source/@ember/engine/lib/engine-parent.js", + "@ember/engine/lib/strict-resolver.js": "ember-source/@ember/engine/lib/strict-resolver.js", "@ember/engine/parent.js": "ember-source/@ember/engine/parent.js", "@ember/enumerable/index.js": "ember-source/@ember/enumerable/index.js", "@ember/enumerable/mutable.js": "ember-source/@ember/enumerable/mutable.js", diff --git a/packages/@ember/engine/index.ts b/packages/@ember/engine/index.ts index e16240fa87d..b51a336e89a 100644 --- a/packages/@ember/engine/index.ts +++ b/packages/@ember/engine/index.ts @@ -13,6 +13,7 @@ import type { EngineInstanceOptions } from '@ember/engine/instance'; import EngineInstance from '@ember/engine/instance'; import { RoutingService } from '@ember/routing/-internals'; import { ComponentLookup } from '@ember/-internals/views'; +import { StrictResolver } from './lib/strict-resolver'; import { setupEngineRegistry } from '@ember/-internals/glimmer'; import { RegistryProxyMixin } from '@ember/-internals/runtime'; @@ -328,7 +329,30 @@ class Engine extends Namespace.extend(RegistryProxyMixin) { @property resolver @public */ - declare Resolver: ResolverClass; + Resolver: ResolverClass = { + create: ({ namespace }: { namespace: Engine }) => + new StrictResolver(namespace.modules ?? {}, namespace.plurals), + } as unknown as ResolverClass; + + /** + Set this to opt-in to using a strict resolver that will only return the + given set of ES modules. The names of the modules should all be relative to + the root of the app and start with "./" + + @property modules + @public + */ + declare modules?: Record; + + /** + Custom pluralization rules for the strict resolver. By default, types are + pluralized by appending 's' (e.g. 'service' -> 'services'). The 'config' + type is pre-mapped to 'config' (no pluralization). + + @property plurals + @public + */ + declare plurals?: Record; init(properties: object | undefined) { super.init(properties); diff --git a/packages/@ember/engine/lib/strict-resolver.ts b/packages/@ember/engine/lib/strict-resolver.ts new file mode 100644 index 00000000000..8fcd93da130 --- /dev/null +++ b/packages/@ember/engine/lib/strict-resolver.ts @@ -0,0 +1,145 @@ +import type { Factory, Resolver } from '@ember/owner'; + +export class StrictResolver implements Resolver { + // Ember's router uses this flag to decide whether to auto-generate + // `${name}_loading` and `${name}_error` substates for routes defined in + // `Router.map(...)`. Since we always resolve against an ES module registry, + // we unconditionally opt in. + moduleBasedResolver = true; + + #modules = new Map(); + #plurals = new Map(); + original: any; + + constructor( + modules: Record, + plurals: Record | undefined = undefined + ) { + this.addModules(modules); + this.#plurals.set('config', 'config'); + if (plurals) { + for (let [singular, plural] of Object.entries(plurals)) { + this.#plurals.set(singular, plural); + } + } + } + + addModules(modules: Record) { + for (let [moduleName, module] of Object.entries(modules)) { + this.#modules.set(this.#normalizeModule(moduleName), module); + } + } + + #normalizeModule(moduleName: string) { + return moduleName.replace(fileExtension, '').replace(leadingDotSlash, ''); + } + + #plural(s: string) { + return this.#plurals.get(s) ?? s + 's'; + } + + resolve(fullName: string): Factory | object | undefined { + let [type, name] = fullName.split(':') as [string, string]; + name = this.#normalizeName(type, name); + for (let strategy of [ + this.#resolveSelf, + this.#mainLookup, + this.#defaultLookup, + this.#nestedColocationLookup, + ]) { + let result = strategy.call(this, type, name); + if (result) { + return this.#extractDefaultExport(result.hit); + } + } + return undefined; + } + + #extractDefaultExport(module: any): Factory | object | undefined { + if (module && module['default']) { + module = module['default']; + } + return module as Factory | object | undefined; + } + + normalize(fullName: `${string}:${string}`): `${string}:${string}` { + let [type, name] = fullName.split(':') as [string, string]; + name = this.#normalizeName(type, name); + return `${type}:${name}`; + } + + #normalizeName(type: string, name: string): string { + if ( + type === 'component' || + type === 'helper' || + type === 'modifier' || + (type === 'template' && name.indexOf('components/') === 0) + ) { + return name.replace(/_/g, '-'); + } else { + return dasherize(name.replace(/\./g, '/')); + } + } + + #resolveSelf(type: string, name: string): Result { + if (type === 'resolver' && name === 'current') { + return { + hit: { + create: () => this, + }, + }; + } + return undefined; + } + + #mainLookup(type: string, name: string): Result { + if (name === 'main') { + let module = this.#modules.get(type); + if (module) { + return { hit: module }; + } + } + return undefined; + } + + #defaultLookup(type: string, name: string): Result { + let dir = this.#plural(type); + let target = `${dir}/${name}`; + let module = this.#modules.get(target); + if (module) { + return { hit: module }; + } + return undefined; + } + + // Supports the nested colocation pattern where `component:my-widget` + // resolves to `./components/my-widget/index.{js,ts,gjs,gts}`. The index + // file is typically the component class, and it's commonly paired with a + // sibling `index.hbs` inside the same folder. + #nestedColocationLookup(type: string, name: string): Result { + if (type !== 'component') return undefined; + + let dir = this.#plural(type); + let target = `${dir}/${name}/index`; + let module = this.#modules.get(target); + if (module) { + return { hit: module }; + } + return undefined; + } +} + +const fileExtension = /\.\w{1,4}$/; +const leadingDotSlash = /^\.\//; +const camelCaseBoundary = /([a-z\d])([A-Z])/g; +const spacesAndUnderscores = /[ _]/g; + +function dasherize(str: string): string { + return str.replace(camelCaseBoundary, '$1_$2').toLowerCase().replace(spacesAndUnderscores, '-'); +} + +type Result = + | { + hit: any; + } + | undefined; diff --git a/packages/@ember/engine/tests/resolver/basic-test.js b/packages/@ember/engine/tests/resolver/basic-test.js new file mode 100644 index 00000000000..2074f9ab3c6 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -0,0 +1,311 @@ +import { module, test } from 'qunit'; +import { StrictResolver } from '@ember/engine/lib/strict-resolver'; + +module('StrictResolver', function (hooks) { + let resolver; + let modules; + + hooks.beforeEach(function () { + modules = {}; + resolver = new StrictResolver(modules); + }); + + module('#resolve + #addModules', function () { + test('resolves the standard ember types via default pluralization', function (assert) { + // All of these go through the same `type + 's' -> dir` path. One table- + // driven test keeps the coverage without nine copies of the same setup. + let cases = [ + { fullName: 'adapter:post', key: './adapters/post' }, + { fullName: 'service:session', key: './services/session' }, + { fullName: 'helper:reverse-list', key: './helpers/reverse-list' }, + { fullName: 'component:my-widget', key: './components/my-widget' }, + { fullName: 'modifier:auto-focus', key: './modifiers/auto-focus' }, + { fullName: 'template:application', key: './templates/application' }, + { fullName: 'view:queue-list', key: './views/queue-list' }, + { fullName: 'route:index', key: './routes/index' }, + { fullName: 'controller:application', key: './controllers/application' }, + ]; + + for (let { fullName, key } of cases) { + let expected = { fullName }; + let longForm = new StrictResolver({ [key]: { default: expected } }); + let shortForm = new StrictResolver({ [key]: expected }); + + assert.strictEqual(longForm.resolve(fullName), expected, `${fullName} -> ${key}`); + assert.strictEqual(shortForm.resolve(fullName), expected, `${fullName} -> ${key}`); + } + }); + + test('`type:main` resolves to the unpluralized `type` module key', function (assert) { + // The mainLookup strategy short-circuits pluralization: for any type + // `type:main` reads the module at the type's bare name. + resolver.addModules({ + './router': 'the-router', + './store': 'the-store', + }); + + assert.strictEqual(resolver.resolve('router:main'), 'the-router'); + assert.strictEqual(resolver.resolve('store:main'), 'the-store'); + }); + + test('returns undefined for missing modules', function (assert) { + let result = resolver.resolve('service:nonexistent'); + + assert.strictEqual(result, undefined, 'undefined was returned'); + }); + + test('can resolve self via resolver:current', function (assert) { + let self = resolver.resolve('resolver:current'); + + assert.ok(self, 'resolver:current returned a factory'); + assert.strictEqual(self.create(), resolver, 'factory creates the resolver'); + }); + }); + + module('#addModules + normalization', function () { + test('normalization', function (assert) { + assert.strictEqual(resolver.normalize('controller:posts'), 'controller:posts'); + assert.strictEqual(resolver.normalize('controller:postsIndex'), 'controller:posts-index'); + assert.strictEqual(resolver.normalize('controller:posts.index'), 'controller:posts/index'); + assert.strictEqual(resolver.normalize('controller:posts_index'), 'controller:posts-index'); + assert.strictEqual(resolver.normalize('controller:posts-index'), 'controller:posts-index'); + assert.strictEqual( + resolver.normalize('controller:posts.post.index'), + 'controller:posts/post/index' + ); + assert.strictEqual( + resolver.normalize('controller:posts_post.index'), + 'controller:posts-post/index' + ); + assert.strictEqual( + resolver.normalize('controller:posts.post_index'), + 'controller:posts/post-index' + ); + assert.strictEqual( + resolver.normalize('controller:posts.post-index'), + 'controller:posts/post-index' + ); + assert.strictEqual( + resolver.normalize('controller:blogPosts.index'), + 'controller:blog-posts/index' + ); + assert.strictEqual( + resolver.normalize('controller:blog/posts.index'), + 'controller:blog/posts/index' + ); + assert.strictEqual( + resolver.normalize('controller:blog/posts-index'), + 'controller:blog/posts-index' + ); + assert.strictEqual( + resolver.normalize('controller:blog/posts.post.index'), + 'controller:blog/posts/post/index' + ); + assert.strictEqual( + resolver.normalize('controller:blog/posts_post.index'), + 'controller:blog/posts-post/index' + ); + assert.strictEqual( + resolver.normalize('controller:blog/posts_post-index'), + 'controller:blog/posts-post-index' + ); + + assert.strictEqual( + resolver.normalize('template:blog/posts_index'), + 'template:blog/posts-index' + ); + assert.strictEqual(resolver.normalize('service:userAuth'), 'service:user-auth'); + + // For helpers, we have special logic to avoid the situation of a template's + // `{{someName}}` being surprisingly shadowed by a `some-name` helper + assert.strictEqual(resolver.normalize('helper:make-fabulous'), 'helper:make-fabulous'); + assert.strictEqual(resolver.normalize('helper:fabulize'), 'helper:fabulize'); + assert.strictEqual(resolver.normalize('helper:make_fabulous'), 'helper:make-fabulous'); + assert.strictEqual(resolver.normalize('helper:makeFabulous'), 'helper:makeFabulous'); + + // The same applies to components + assert.strictEqual( + resolver.normalize('component:fabulous-component'), + 'component:fabulous-component' + ); + assert.strictEqual( + resolver.normalize('component:fabulousComponent'), + 'component:fabulousComponent' + ); + assert.strictEqual( + resolver.normalize('template:components/fabulousComponent'), + 'template:components/fabulousComponent' + ); + + // and modifiers + assert.strictEqual( + resolver.normalize('modifier:fabulous-component'), + 'modifier:fabulous-component' + ); + assert.strictEqual( + resolver.normalize('modifier:fabulouslyMissing'), + 'modifier:fabulouslyMissing' + ); + }); + + test('addModules allows adding modules after construction', function (assert) { + let expected = {}; + + resolver.addModules({ + './components/hello': { default: expected }, + }); + + let component = resolver.resolve('component:hello'); + + assert.strictEqual(component, expected, 'component was resolved'); + }); + }); + + test('module paths with ./ prefix are normalized', function (assert) { + let resolver2 = new StrictResolver({ + './services/foo': { default: 'from-dot-slash' }, + }); + + assert.strictEqual( + resolver2.resolve('service:foo'), + 'from-dot-slash', + './ prefix was stripped' + ); + }); + + test('module paths with file extensions are normalized', function (assert) { + let resolver2 = new StrictResolver({ + './services/foo.ts': { default: 'from-ts' }, + }); + + assert.strictEqual(resolver2.resolve('service:foo'), 'from-ts', 'file extension was stripped'); + }); + + module('weird scenarios', function () { + test('shorthand class with a falsy or missing `default` falls back to the class itself', function (assert) { + // `.default` being falsy (undefined / null / 0 / '') means the shorthand + // value is used directly — matching the "if there's a default use it, + // else use the value" rule from the other direction. + class ClassWithUndefinedDefault { + static default = undefined; + static create() { + return new this(); + } + } + + class ClassWithoutDefault { + static create() { + return new this(); + } + } + + let r = new StrictResolver({ + './services/with-undefined-default': ClassWithUndefinedDefault, + './services/without-default': ClassWithoutDefault, + }); + + assert.strictEqual( + r.resolve('service:with-undefined-default'), + ClassWithUndefinedDefault, + 'undefined `.default` is treated as "no default", class is used' + ); + assert.strictEqual( + r.resolve('service:without-default'), + ClassWithoutDefault, + 'class without a `default` property is used as-is' + ); + }); + + test('ES-module-shaped module with extra named exports still unwraps to `default`', function (assert) { + // A normal `import * as mod from './...'` yields an object whose + // `default` is the default export plus any named exports. The resolver + // returns the default; everything else on the namespace object is + // ignored. Documenting this so authors know the named exports don't + // leak through. + let defaultExport = { isDefault: true }; + let registered = { + default: defaultExport, + named: 'not used', + another: 42, + }; + + let resolver2 = new StrictResolver({ + './services/extras': registered, + }); + + assert.strictEqual(resolver2.resolve('service:extras'), defaultExport); + }); + + test('`type:name` falls back to `types/name/index`, but only for comopnents', function (assert) { + let component = { component: true }; + let helper = { helper: true }; + let modifier = { modifier: true }; + let route = { route: true }; + resolver.addModules({ + './components/my-widget/index': { default: component }, + './helpers/format-date/index': { default: helper }, + './modifiers/on-intersect/index': { default: modifier }, + './routes/some-route/index': { default: route }, + }); + + assert.strictEqual(resolver.resolve('component:my-widget'), component); + assert.strictEqual(resolver.resolve('helper:format-date'), undefined); + assert.strictEqual(resolver.resolve('modifier:on-intersect'), undefined); + assert.strictEqual(resolver.resolve('route:some-route'), undefined); + }); + + test('direct module takes precedence over the nested-colocation index', function (assert) { + let direct = { direct: true }; + let nested = { nested: true }; + resolver.addModules({ + './components/my-widget': { default: direct }, + './components/my-widget/index': { default: nested }, + }); + + assert.strictEqual( + resolver.resolve('component:my-widget'), + direct, + 'direct match wins over the colocation fallback' + ); + }); + }); + + module('pluralization', function () { + test('config type pluralizes as config by default', function (assert) { + modules['./config/environment'] = 'env-config'; + resolver.addModules(modules); + + let result = resolver.resolve('config:environment'); + + assert.strictEqual(result, 'env-config', 'config/environment is found'); + }); + + test('custom plurals are supported', function (assert) { + let resolver2 = new StrictResolver({ './sheep/baaaaaa': 'whatever' }, { sheep: 'sheep' }); + + let result = resolver2.resolve('sheep:baaaaaa'); + + assert.strictEqual(result, 'whatever', 'custom plural was used'); + }); + + test("'config' plural can be overridden", function (assert) { + let resolver2 = new StrictResolver( + { './super-duper-config/environment': 'whatever' }, + { config: 'super-duper-config' } + ); + + let result = resolver2.resolve('config:environment'); + + assert.strictEqual(result, 'whatever', 'super-duper-config/environment is found'); + }); + + test('irregular plurals must be opted into via the plurals option', function (assert) { + // Default pluralization is naive (type + 's'), matching ember-resolver's + // behavior. A consumer that wants proper English irregulars registers + // them up-front via the plurals map. + let r = new StrictResolver({ './children/alice': 'alice' }, { child: 'children' }); + + assert.strictEqual(r.resolve('child:alice'), 'alice'); + }); + }); +}); diff --git a/packages/@ember/engine/tests/resolver/registry_test.ts b/packages/@ember/engine/tests/resolver/registry_test.ts new file mode 100644 index 00000000000..aaedda0b37c --- /dev/null +++ b/packages/@ember/engine/tests/resolver/registry_test.ts @@ -0,0 +1,63 @@ +import { module, test } from 'qunit'; +import Application from '@ember/application'; +import Service from '@ember/service'; +import { run } from '@ember/runloop'; +import type ApplicationInstance from '@ember/application/instance'; + +module('strict-resolver | Application with modules', function (hooks) { + let app: Application | undefined; + let instance: ApplicationInstance | undefined; + + hooks.afterEach(function () { + run(() => { + instance?.destroy(); + app?.destroy(); + }); + instance = undefined; + app = undefined; + }); + + test('registered stuff can be looked up', async function (assert) { + class Foo { + static create() { + return new this(); + } + + two = 2; + } + + app = Application.create({ + modules: {}, + rootElement: '#qunit-fixture', + autoboot: false, + }); + + instance = (await app.visit('/', {})) as ApplicationInstance; + + instance.register('not-standard:main', Foo); + + let value = instance.lookup('not-standard:main') as Foo; + + assert.strictEqual(value.two, 2); + }); + + test('resolves modules provided via modules property', async function (assert) { + class MyService extends Service { + weDidIt = true; + } + + app = Application.create({ + modules: { + './services/my-thing': { default: MyService }, + }, + rootElement: '#qunit-fixture', + autoboot: false, + }); + + instance = (await app.visit('/', {})) as ApplicationInstance; + let service = instance.lookup('service:my-thing') as MyService; + + assert.ok(service, 'service was found'); + assert.ok(service.weDidIt, 'service has the right property'); + }); +}); diff --git a/smoke-tests/scenarios/scenarios.ts b/smoke-tests/scenarios/scenarios.ts index 571c33d3e62..25c229230e6 100644 --- a/smoke-tests/scenarios/scenarios.ts +++ b/smoke-tests/scenarios/scenarios.ts @@ -21,6 +21,41 @@ function embroiderWebpack(project: Project) { function embroiderVite(project: Project) {} +// Swap the v2-app-template's default app.js for a strict-resolver variant: +// no ember-resolver, no compatModules, no modulePrefix — just a `modules = +// {...import.meta.glob(...)}` literal. Making this a variant of +// v2AppScenarios means every test that runs against v2AppScenarios also +// runs against this configuration. +function strictResolver(project: Project) { + project.mergeFiles({ + app: { + 'app.js': ` + import Application from '@ember/application'; + import Router from './router'; + // v2 addons don't auto-register with the strict resolver the way + // classic ember-resolver + compat-modules does; we have to explicitly + // wire each addon-provided module into \`modules\`. The + // v2-app-template uses \`{{pageTitle}}\` in application.gjs, which + // injects \`@service('page-title')\`, so we need to register it here. + import PageTitleService from 'ember-page-title/services/page-title'; + + export default class App extends Application { + modules = { + './router': { default: Router }, + './services/page-title': { default: PageTitleService }, + ...import.meta.glob('./services/**/*.{js,ts}', { eager: true }), + ...import.meta.glob('./controllers/**/*.{js,ts}', { eager: true }), + ...import.meta.glob('./routes/**/*.{js,ts}', { eager: true }), + ...import.meta.glob('./components/**/*.{gjs,gts,js,ts}', { eager: true }), + ...import.meta.glob('./helpers/**/*.{js,ts}', { eager: true }), + ...import.meta.glob('./templates/**/*.{hbs,gjs,gts}', { eager: true }), + }; + } + `, + }, + }); +} + export const v1AppScenarios = Scenarios.fromProject(() => Project.fromDir(dirname(require.resolve('../app-template/package.json')), { linkDevDeps: true }) ).expand({ @@ -34,6 +69,15 @@ export const v2AppScenarios = Scenarios.fromProject(() => }) ).expand({ embroiderVite, + strictResolver, +}); + +export const strictAppScenarios = Scenarios.fromProject(() => + Project.fromDir(dirname(require.resolve('../v2-app-template/package.json')), { + linkDevDeps: true, + }) +).expand({ + strictResolver, }); function node(project: Project) { diff --git a/smoke-tests/scenarios/strict-resolver-substates-test.ts b/smoke-tests/scenarios/strict-resolver-substates-test.ts new file mode 100644 index 00000000000..e6511dff293 --- /dev/null +++ b/smoke-tests/scenarios/strict-resolver-substates-test.ts @@ -0,0 +1,193 @@ +import { strictAppScenarios } from './scenarios'; +import type { PreparedApp } from 'scenario-tester'; +import * as QUnit from 'qunit'; +const { module: Qmodule, test } = QUnit; + +strictAppScenarios + .map('strict-resolver-substates', (project) => { + project.mergeFiles({ + app: { + 'app.js': ` + import Application from '@ember/application'; + import Router from './router'; + + export default class App extends Application { + modules = { + './router': { default: Router }, + ...import.meta.glob('./services/**/*.{js,ts}', { eager: true }), + ...import.meta.glob('./routes/**/*.{js,ts}', { eager: true }), + ...import.meta.glob('./controllers/**/*.{js,ts}', { eager: true }), + ...import.meta.glob('./templates/**/*.hbs', { eager: true }), + }; + } + `, + 'router.js': ` + import EmberRouter from '@embroider/router'; + import config from 'v2-app-template/config/environment'; + + export default class Router extends EmberRouter { + location = config.locationType; + rootURL = config.rootURL; + } + + Router.map(function () { + this.route('slow'); + this.route('broken'); + }); + `, + services: { + // RSVP.defer-as-a-service + 'gate.js': ` + import Service from '@ember/service'; + + export default class GateService extends Service { + _resolve = null; + + hold() { + return new Promise((resolve) => { + this._resolve = resolve; + }); + } + + release() { + if (this._resolve) { + this._resolve(); + this._resolve = null; + } + } + } + `, + }, + routes: { + 'index.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { + model() { + return { welcome: 'Welcome to the strict app' }; + } + } + `, + 'slow.js': ` + import Route from '@ember/routing/route'; + import { service } from '@ember/service'; + + export default class extends Route { + @service gate; + + async model() { + await this.gate.hold(); + return { message: 'Slow route ready' }; + } + } + `, + 'broken.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { + model() { + return Promise.reject(new Error('intentional model failure')); + } + } + `, + }, + templates: { + 'application.hbs': ` +
+ {{outlet}} +
+ `, + 'index.hbs': ` +
{{@model.welcome}}
+ `, + 'slow.hbs': ` +
{{@model.message}}
+ `, + 'slow-loading.hbs': ` +
Loading slow route...
+ `, + 'broken-error.hbs': ` +
Caught error: {{@model.message}}
+ `, + }, + }, + tests: { + acceptance: { + 'strict-resolver-substates-test.js': ` + import { module, test } from 'qunit'; + import { visit, currentURL, waitUntil } from '@ember/test-helpers'; + import { setupApplicationTest } from 'v2-app-template/tests/helpers'; + + module('Acceptance | strict resolver substates', function (hooks) { + setupApplicationTest(hooks); + + test('visiting / renders the index template', async function (assert) { + await visit('/'); + assert.strictEqual(currentURL(), '/'); + assert.dom('[data-test="app-shell"]').exists(); + assert.dom('[data-test="index-welcome"]').hasText( + 'Welcome to the strict app' + ); + }); + + test('visiting /slow shows the loading substate while the model is pending', async function (assert) { + let gate = this.owner.lookup('service:gate'); + + // Don't await — the model is blocked on gate.release(), + // so awaiting visit directly would hang. + let visitPromise = visit('/slow'); + + // Poll the DOM (doesn't depend on settled) until the + // loading substate appears. If the resolver can't find + // ./templates/slow-loading.hbs this will time out. + await waitUntil( + () => document.querySelector('[data-test="slow-loading"]'), + { timeout: 2000 } + ); + + assert.dom('[data-test="slow-loading"]').exists( + 'loading substate template is rendered' + ); + assert.dom('[data-test="slow-ready"]').doesNotExist( + 'main template is not yet rendered' + ); + + gate.release(); + await visitPromise; + + assert.strictEqual(currentURL(), '/slow'); + assert.dom('[data-test="slow-ready"]').hasText( + 'Slow route ready' + ); + assert.dom('[data-test="slow-loading"]').doesNotExist( + 'loading substate is replaced once the model resolves' + ); + }); + + test('visiting /broken shows the error substate when the model rejects', async function (assert) { + await visit('/broken'); + + assert.dom('[data-test="broken-error"]').exists( + 'error substate template is rendered' + ); + assert.dom('[data-test="broken-error"]').hasText( + 'Caught error: intentional model failure' + ); + }); + }); + `, + }, + }, + }); + }) + .forEachScenario((scenario) => { + Qmodule(scenario.name, function (hooks) { + let app: PreparedApp; + hooks.before(async () => { + app = await scenario.prepare(); + }); + + test(`ember test`, async function (assert) { + let result = await app.execute(`pnpm test`); + assert.equal(result.exitCode, 0, result.output); + }); + }); + }); diff --git a/smoke-tests/scenarios/strict-resolver-test.ts b/smoke-tests/scenarios/strict-resolver-test.ts new file mode 100644 index 00000000000..b086e53c108 --- /dev/null +++ b/smoke-tests/scenarios/strict-resolver-test.ts @@ -0,0 +1,263 @@ +import { strictAppScenarios } from './scenarios'; +import type { PreparedApp } from 'scenario-tester'; +import * as QUnit from 'qunit'; +const { module: Qmodule, test } = QUnit; + +strictAppScenarios + .map('strict-resolver', (project) => { + project.mergeFiles({ + app: { + 'app.js': ` + import Application from '@ember/application'; + import Router from './router'; + import config from 'v2-app-template/config/environment'; + + export default class App extends Application { + + modules = { + './router': { default: Router }, + ...import.meta.glob('./services/**/*.{js,ts}', { eager: true }), + ...import.meta.glob('./controllers/**/*.{js,ts}', { eager: true }), + ...import.meta.glob('./routes/**/*.{js,ts}', { eager: true }), + ...import.meta.glob('./templates/**/*.hbs', { eager: true }), + }; + } + `, + 'router.js': ` + import EmberRouter from '@embroider/router'; + import config from 'v2-app-template/config/environment'; + + export default class Router extends EmberRouter { + location = config.locationType; + rootURL = config.rootURL; + } + + Router.map(function () { + this.route('posts', function () { + this.route('show', { path: '/:post_id' }, function () { + this.route('comments'); + }); + }); + }); + `, + services: { + 'greeter.js': ` + import Service from '@ember/service'; + + export default class GreeterService extends Service { + greeting = 'Hello from strict resolver!'; + } + `, + }, + routes: { + 'index.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { + model() { + return { welcome: 'Welcome to the strict app' }; + } + } + `, + 'posts.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { + model() { + return [ + { id: 1, title: 'First Post' }, + { id: 2, title: 'Second Post' }, + ]; + } + } + `, + 'posts': { + 'show.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { + model(params) { + return { id: params.post_id, title: 'Post ' + params.post_id }; + } + } + `, + 'show': { + 'comments.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { + model() { + return ['great post!', 'meh']; + } + } + `, + }, + }, + }, + controllers: { + 'application.js': ` + import Controller from '@ember/controller'; + import { service } from '@ember/service'; + + export default class extends Controller { + @service greeter; + } + `, + }, + components: { + 'site-header.gjs': ` + + `, + 'post-card.gjs': ` + import Component from '@glimmer/component'; + + export default class PostCard extends Component { + + } + `, + }, + templates: { + 'application.hbs': ` +
{{this.greeter.greeting}}
+ + {{outlet}} + `, + 'index.hbs': ` +
{{@model.welcome}}
+ `, + 'posts.hbs': ` +
+ {{#each @model as |post|}} + + {{/each}} +
+ {{outlet}} + `, + 'posts': { + 'index.hbs': ` +
posts index page
+ `, + 'show.hbs': ` +
{{@model.title}}
+ {{outlet}} + `, + 'show': { + 'comments.hbs': ` +
    + {{#each @model as |c|}}
  • {{c}}
  • {{/each}} +
+ `, + }, + }, + }, + }, + tests: { + acceptance: { + 'strict-resolver-test.js': ` + import { module, test } from 'qunit'; + import { visit, currentURL } from '@ember/test-helpers'; + import { setupApplicationTest } from 'v2-app-template/tests/helpers'; + + module('Acceptance | strict resolver', function (hooks) { + setupApplicationTest(hooks); + + test('index route renders with model', async function (assert) { + await visit('/'); + assert.strictEqual(currentURL(), '/'); + assert.dom('[data-test="index-welcome"]').hasText('Welcome to the strict app'); + }); + + test('application template renders with service injection', async function (assert) { + await visit('/'); + assert.dom('[data-test="app-greeting"]').hasText('Hello from strict resolver!'); + }); + + test('template-only gjs component resolves from modules', async function (assert) { + await visit('/'); + assert.dom('[data-test="site-header"]').exists(); + assert.dom('[data-test="site-header"] h1').hasText('Strict App'); + }); + + test('parent route with auto-generated index renders both templates', async function (assert) { + await visit('/posts'); + assert.strictEqual(currentURL(), '/posts'); + assert.dom('[data-test="posts"]').exists('parent template rendered'); + assert.dom('[data-test="post-card"]').exists({ count: 2 }); + assert.dom('[data-test="post-card"] h2').exists( + 'PostCard gjs template renders inside the nested route' + ); + assert.dom('[data-test="posts-index"]').exists( + 'posts.index template rendered inside the parent outlet' + ); + }); + + test('dynamic nested child renders alongside its parent template', async function (assert) { + await visit('/posts/42'); + assert.strictEqual(currentURL(), '/posts/42'); + assert.dom('[data-test="posts"]').exists('parent template still rendered'); + assert.dom('[data-test="post-card"]').exists({ count: 2 }); + assert.dom('[data-test="post-detail"]').hasText('Post 42'); + }); + + test('three-level nested route resolves every level', async function (assert) { + await visit('/posts/42/comments'); + assert.strictEqual(currentURL(), '/posts/42/comments'); + assert.dom('[data-test="posts"]').exists('level 1: posts.hbs'); + assert.dom('[data-test="post-detail"]').hasText( + 'Post 42', + 'level 2: posts/show.hbs' + ); + assert.dom('[data-test="post-comments"] li').exists({ count: 2 }, + 'level 3: posts/show/comments.hbs' + ); + }); + }); + `, + }, + unit: { + 'strict-resolver-unit-test.js': ` + import { module, test } from 'qunit'; + import { setupTest } from 'v2-app-template/tests/helpers'; + + module('Unit | strict resolver', function (hooks) { + setupTest(hooks); + + test('can lookup a service registered via modules', function (assert) { + let greeter = this.owner.lookup('service:greeter'); + assert.ok(greeter, 'service was found'); + assert.strictEqual(greeter.greeting, 'Hello from strict resolver!'); + }); + + test('can register and lookup custom factories', function (assert) { + class Foo { + static create() { return new this(); } + value = 42; + } + + this.owner.register('custom:foo', Foo); + let foo = this.owner.lookup('custom:foo'); + assert.strictEqual(foo.value, 42); + }); + }); + `, + }, + }, + }); + }) + .forEachScenario((scenario) => { + Qmodule(scenario.name, function (hooks) { + let app: PreparedApp; + hooks.before(async () => { + app = await scenario.prepare(); + }); + + test(`ember test`, async function (assert) { + let result = await app.execute(`pnpm test`); + assert.equal(result.exitCode, 0, result.output); + }); + }); + }); diff --git a/tests/docs/expected.js b/tests/docs/expected.js index c6e4d990223..66f8353a2cd 100644 --- a/tests/docs/expected.js +++ b/tests/docs/expected.js @@ -329,6 +329,7 @@ module.exports = { 'model', 'modelFor', 'modifier', + 'modules', 'mount', 'mut', 'name', @@ -365,6 +366,7 @@ module.exports = { 'parent', 'parentView', 'parentViewDidChange', + 'plurals', 'popObject', 'positionalParams', 'promise',