From b4319ca9d8e736e7a56e4e3ade6558852fd1ee90 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:53:00 -0400 Subject: [PATCH 01/25] Phase 1: copy content from ember-resolver so we can more easily assess inflection pairity --- packages/@ember/engine/lib/strict-resolver.ts | 488 ++++++++++++++ .../engine/lib/strict-resolver/cache.js | 35 + .../lib/strict-resolver/class-factory.js | 11 + .../engine/lib/strict-resolver/string.js | 132 ++++ .../engine/tests/resolver/-setup-resolver.js | 34 + .../engine/tests/resolver/basic-test.js | 628 ++++++++++++++++++ .../engine/tests/resolver/classify_test.js | 43 ++ .../tests/resolver/create-test-function.js | 9 + .../tests/resolver/custom-prefixes-test.js | 25 + .../engine/tests/resolver/dasherize_test.js | 28 + .../engine/tests/resolver/decamelize_test.js | 27 + .../@ember/engine/tests/resolver/pods-test.js | 218 ++++++ .../engine/tests/resolver/underscore_test.js | 27 + .../engine/tests/resolver/with-modues-test.js | 45 ++ 14 files changed, 1750 insertions(+) create mode 100644 packages/@ember/engine/lib/strict-resolver.ts create mode 100644 packages/@ember/engine/lib/strict-resolver/cache.js create mode 100644 packages/@ember/engine/lib/strict-resolver/class-factory.js create mode 100644 packages/@ember/engine/lib/strict-resolver/string.js create mode 100644 packages/@ember/engine/tests/resolver/-setup-resolver.js create mode 100644 packages/@ember/engine/tests/resolver/basic-test.js create mode 100644 packages/@ember/engine/tests/resolver/classify_test.js create mode 100644 packages/@ember/engine/tests/resolver/create-test-function.js create mode 100644 packages/@ember/engine/tests/resolver/custom-prefixes-test.js create mode 100644 packages/@ember/engine/tests/resolver/dasherize_test.js create mode 100644 packages/@ember/engine/tests/resolver/decamelize_test.js create mode 100644 packages/@ember/engine/tests/resolver/pods-test.js create mode 100644 packages/@ember/engine/tests/resolver/underscore_test.js create mode 100644 packages/@ember/engine/tests/resolver/with-modues-test.js diff --git a/packages/@ember/engine/lib/strict-resolver.ts b/packages/@ember/engine/lib/strict-resolver.ts new file mode 100644 index 00000000000..e68ddb45834 --- /dev/null +++ b/packages/@ember/engine/lib/strict-resolver.ts @@ -0,0 +1,488 @@ +import { dasherize, classify, underscore } from './strict-resolver/string'; +import classFactory from './strict-resolver/class-factory'; + +export class ModuleRegistry { + constructor(entries) { + this._entries = entries || globalThis.requirejs.entries; + } + moduleNames() { + return Object.keys(this._entries); + } + has(moduleName) { + return moduleName in this._entries; + } + get(...args) { + return globalThis.require(...args); + } +} + +/** + * This module defines a subclass of Ember.DefaultResolver that adds two + * important features: + * + * 1) The resolver makes the container aware of es6 modules via the AMD + * output. The loader's _moduleEntries is consulted so that classes can be + * resolved directly via the module loader, without needing a manual + * `import`. + * 2) is able to provide injections to classes that implement `extend` + * (as is typical with Ember). + */ +export default class Resolver { + static moduleBasedResolver = true; + moduleBasedResolver = true; + + _deprecatedPodModulePrefix = false; + _normalizeCache = Object.create(null); + + static create(props) { + return new this(props); + } + + /** + A listing of functions to test for moduleName's based on the provided + `parsedName`. This allows easy customization of additional module based + lookup patterns. + + @property moduleNameLookupPatterns + @returns {Ember.Array} + */ + moduleNameLookupPatterns = [ + this.podBasedModuleName, + this.podBasedComponentsInSubdir, + this.mainModuleName, + this.defaultModuleName, + this.nestedColocationComponentModuleName, + ]; + + static withModules(explicitModules) { + return class extends this { + static explicitModules = explicitModules; + }; + } + + constructor(props) { + Object.assign(this, props); + if (!this._moduleRegistry) { + let explicitModules = this.constructor.explicitModules; + if (explicitModules) { + this._moduleRegistry = { + moduleNames() { + return Object.keys(explicitModules); + }, + has(name) { + return Boolean(explicitModules[name]); + }, + get(name) { + return explicitModules[name]; + }, + addModules(modules) { + explicitModules = Object.assign({}, explicitModules, modules); + }, + }; + } else { + if (typeof globalThis.requirejs.entries === 'undefined') { + globalThis.requirejs.entries = globalThis.requirejs._eak_seen; + } + this._moduleRegistry = new ModuleRegistry(); + } + } + + this.pluralizedTypes = this.pluralizedTypes || Object.create(null); + + if (!this.pluralizedTypes.config) { + this.pluralizedTypes.config = 'config'; + } + } + + makeToString(factory, fullName) { + return '' + this.namespace.modulePrefix + '@' + fullName + ':'; + } + + shouldWrapInClassFactory(/* module, parsedName */) { + return false; + } + + parseName(fullName) { + if (fullName.parsedName === true) { + return fullName; + } + + let prefix, type, name; + let fullNameParts = fullName.split('@'); + + if (fullNameParts.length === 3) { + if (fullNameParts[0].length === 0) { + // leading scoped namespace: `@scope/pkg@type:name` + prefix = `@${fullNameParts[1]}`; + let prefixParts = fullNameParts[2].split(':'); + type = prefixParts[0]; + name = prefixParts[1]; + } else { + // interweaved scoped namespace: `type:@scope/pkg@name` + prefix = `@${fullNameParts[1]}`; + type = fullNameParts[0].slice(0, -1); + name = fullNameParts[2]; + } + + if (type === 'template:components') { + name = `components/${name}`; + type = 'template'; + } + } else if (fullNameParts.length === 2) { + let prefixParts = fullNameParts[0].split(':'); + + if (prefixParts.length === 2) { + if (prefixParts[1].length === 0) { + type = prefixParts[0]; + name = `@${fullNameParts[1]}`; + } else { + prefix = prefixParts[1]; + type = prefixParts[0]; + name = fullNameParts[1]; + } + } else { + let nameParts = fullNameParts[1].split(':'); + + prefix = fullNameParts[0]; + type = nameParts[0]; + name = nameParts[1]; + } + + if (type === 'template' && prefix.lastIndexOf('components/', 0) === 0) { + name = `components/${name}`; + prefix = prefix.slice(11); + } + } else { + fullNameParts = fullName.split(':'); + type = fullNameParts[0]; + name = fullNameParts[1]; + } + + let fullNameWithoutType = name; + let namespace = this.namespace; + let root = namespace; + + return { + parsedName: true, + fullName: fullName, + prefix: prefix || this.prefix({ type: type }), + type: type, + fullNameWithoutType: fullNameWithoutType, + name: name, + root: root, + resolveMethodName: 'resolve' + classify(type), + }; + } + + resolveOther(parsedName) { + assert('`modulePrefix` must be defined', this.namespace.modulePrefix); + + let normalizedModuleName = this.findModuleName(parsedName); + + if (normalizedModuleName) { + let defaultExport = this._extractDefaultExport( + normalizedModuleName, + parsedName + ); + + if (defaultExport === undefined) { + throw new Error( + ` Expected to find: '${parsedName.fullName}' within '${normalizedModuleName}' but got 'undefined'. Did you forget to 'export default' within '${normalizedModuleName}'?` + ); + } + + if (this.shouldWrapInClassFactory(defaultExport, parsedName)) { + defaultExport = classFactory(defaultExport); + } + + return defaultExport; + } + } + + normalize(fullName) { + return ( + this._normalizeCache[fullName] || + (this._normalizeCache[fullName] = this._normalize(fullName)) + ); + } + + resolve(fullName) { + if (fullName === 'resolver:current') { + return { create: () => this }; + } + let parsedName = this.parseName(fullName); + let resolveMethodName = parsedName.resolveMethodName; + let resolved; + + if (typeof this[resolveMethodName] === 'function') { + resolved = this[resolveMethodName](parsedName); + } + + if (resolved == null) { + resolved = this.resolveOther(parsedName); + } + + return resolved; + } + + addModules(modules) { + if (!this._moduleRegistry.addModules) { + throw new Error( + `addModules is only supported when your Resolver has been configured to use static modules via Resolver.withModules()` + ); + } + this._moduleRegistry.addModules(modules); + } + + _normalize(fullName) { + // A) Convert underscores to dashes + // B) Convert camelCase to dash-case, except for components (their + // templates) and helpers where we want to avoid shadowing camelCase + // expressions + // C) replace `.` with `/` in order to make nested controllers work in the following cases + // 1. `needs: ['posts/post']` + // 2. `{{render "posts/post"}}` + // 3. `this.render('posts/post')` from Route + + let split = fullName.split(':'); + if (split.length > 1) { + let type = split[0]; + + if ( + type === 'component' || + type === 'helper' || + type === 'modifier' || + (type === 'template' && split[1].indexOf('components/') === 0) + ) { + return type + ':' + split[1].replace(/_/g, '-'); + } else { + return type + ':' + dasherize(split[1].replace(/\./g, '/')); + } + } else { + return fullName; + } + } + + pluralize(type) { + return ( + this.pluralizedTypes[type] || (this.pluralizedTypes[type] = type + 's') + ); + } + + podBasedLookupWithPrefix(podPrefix, parsedName) { + let fullNameWithoutType = parsedName.fullNameWithoutType; + + if (parsedName.type === 'template') { + fullNameWithoutType = fullNameWithoutType.replace(/^components\//, ''); + } + + return podPrefix + '/' + fullNameWithoutType + '/' + parsedName.type; + } + + podBasedModuleName(parsedName) { + let podPrefix = + this.namespace.podModulePrefix || this.namespace.modulePrefix; + + return this.podBasedLookupWithPrefix(podPrefix, parsedName); + } + + podBasedComponentsInSubdir(parsedName) { + let podPrefix = + this.namespace.podModulePrefix || this.namespace.modulePrefix; + podPrefix = podPrefix + '/components'; + + if ( + parsedName.type === 'component' || + /^components/.test(parsedName.fullNameWithoutType) + ) { + return this.podBasedLookupWithPrefix(podPrefix, parsedName); + } + } + + resolveEngine(parsedName) { + let engineName = parsedName.fullNameWithoutType; + let engineModule = engineName + '/engine'; + + if (this._moduleRegistry.has(engineModule)) { + return this._extractDefaultExport(engineModule); + } + } + + resolveRouteMap(parsedName) { + let engineName = parsedName.fullNameWithoutType; + let engineRoutesModule = engineName + '/routes'; + + if (this._moduleRegistry.has(engineRoutesModule)) { + let routeMap = this._extractDefaultExport(engineRoutesModule); + + assert( + `The route map for ${engineName} should be wrapped by 'buildRoutes' before exporting.`, + routeMap.isRouteMap + ); + + return routeMap; + } + } + + resolveTemplate(parsedName) { + return this.resolveOther(parsedName); + } + + mainModuleName(parsedName) { + if (parsedName.fullNameWithoutType === 'main') { + // if router:main or adapter:main look for a module with just the type first + return parsedName.prefix + '/' + parsedName.type; + } + } + + defaultModuleName(parsedName) { + return ( + parsedName.prefix + + '/' + + this.pluralize(parsedName.type) + + '/' + + parsedName.fullNameWithoutType + ); + } + + nestedColocationComponentModuleName(parsedName) { + if (parsedName.type === 'component') { + return ( + parsedName.prefix + + '/' + + this.pluralize(parsedName.type) + + '/' + + parsedName.fullNameWithoutType + + '/index' + ); + } + } + + prefix(parsedName) { + let tmpPrefix = this.namespace.modulePrefix; + + if (this.namespace[parsedName.type + 'Prefix']) { + tmpPrefix = this.namespace[parsedName.type + 'Prefix']; + } + + return tmpPrefix; + } + + findModuleName(parsedName) { + let moduleNameLookupPatterns = this.moduleNameLookupPatterns; + let moduleName; + + for ( + let index = 0, length = moduleNameLookupPatterns.length; + index < length; + index++ + ) { + let item = moduleNameLookupPatterns[index]; + + let tmpModuleName = item.call(this, parsedName); + + // allow treat all dashed and all underscored as the same thing + // supports components with dashes and other stuff with underscores. + if (tmpModuleName) { + tmpModuleName = this.chooseModuleName(tmpModuleName); + } + + if (tmpModuleName && this._moduleRegistry.has(tmpModuleName)) { + moduleName = tmpModuleName; + } + + if (moduleName) { + return moduleName; + } + } + } + + chooseModuleName(moduleName) { + let underscoredModuleName = underscore(moduleName); + + if ( + moduleName !== underscoredModuleName && + this._moduleRegistry.has(moduleName) && + this._moduleRegistry.has(underscoredModuleName) + ) { + throw new TypeError( + `Ambiguous module names: '${moduleName}' and '${underscoredModuleName}'` + ); + } + + if (this._moduleRegistry.has(moduleName)) { + return moduleName; + } else if (this._moduleRegistry.has(underscoredModuleName)) { + return underscoredModuleName; + } + } + + knownForType(type) { + let moduleKeys = this._moduleRegistry.moduleNames(); + + let items = Object.create(null); + for (let index = 0, length = moduleKeys.length; index < length; index++) { + let moduleName = moduleKeys[index]; + let fullname = this.translateToContainerFullname(type, moduleName); + + if (fullname) { + items[fullname] = true; + } + } + + return items; + } + + translateToContainerFullname(type, moduleName) { + let prefix = this.prefix({ type }); + + // Note: using string manipulation here rather than regexes for better performance. + // pod modules + // '^' + prefix + '/(.+)/' + type + '$' + let podPrefix = prefix + '/'; + let podSuffix = '/' + type; + let start = moduleName.indexOf(podPrefix); + let end = moduleName.indexOf(podSuffix); + + if ( + start === 0 && + end === moduleName.length - podSuffix.length && + moduleName.length > podPrefix.length + podSuffix.length + ) { + return type + ':' + moduleName.slice(start + podPrefix.length, end); + } + + // non-pod modules + // '^' + prefix + '/' + pluralizedType + '/(.+)$' + let pluralizedType = this.pluralize(type); + let nonPodPrefix = prefix + '/' + pluralizedType + '/'; + + if ( + moduleName.indexOf(nonPodPrefix) === 0 && + moduleName.length > nonPodPrefix.length + ) { + return type + ':' + moduleName.slice(nonPodPrefix.length); + } + } + + _extractDefaultExport(normalizedModuleName) { + let module = this._moduleRegistry.get( + normalizedModuleName, + null, + null, + true /* force sync */ + ); + + if (module && module['default']) { + module = module['default']; + } + + return module; + } +} + +function assert(message, check) { + if (!check) { + throw new Error(message); + } +} diff --git a/packages/@ember/engine/lib/strict-resolver/cache.js b/packages/@ember/engine/lib/strict-resolver/cache.js new file mode 100644 index 00000000000..68902ada388 --- /dev/null +++ b/packages/@ember/engine/lib/strict-resolver/cache.js @@ -0,0 +1,35 @@ +export default class Cache { + constructor(limit, func, store) { + this.limit = limit; + this.func = func; + this.store = store; + this.size = 0; + this.misses = 0; + this.hits = 0; + this.store = store || new Map(); + } + get(key) { + let value = this.store.get(key); + if (this.store.has(key)) { + this.hits++; + return this.store.get(key); + } else { + this.misses++; + value = this.set(key, this.func(key)); + } + return value; + } + set(key, value) { + if (this.limit > this.size) { + this.size++; + this.store.set(key, value); + } + return value; + } + purge() { + this.store.clear(); + this.size = 0; + this.hits = 0; + this.misses = 0; + } +} diff --git a/packages/@ember/engine/lib/strict-resolver/class-factory.js b/packages/@ember/engine/lib/strict-resolver/class-factory.js new file mode 100644 index 00000000000..4fc4768df25 --- /dev/null +++ b/packages/@ember/engine/lib/strict-resolver/class-factory.js @@ -0,0 +1,11 @@ +export default function classFactory(klass) { + return { + create(injections) { + if (typeof klass.extend === 'function') { + return klass.extend(injections); + } else { + return klass; + } + }, + }; +} diff --git a/packages/@ember/engine/lib/strict-resolver/string.js b/packages/@ember/engine/lib/strict-resolver/string.js new file mode 100644 index 00000000000..9773829941d --- /dev/null +++ b/packages/@ember/engine/lib/strict-resolver/string.js @@ -0,0 +1,132 @@ +/* eslint-disable no-useless-escape */ +import Cache from './cache'; +let STRINGS = {}; +export function setStrings(strings) { + STRINGS = strings; +} +export function getStrings() { + return STRINGS; +} +export function getString(name) { + return STRINGS[name]; +} +const STRING_DASHERIZE_REGEXP = /[ _]/g; +const STRING_DASHERIZE_CACHE = new Cache(1000, (key) => + decamelize(key).replace(STRING_DASHERIZE_REGEXP, '-') +); +const STRING_CLASSIFY_REGEXP_1 = /^(\-|_)+(.)?/; +const STRING_CLASSIFY_REGEXP_2 = /(.)(\-|\_|\.|\s)+(.)?/g; +const STRING_CLASSIFY_REGEXP_3 = /(^|\/|\.)([a-z])/g; +const CLASSIFY_CACHE = new Cache(1000, (str) => { + const replace1 = (_match, _separator, chr) => + chr ? `_${chr.toUpperCase()}` : ''; + const replace2 = (_match, initialChar, _separator, chr) => + initialChar + (chr ? chr.toUpperCase() : ''); + const parts = str.split('/'); + for (let i = 0; i < parts.length; i++) { + parts[i] = parts[i] + .replace(STRING_CLASSIFY_REGEXP_1, replace1) + .replace(STRING_CLASSIFY_REGEXP_2, replace2); + } + return parts + .join('/') + .replace(STRING_CLASSIFY_REGEXP_3, (match /*, separator, chr */) => + match.toUpperCase() + ); +}); +const STRING_UNDERSCORE_REGEXP_1 = /([a-z\d])([A-Z]+)/g; +const STRING_UNDERSCORE_REGEXP_2 = /\-|\s+/g; +const UNDERSCORE_CACHE = new Cache(1000, (str) => + str + .replace(STRING_UNDERSCORE_REGEXP_1, '$1_$2') + .replace(STRING_UNDERSCORE_REGEXP_2, '_') + .toLowerCase() +); +const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; +const DECAMELIZE_CACHE = new Cache(1000, (str) => + str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase() +); +/** + Converts a camelized string into all lower case separated by underscores. + + ```javascript + import { decamelize } from '@ember/string'; + + decamelize('innerHTML'); // 'inner_html' + decamelize('action_name'); // 'action_name' + decamelize('css-class-name'); // 'css-class-name' + decamelize('my favorite items'); // 'my favorite items' + ``` + + @method decamelize + @param {String} str The string to decamelize. + @return {String} the decamelized string. + @public +*/ +export function decamelize(str) { + return DECAMELIZE_CACHE.get(str); +} +/** + Replaces underscores, spaces, or camelCase with dashes. + + ```javascript + import { dasherize } from '@ember/string'; + + dasherize('innerHTML'); // 'inner-html' + dasherize('action_name'); // 'action-name' + dasherize('css-class-name'); // 'css-class-name' + dasherize('my favorite items'); // 'my-favorite-items' + dasherize('privateDocs/ownerInvoice'; // 'private-docs/owner-invoice' + ``` + + @method dasherize + @param {String} str The string to dasherize. + @return {String} the dasherized string. + @public +*/ +export function dasherize(str) { + return STRING_DASHERIZE_CACHE.get(str); +} +/** + Returns the UpperCamelCase form of a string. + + ```javascript + import { classify } from '@ember/string'; + + classify('innerHTML'); // 'InnerHTML' + classify('action_name'); // 'ActionName' + classify('css-class-name'); // 'CssClassName' + classify('my favorite items'); // 'MyFavoriteItems' + classify('private-docs/owner-invoice'); // 'PrivateDocs/OwnerInvoice' + ``` + + @method classify + @param {String} str the string to classify + @return {String} the classified string + @public +*/ +export function classify(str) { + return CLASSIFY_CACHE.get(str); +} +/** + More general than decamelize. Returns the lower\_case\_and\_underscored + form of a string. + + ```javascript + import { underscore } from '@ember/string'; + + underscore('innerHTML'); // 'inner_html' + underscore('action_name'); // 'action_name' + underscore('css-class-name'); // 'css_class_name' + underscore('my favorite items'); // 'my_favorite_items' + underscore('privateDocs/ownerInvoice'); // 'private_docs/owner_invoice' + ``` + + @method underscore + @param {String} str The string to underscore. + @return {String} the underscored string. + @public +*/ +export function underscore(str) { + return UNDERSCORE_CACHE.get(str); +} diff --git a/packages/@ember/engine/tests/resolver/-setup-resolver.js b/packages/@ember/engine/tests/resolver/-setup-resolver.js new file mode 100644 index 00000000000..f1b8808fa94 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/-setup-resolver.js @@ -0,0 +1,34 @@ +import Resolver, { ModuleRegistry } from 'ember-resolver'; + +import { setOwner } from '@ember/owner'; + +export let resolver; +export let loader; + +export function setupResolver(options = {}) { + let owner = options.owner; + delete options.owner; + + if (!options.namespace) { + options.namespace = { modulePrefix: 'appkit' }; + } + loader = { + entries: Object.create(null), + define(id, deps, callback) { + if (deps.length > 0) { + throw new Error('Test Module loader does not support dependencies'); + } + this.entries[id] = callback; + }, + }; + options._moduleRegistry = new ModuleRegistry(loader.entries); + options._moduleRegistry.get = function (moduleName) { + return loader.entries[moduleName](); + }; + + resolver = Resolver.create(options); + + if (owner) { + setOwner(resolver, owner); + } +} 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..858864d6a4a --- /dev/null +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -0,0 +1,628 @@ +/* eslint-disable no-console */ + +import { module, test } from 'qunit'; +import { setupResolver, resolver, loader } from './-setup-resolver'; + +let originalConsoleInfo; + +module('ember-resolver/resolvers/classic', function (hooks) { + hooks.beforeEach(function () { + setupResolver(); + }); + + hooks.afterEach(function () { + if (originalConsoleInfo) { + console.info = originalConsoleInfo; + } + }); + + // ember @ 3.3 breaks this: https://github.com/emberjs/ember.js/commit/b8613c20289cc8a730e181c4c51ecfc4b6836052#r29790209 + // ember @ 3.4.0-beta.1 restores this: https://github.com/emberjs/ember.js/commit/ddd8d9b9d9f6d315185a34802618a666bb3aeaac + // test('does not require `namespace` to exist at `init` time', function(assert) { + // assert.expect(0); + + // Resolver.create({ namespace: '' }); + // }); + + test('can lookup something', function (assert) { + assert.expect(2); + + loader.define('appkit/adapters/post', [], function () { + assert.ok(true, 'adapter was invoked properly'); + + return {}; + }); + + var adapter = resolver.resolve('adapter:post'); + + assert.ok(adapter, 'adapter was returned'); + }); + + test('can lookup something in another namespace', function (assert) { + assert.expect(3); + + let expected = {}; + + loader.define('other/adapters/post', [], function () { + assert.ok(true, 'adapter was invoked properly'); + + return { + default: expected, + }; + }); + + var adapter = resolver.resolve('other@adapter:post'); + + assert.ok(adapter, 'adapter was returned'); + assert.equal(adapter, expected, 'default export was returned'); + }); + + test('can lookup something in another namespace with an @ scope', function (assert) { + assert.expect(3); + + let expected = {}; + + loader.define('@scope/other/adapters/post', [], function () { + assert.ok(true, 'adapter was invoked properly'); + + return { + default: expected, + }; + }); + + var adapter = resolver.resolve('@scope/other@adapter:post'); + + assert.ok(adapter, 'adapter was returned'); + assert.equal(adapter, expected, 'default export was returned'); + }); + + test('can lookup something with an @ sign', function (assert) { + assert.expect(3); + + let expected = {}; + loader.define('appkit/helpers/@content-helper', [], function () { + assert.ok(true, 'helper was invoked properly'); + + return { default: expected }; + }); + + var helper = resolver.resolve('helper:@content-helper'); + + assert.ok(helper, 'helper was returned'); + assert.equal(helper, expected, 'default export was returned'); + }); + + test('can lookup something in another namespace with different syntax', function (assert) { + assert.expect(3); + + let expected = {}; + loader.define('other/adapters/post', [], function () { + assert.ok(true, 'adapter was invoked properly'); + + return { default: expected }; + }); + + var adapter = resolver.resolve('adapter:other@post'); + + assert.ok(adapter, 'adapter was returned'); + assert.equal(adapter, expected, 'default export was returned'); + }); + + test('can lookup something in another namespace with an @ scope with different syntax', function (assert) { + assert.expect(3); + + let expected = {}; + loader.define('@scope/other/adapters/post', [], function () { + assert.ok(true, 'adapter was invoked properly'); + + return { default: expected }; + }); + + var adapter = resolver.resolve('adapter:@scope/other@post'); + + assert.ok(adapter, 'adapter was returned'); + assert.equal(adapter, expected, 'default export was returned'); + }); + + test('can lookup a view in another namespace', function (assert) { + assert.expect(3); + + let expected = { isViewFactory: true }; + loader.define('other/views/post', [], function () { + assert.ok(true, 'view was invoked properly'); + + return { default: expected }; + }); + + var view = resolver.resolve('other@view:post'); + + assert.ok(view, 'view was returned'); + assert.equal(view, expected, 'default export was returned'); + }); + + test('can lookup a view in another namespace with an @ scope', function (assert) { + assert.expect(3); + + let expected = { isViewFactory: true }; + loader.define('@scope/other/views/post', [], function () { + assert.ok(true, 'view was invoked properly'); + + return { default: expected }; + }); + + var view = resolver.resolve('@scope/other@view:post'); + + assert.ok(view, 'view was returned'); + assert.equal(view, expected, 'default export was returned'); + }); + + test('can lookup a view in another namespace with different syntax', function (assert) { + assert.expect(3); + + let expected = { isViewFactory: true }; + loader.define('other/views/post', [], function () { + assert.ok(true, 'view was invoked properly'); + + return { default: expected }; + }); + + var view = resolver.resolve('view:other@post'); + + assert.ok(view, 'view was returned'); + assert.equal(view, expected, 'default export was returned'); + }); + + test('can lookup a view in another namespace with an @ scope with different syntax', function (assert) { + assert.expect(3); + + let expected = { isViewFactory: true }; + loader.define('@scope/other/views/post', [], function () { + assert.ok(true, 'view was invoked properly'); + + return { default: expected }; + }); + + var view = resolver.resolve('view:@scope/other@post'); + + assert.ok(view, 'view was returned'); + assert.equal(view, expected, 'default export was returned'); + }); + + test('can lookup a component template in another namespace with different syntax', function (assert) { + assert.expect(2); + + let expected = { isTemplate: true }; + loader.define('other/templates/components/foo-bar', [], function () { + assert.ok(true, 'template was looked up properly'); + + return { default: expected }; + }); + + var template = resolver.resolve('template:components/other@foo-bar'); + + assert.equal(template, expected, 'default export was returned'); + }); + + test('can lookup a component template in another namespace with an @ scope with different syntax', function (assert) { + assert.expect(2); + + let expected = { isTemplate: true }; + loader.define('@scope/other/templates/components/foo-bar', [], function () { + assert.ok(true, 'template was looked up properly'); + + return { default: expected }; + }); + + var template = resolver.resolve('template:components/@scope/other@foo-bar'); + + assert.equal(template, expected, 'default export was returned'); + }); + + test('can lookup a view', function (assert) { + assert.expect(3); + + let expected = { isViewFactory: true }; + loader.define('appkit/views/queue-list', [], function () { + assert.ok(true, 'view was invoked properly'); + + return { default: expected }; + }); + + var view = resolver.resolve('view:queue-list'); + + assert.ok(view, 'view was returned'); + assert.equal(view, expected, 'default export was returned'); + }); + + test('can lookup a helper', function (assert) { + assert.expect(3); + + let expected = { isHelperInstance: true }; + loader.define('appkit/helpers/reverse-list', [], function () { + assert.ok(true, 'helper was invoked properly'); + + return { default: expected }; + }); + + var helper = resolver.resolve('helper:reverse-list'); + + assert.ok(helper, 'helper was returned'); + assert.equal(helper, expected, 'default export was returned'); + }); + + test('can lookup an engine', function (assert) { + assert.expect(3); + + let expected = {}; + loader.define('appkit/engine', [], function () { + assert.ok(true, 'engine was invoked properly'); + + return { default: expected }; + }); + + let engine = resolver.resolve('engine:appkit'); + + assert.ok(engine, 'engine was returned'); + assert.equal(engine, expected, 'default export was returned'); + }); + + test('can lookup an engine from a scoped package', function (assert) { + assert.expect(3); + + let expected = {}; + loader.define('@some-scope/some-module/engine', [], function () { + assert.ok(true, 'engine was invoked properly'); + + return { default: expected }; + }); + + var engine = resolver.resolve('engine:@some-scope/some-module'); + + assert.ok(engine, 'engine was returned'); + assert.equal(engine, expected, 'default export was returned'); + }); + + test('can lookup a route-map', function (assert) { + assert.expect(3); + + let expected = { isRouteMap: true }; + loader.define('appkit/routes', [], function () { + assert.ok(true, 'route-map was invoked properly'); + + return { default: expected }; + }); + + let routeMap = resolver.resolve('route-map:appkit'); + + assert.ok(routeMap, 'route-map was returned'); + assert.equal(routeMap, expected, 'default export was returned'); + }); + + // the assert.expectWarning helper no longer works + test.skip('warns if looking up a camelCase helper that has a dasherized module present', function (assert) { + assert.expect(1); + + loader.define('appkit/helpers/reverse-list', [], function () { + return { default: { isHelperInstance: true } }; + }); + + var helper = resolver.resolve('helper:reverseList'); + + assert.ok(!helper, 'no helper was returned'); + // assert.expectWarning('Attempted to lookup "helper:reverseList" which was not found. In previous versions of ember-resolver, a bug would have caused the module at "appkit/helpers/reverse-list" to be returned for this camel case helper name. This has been fixed. Use the dasherized name to resolve the module that would have been returned in previous versions.'); + }); + + test('errors if lookup of a route-map does not specify isRouteMap', function (assert) { + assert.expect(2); + + let expected = { isRouteMap: false }; + loader.define('appkit/routes', [], function () { + assert.ok(true, 'route-map was invoked properly'); + + return { default: expected }; + }); + + assert.throws(() => { + resolver.resolve('route-map:appkit'); + }, /The route map for appkit should be wrapped by 'buildRoutes' before exporting/); + }); + + test("will return the raw value if no 'default' is available", function (assert) { + loader.define('appkit/fruits/orange', [], function () { + return 'is awesome'; + }); + + assert.equal( + resolver.resolve('fruit:orange'), + 'is awesome', + 'adapter was returned' + ); + }); + + test("will unwrap the 'default' export automatically", function (assert) { + loader.define('appkit/fruits/orange', [], function () { + return { default: 'is awesome' }; + }); + + assert.equal( + resolver.resolve('fruit:orange'), + 'is awesome', + 'adapter was returned' + ); + }); + + test('router:main is hard-coded to prefix/router.js', function (assert) { + assert.expect(1); + + loader.define('appkit/router', [], function () { + assert.ok(true, 'router:main was looked up'); + return 'whatever'; + }); + + resolver.resolve('router:main'); + }); + + test('store:main is looked up as prefix/store', function (assert) { + assert.expect(1); + + loader.define('appkit/store', [], function () { + assert.ok(true, 'store:main was looked up'); + return 'whatever'; + }); + + resolver.resolve('store:main'); + }); + + test('store:posts as prefix/stores/post', function (assert) { + assert.expect(1); + + loader.define('appkit/stores/post', [], function () { + assert.ok(true, 'store:post was looked up'); + return 'whatever'; + }); + + resolver.resolve('store:post'); + }); + + test('will raise error if both dasherized and underscored modules exist', function (assert) { + loader.define('appkit/big-bands/steve-miller-band', [], function () { + assert.ok(true, 'dasherized version looked up'); + return 'whatever'; + }); + + loader.define('appkit/big_bands/steve_miller_band', [], function () { + assert.ok(false, 'underscored version looked up'); + return 'whatever'; + }); + + try { + resolver.resolve('big-band:steve-miller-band'); + } catch (e) { + assert.equal( + e.message, + `Ambiguous module names: 'appkit/big-bands/steve-miller-band' and 'appkit/big_bands/steve_miller_band'`, + 'error with a descriptive value is thrown' + ); + } + }); + + test('will lookup an underscored version of the module name when the dasherized version is not found', function (assert) { + assert.expect(1); + + loader.define('appkit/big_bands/steve_miller_band', [], function () { + assert.ok(true, 'underscored version looked up properly'); + return 'whatever'; + }); + + resolver.resolve('big-band:steve-miller-band'); + }); + + test('it provides eachForType which invokes the callback for each item found', function (assert) { + function orange() {} + loader.define('appkit/fruits/orange', [], function () { + return { default: orange }; + }); + + function apple() {} + loader.define('appkit/fruits/apple', [], function () { + return { default: apple }; + }); + + function other() {} + loader.define('appkit/stuffs/other', [], function () { + return { default: other }; + }); + + var items = resolver.knownForType('fruit'); + + assert.deepEqual(items, { + 'fruit:orange': true, + 'fruit:apple': true, + }); + }); + + test('eachForType can find both pod and non-pod factories', function (assert) { + function orange() {} + loader.define('appkit/fruits/orange', [], function () { + return { default: orange }; + }); + + function lemon() {} + loader.define('appkit/lemon/fruit', [], function () { + return { default: lemon }; + }); + + var items = resolver.knownForType('fruit'); + + assert.deepEqual(items, { + 'fruit:orange': true, + 'fruit:lemon': true, + }); + }); + + test('if shouldWrapInClassFactory returns true a wrapped object is returned', function (assert) { + resolver.shouldWrapInClassFactory = function (defaultExport, parsedName) { + assert.equal(defaultExport, 'foo'); + assert.equal(parsedName.fullName, 'string:foo'); + + return true; + }; + + loader.define('appkit/strings/foo', [], function () { + return { default: 'foo' }; + }); + + var value = resolver.resolve('string:foo'); + + assert.equal(value.create(), 'foo'); + }); + + test('normalization', function (assert) { + assert.ok(resolver.normalize, 'resolver#normalize is present'); + + assert.equal(resolver.normalize('foo:bar'), 'foo:bar'); + + assert.equal(resolver.normalize('controller:posts'), 'controller:posts'); + assert.equal( + resolver.normalize('controller:posts_index'), + 'controller:posts-index' + ); + assert.equal( + resolver.normalize('controller:posts.index'), + 'controller:posts/index' + ); + assert.equal( + resolver.normalize('controller:posts-index'), + 'controller:posts-index' + ); + assert.equal( + resolver.normalize('controller:posts.post.index'), + 'controller:posts/post/index' + ); + assert.equal( + resolver.normalize('controller:posts_post.index'), + 'controller:posts-post/index' + ); + assert.equal( + resolver.normalize('controller:posts.post_index'), + 'controller:posts/post-index' + ); + assert.equal( + resolver.normalize('controller:posts.post-index'), + 'controller:posts/post-index' + ); + assert.equal( + resolver.normalize('controller:postsIndex'), + 'controller:posts-index' + ); + assert.equal( + resolver.normalize('controller:blogPosts.index'), + 'controller:blog-posts/index' + ); + assert.equal( + resolver.normalize('controller:blog/posts.index'), + 'controller:blog/posts/index' + ); + assert.equal( + resolver.normalize('controller:blog/posts-index'), + 'controller:blog/posts-index' + ); + assert.equal( + resolver.normalize('controller:blog/posts.post.index'), + 'controller:blog/posts/post/index' + ); + assert.equal( + resolver.normalize('controller:blog/posts_post.index'), + 'controller:blog/posts-post/index' + ); + assert.equal( + resolver.normalize('controller:blog/posts_post-index'), + 'controller:blog/posts-post-index' + ); + + assert.equal( + resolver.normalize('template:blog/posts_index'), + 'template:blog/posts-index' + ); + assert.equal(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.equal( + resolver.normalize('helper:make-fabulous'), + 'helper:make-fabulous' + ); + assert.equal(resolver.normalize('helper:fabulize'), 'helper:fabulize'); + assert.equal( + resolver.normalize('helper:make_fabulous'), + 'helper:make-fabulous' + ); + assert.equal( + resolver.normalize('helper:makeFabulous'), + 'helper:makeFabulous' + ); + + // The same applies to components + assert.equal( + resolver.normalize('component:fabulous-component'), + 'component:fabulous-component' + ); + assert.equal( + resolver.normalize('component:fabulousComponent'), + 'component:fabulousComponent' + ); + assert.equal( + resolver.normalize('template:components/fabulousComponent'), + 'template:components/fabulousComponent' + ); + + // and modifiers + assert.equal( + resolver.normalize('modifier:fabulous-component'), + 'modifier:fabulous-component' + ); + + // deprecated when fabulously-missing actually exists, but normalize still returns it + assert.equal( + resolver.normalize('modifier:fabulouslyMissing'), + 'modifier:fabulouslyMissing' + ); + }); + + test('camel case modifier is not normalized', function (assert) { + assert.expect(2); + + let expected = {}; + loader.define('appkit/modifiers/other-thing', [], function () { + assert.ok(false, 'appkit/modifiers/other-thing was accessed'); + + return { default: 'oh no' }; + }); + + loader.define('appkit/modifiers/otherThing', [], function () { + assert.ok(true, 'appkit/modifiers/otherThing was accessed'); + + return { default: expected }; + }); + + let modifier = resolver.resolve('modifier:otherThing'); + + assert.strictEqual(modifier, expected); + }); + + test('normalization is idempotent', function (assert) { + let examples = [ + 'controller:posts', + 'controller:posts.post.index', + 'controller:blog/posts.post_index', + 'template:foo_bar', + ]; + + examples.forEach((example) => { + assert.equal( + resolver.normalize(resolver.normalize(example)), + resolver.normalize(example) + ); + }); + }); +}); diff --git a/packages/@ember/engine/tests/resolver/classify_test.js b/packages/@ember/engine/tests/resolver/classify_test.js new file mode 100644 index 00000000000..7de6440c427 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/classify_test.js @@ -0,0 +1,43 @@ +import { module } from 'qunit'; +import { classify } from 'ember-resolver/string/index'; +import createTestFunction from './helpers/create-test-function'; + +module('classify', function () { + const test = createTestFunction(classify); + + test('my favorite items', 'MyFavoriteItems', 'classify normal string'); + test('css-class-name', 'CssClassName', 'classify dasherized string'); + test('action_name', 'ActionName', 'classify underscored string'); + test( + 'privateDocs/ownerInvoice', + 'PrivateDocs/OwnerInvoice', + 'classify namespaced camelized string' + ); + test( + 'private_docs/owner_invoice', + 'PrivateDocs/OwnerInvoice', + 'classify namespaced underscored string' + ); + test( + 'private-docs/owner-invoice', + 'PrivateDocs/OwnerInvoice', + 'classify namespaced dasherized string' + ); + test('-view-registry', '_ViewRegistry', 'classify prefixed dasherized string'); + test( + 'components/-text-field', + 'Components/_TextField', + 'classify namespaced prefixed dasherized string' + ); + test('_Foo_Bar', '_FooBar', 'classify underscore-prefixed underscored string'); + test('_Foo-Bar', '_FooBar', 'classify underscore-prefixed dasherized string'); + test( + '_foo/_bar', + '_Foo/_Bar', + 'classify underscore-prefixed-namespaced underscore-prefixed string' + ); + test('-foo/_bar', '_Foo/_Bar', 'classify dash-prefixed-namespaced underscore-prefixed string'); + test('-foo/-bar', '_Foo/_Bar', 'classify dash-prefixed-namespaced dash-prefixed string'); + test('InnerHTML', 'InnerHTML', 'does nothing with classified string'); + test('_FooBar', '_FooBar', 'does nothing with classified prefixed string'); +}); diff --git a/packages/@ember/engine/tests/resolver/create-test-function.js b/packages/@ember/engine/tests/resolver/create-test-function.js new file mode 100644 index 00000000000..43dfd8fc733 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/create-test-function.js @@ -0,0 +1,9 @@ +import { test } from 'qunit'; + +export default function (fn) { + return function (given, expected, description) { + test(description, function (assert) { + assert.deepEqual(fn(given), expected); + }); + }; +} diff --git a/packages/@ember/engine/tests/resolver/custom-prefixes-test.js b/packages/@ember/engine/tests/resolver/custom-prefixes-test.js new file mode 100644 index 00000000000..dc0ccde4bd3 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/custom-prefixes-test.js @@ -0,0 +1,25 @@ +import { module, test } from 'qunit'; + +import { setupResolver, resolver, loader } from './-setup-resolver'; + +module('custom prefixes by type', function (hooks) { + hooks.beforeEach(function () { + setupResolver(); + }); + + test('will use the prefix specified for a given type if present', function (assert) { + setupResolver({ + namespace: { + fruitPrefix: 'grovestand', + modulePrefix: 'appkit', + }, + }); + + loader.define('grovestand/fruits/orange', [], function () { + assert.ok(true, 'custom prefix used'); + return 'whatever'; + }); + + resolver.resolve('fruit:orange'); + }); +}); diff --git a/packages/@ember/engine/tests/resolver/dasherize_test.js b/packages/@ember/engine/tests/resolver/dasherize_test.js new file mode 100644 index 00000000000..06431ba7f63 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/dasherize_test.js @@ -0,0 +1,28 @@ +import { module } from 'qunit'; +import { dasherize } from 'ember-resolver/string/index'; +import createTestFunction from './helpers/create-test-function'; + +module('dasherize', function () { + const test = createTestFunction(dasherize); + + test('my favorite items', 'my-favorite-items', 'dasherize normal string'); + test('css-class-name', 'css-class-name', 'does nothing with dasherized string'); + test('action_name', 'action-name', 'dasherize underscored string'); + test('innerHTML', 'inner-html', 'dasherize camelcased string'); + test('toString', 'to-string', 'dasherize string that is the property name of Object.prototype'); + test( + 'PrivateDocs/OwnerInvoice', + 'private-docs/owner-invoice', + 'dasherize namespaced classified string' + ); + test( + 'privateDocs/ownerInvoice', + 'private-docs/owner-invoice', + 'dasherize namespaced camelized string' + ); + test( + 'private_docs/owner_invoice', + 'private-docs/owner-invoice', + 'dasherize namespaced underscored string' + ); +}); diff --git a/packages/@ember/engine/tests/resolver/decamelize_test.js b/packages/@ember/engine/tests/resolver/decamelize_test.js new file mode 100644 index 00000000000..bb9f5ef05aa --- /dev/null +++ b/packages/@ember/engine/tests/resolver/decamelize_test.js @@ -0,0 +1,27 @@ +import { module } from 'qunit'; +import { decamelize } from 'ember-resolver/string/index'; +import createTestFunction from './helpers/create-test-function'; + +module('decamelize', function () { + const test = createTestFunction(decamelize); + + test('my favorite items', 'my favorite items', 'does nothing with normal string'); + test('css-class-name', 'css-class-name', 'does nothing with dasherized string'); + test('action_name', 'action_name', 'does nothing with underscored string'); + test( + 'innerHTML', + 'inner_html', + 'converts a camelized string into all lower case separated by underscores.' + ); + test('size160Url', 'size160_url', 'decamelizes strings with numbers'); + test( + 'PrivateDocs/OwnerInvoice', + 'private_docs/owner_invoice', + 'decamelize namespaced classified string' + ); + test( + 'privateDocs/ownerInvoice', + 'private_docs/owner_invoice', + 'decamelize namespaced camelized string' + ); +}); diff --git a/packages/@ember/engine/tests/resolver/pods-test.js b/packages/@ember/engine/tests/resolver/pods-test.js new file mode 100644 index 00000000000..2adfb571b00 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/pods-test.js @@ -0,0 +1,218 @@ +import { module, test } from 'qunit'; + +import { setupResolver, resolver, loader } from './-setup-resolver'; + +module('pods lookup structure', function (hooks) { + hooks.beforeEach(function () { + setupResolver(); + }); + + test('will lookup modulePrefix/name/type before prefix/type/name', function (assert) { + loader.define('appkit/controllers/foo', [], function () { + assert.ok(false, 'appkit/controllers was used'); + return 'whatever'; + }); + + loader.define('appkit/foo/controller', [], function () { + assert.ok(true, 'appkit/foo/controllers was used'); + return 'whatever'; + }); + + resolver.resolve('controller:foo'); + }); + + test('will lookup names with slashes properly', function (assert) { + loader.define('appkit/controllers/foo/index', [], function () { + assert.ok(false, 'appkit/controllers was used'); + return 'whatever'; + }); + + loader.define('appkit/foo/index/controller', [], function () { + assert.ok(true, 'appkit/foo/index/controller was used'); + return 'whatever'; + }); + + resolver.resolve('controller:foo/index'); + }); + + test('specifying a podModulePrefix overrides the general modulePrefix', function (assert) { + setupResolver({ + namespace: { + modulePrefix: 'appkit', + podModulePrefix: 'appkit/pods', + }, + }); + + loader.define('appkit/controllers/foo', [], function () { + assert.ok(false, 'appkit/controllers was used'); + return 'whatever'; + }); + + loader.define('appkit/foo/controller', [], function () { + assert.ok(false, 'appkit/foo/controllers was used'); + return 'whatever'; + }); + + loader.define('appkit/pods/foo/controller', [], function () { + assert.ok(true, 'appkit/pods/foo/controllers was used'); + return 'whatever'; + }); + + resolver.resolve('controller:foo'); + }); + + test('will not use custom type prefix when using POD format', function (assert) { + resolver.namespace['controllerPrefix'] = 'foobar'; + + loader.define('foobar/controllers/foo', [], function () { + assert.ok(false, 'foobar/controllers was used'); + return 'whatever'; + }); + + loader.define('foobar/foo/controller', [], function () { + assert.ok(false, 'foobar/foo/controllers was used'); + return 'whatever'; + }); + + loader.define('appkit/foo/controller', [], function () { + assert.ok(true, 'appkit/foo/controllers was used'); + return 'whatever'; + }); + + resolver.resolve('controller:foo'); + }); + + test('it will find components nested in app/components/name/index.js', function (assert) { + loader.define('appkit/components/foo-bar/index', [], function () { + assert.ok(true, 'appkit/components/foo-bar was used'); + + return 'whatever'; + }); + + resolver.resolve('component:foo-bar'); + }); + + test('will lookup a components template without being rooted in `components/`', function (assert) { + loader.define('appkit/components/foo-bar/template', [], function () { + assert.ok(false, 'appkit/components was used'); + return 'whatever'; + }); + + loader.define('appkit/foo-bar/template', [], function () { + assert.ok(true, 'appkit/foo-bar/template was used'); + return 'whatever'; + }); + + resolver.resolve('template:components/foo-bar'); + }); + + test('will use pods format to lookup components in components/', function (assert) { + assert.expect(3); + + let expectedComponent = { isComponentFactory: true }; + loader.define('appkit/components/foo-bar/template', [], function () { + assert.ok(true, 'appkit/components was used'); + return 'whatever'; + }); + + loader.define('appkit/components/foo-bar/component', [], function () { + assert.ok(true, 'appkit/components was used'); + return { default: expectedComponent }; + }); + + resolver.resolve('template:components/foo-bar'); + let component = resolver.resolve('component:foo-bar'); + + assert.equal(component, expectedComponent, 'default export was returned'); + }); + + test('will not lookup routes in components/', function (assert) { + assert.expect(1); + + loader.define('appkit/components/foo-bar/route', [], function () { + assert.ok(false, 'appkit/components was used'); + return { isRouteFactory: true }; + }); + + loader.define('appkit/routes/foo-bar', [], function () { + assert.ok(true, 'appkit/routes was used'); + return { isRouteFactory: true }; + }); + + resolver.resolve('route:foo-bar'); + }); + + test('will not lookup non component templates in components/', function (assert) { + assert.expect(1); + + loader.define('appkit/components/foo-bar/template', [], function () { + assert.ok(false, 'appkit/components was used'); + return 'whatever'; + }); + + loader.define('appkit/templates/foo-bar', [], function () { + assert.ok(true, 'appkit/templates was used'); + return 'whatever'; + }); + + resolver.resolve('template:foo-bar'); + }); + + module('custom pluralization'); + + test('will use the pluralization specified for a given type', function (assert) { + assert.expect(1); + + setupResolver({ + namespace: { + modulePrefix: 'appkit', + }, + + pluralizedTypes: { + sheep: 'sheep', + octipus: 'octipii', + }, + }); + + loader.define('appkit/sheep/baaaaaa', [], function () { + assert.ok(true, 'custom pluralization used'); + return 'whatever'; + }); + + resolver.resolve('sheep:baaaaaa'); + }); + + test("will pluralize 'config' as 'config' by default", function (assert) { + assert.expect(1); + + setupResolver(); + + loader.define('appkit/config/environment', [], function () { + assert.ok(true, 'config/environment is found'); + return 'whatever'; + }); + + resolver.resolve('config:environment'); + }); + + test("'config' can be overridden", function (assert) { + assert.expect(1); + + setupResolver({ + namespace: { + modulePrefix: 'appkit', + }, + + pluralizedTypes: { + config: 'super-duper-config', + }, + }); + + loader.define('appkit/super-duper-config/environment', [], function () { + assert.ok(true, 'super-duper-config/environment is found'); + return 'whatever'; + }); + + resolver.resolve('config:environment'); + }); +}); diff --git a/packages/@ember/engine/tests/resolver/underscore_test.js b/packages/@ember/engine/tests/resolver/underscore_test.js new file mode 100644 index 00000000000..9dddd912c67 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/underscore_test.js @@ -0,0 +1,27 @@ +import { module } from 'qunit'; +import { underscore } from 'ember-resolver/string/index'; +import createTestFunction from './helpers/create-test-function'; + +module('underscore', function () { + const test = createTestFunction(underscore); + + test('my favorite items', 'my_favorite_items', 'with normal string'); + test('css-class-name', 'css_class_name', 'with dasherized string'); + test('action_name', 'action_name', 'does nothing with underscored string'); + test('innerHTML', 'inner_html', 'with camelcased string'); + test( + 'PrivateDocs/OwnerInvoice', + 'private_docs/owner_invoice', + 'underscore namespaced classified string' + ); + test( + 'privateDocs/ownerInvoice', + 'private_docs/owner_invoice', + 'underscore namespaced camelized string' + ); + test( + 'private-docs/owner-invoice', + 'private_docs/owner_invoice', + 'underscore namespaced dasherized string' + ); +}); diff --git a/packages/@ember/engine/tests/resolver/with-modues-test.js b/packages/@ember/engine/tests/resolver/with-modues-test.js new file mode 100644 index 00000000000..8a91d9204f1 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/with-modues-test.js @@ -0,0 +1,45 @@ +/* eslint-disable no-console */ + +import { module, test } from 'qunit'; +import Resolver from 'ember-resolver'; + +module('ember-resolver withModules', function () { + test('explicit withModules', function (assert) { + let resolver = Resolver.withModules({ + 'alpha/components/hello': { + default: function () { + return 'it works'; + }, + }, + }).create({ namespace: { modulePrefix: 'alpha' } }); + + assert.strictEqual((0, resolver.resolve('component:hello'))(), 'it works'); + }); + + test('can resolve self', function (assert) { + let resolver = Resolver.create({ namespace: { modulePrefix: 'alpha' } }); + assert.strictEqual(resolver, resolver.resolve('resolver:current').create()); + }); + + test('can addModules', function (assert) { + let startingModules = {}; + let resolver = Resolver.withModules({}).create({ + namespace: { modulePrefix: 'alpha' }, + }); + + resolver.addModules({ + 'alpha/components/hello': { + default: function () { + return 'it works'; + }, + }, + }); + + assert.strictEqual((0, resolver.resolve('component:hello'))(), 'it works'); + assert.deepEqual( + [], + Object.keys(startingModules), + 'did not mutate starting modules' + ); + }); +}); From 0c04468cd7e029bcbeada8685c5395bde5bada92 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:04:12 -0400 Subject: [PATCH 02/25] Copy tests from ember-strict-application-resolver --- .../engine/tests/resolver/registry_test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 packages/@ember/engine/tests/resolver/registry_test.ts 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..eb755c196eb --- /dev/null +++ b/packages/@ember/engine/tests/resolver/registry_test.ts @@ -0,0 +1,53 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Registry', function (hooks) { + setupTest(hooks); + + test('has the router', function (assert) { + // eslint-disable-next-line ember/no-private-routing-service + const router = this.owner.lookup('router:main'); + + assert.ok(router); + }); + + test('has a manually registered service', function (assert) { + const manual = this.owner.lookup('service:manual') as { weDidIt: boolean }; + + assert.ok(manual); + assert.ok(manual.weDidIt); + }); + + test('has a manually registered (shorthand) service', function (assert) { + const manual = this.owner.lookup('service:manual-shorthand') as { + weDidIt: boolean; + }; + + assert.ok(manual); + assert.ok(manual.weDidIt); + }); + + test('has a service from import.meta.glob', function (assert) { + const metaGlob = this.owner.lookup('service:from-meta-glob') as { + weDidIt: boolean; + }; + + assert.ok(metaGlob); + assert.ok(metaGlob.weDidIt); + }); + + test('registered stuff can be looked up', function (assert) { + class Foo { + static create() { + return new this(); + } + + two = 2; + } + this.owner.register('not-standard:main', Foo); + + const value = this.owner.lookup('not-standard:main') as Foo; + + assert.strictEqual(value.two, 2); + }); +}); From e55c2320aa70ea085aef61642b49374547af1621 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:11:56 -0400 Subject: [PATCH 03/25] Adapt strict resolver tests to ember's codebase, remove AMD dependencies - Remove ModuleRegistry class and globalThis.requirejs/require references - Require explicit modules via Resolver.withModules() instead of AMD fallback - Rewrite all tests to use module maps instead of AMD loader.define() - Fix imports from 'ember-resolver' to local '@ember/engine/lib/strict-resolver' - Fix string utility imports to '@ember/engine/lib/strict-resolver/string' - Add package.json exports for strict-resolver and string utilities - Add Application integration tests using Resolver.withModules() - Fix typo: rename with-modues-test.js to with-modules-test.js Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/@ember/engine/lib/strict-resolver.ts | 47 +- packages/@ember/engine/package.json | 4 +- .../engine/tests/resolver/-setup-resolver.js | 22 +- .../engine/tests/resolver/basic-test.js | 462 ++++++------------ .../engine/tests/resolver/classify_test.js | 36 +- .../tests/resolver/custom-prefixes-test.js | 13 +- .../engine/tests/resolver/dasherize_test.js | 18 +- .../engine/tests/resolver/decamelize_test.js | 24 +- .../@ember/engine/tests/resolver/pods-test.js | 202 ++++---- .../engine/tests/resolver/registry_test.ts | 78 +-- .../engine/tests/resolver/underscore_test.js | 12 +- ...th-modues-test.js => with-modules-test.js} | 17 +- 12 files changed, 363 insertions(+), 572 deletions(-) rename packages/@ember/engine/tests/resolver/{with-modues-test.js => with-modules-test.js} (74%) diff --git a/packages/@ember/engine/lib/strict-resolver.ts b/packages/@ember/engine/lib/strict-resolver.ts index e68ddb45834..299a43c15cf 100644 --- a/packages/@ember/engine/lib/strict-resolver.ts +++ b/packages/@ember/engine/lib/strict-resolver.ts @@ -1,31 +1,13 @@ import { dasherize, classify, underscore } from './strict-resolver/string'; import classFactory from './strict-resolver/class-factory'; -export class ModuleRegistry { - constructor(entries) { - this._entries = entries || globalThis.requirejs.entries; - } - moduleNames() { - return Object.keys(this._entries); - } - has(moduleName) { - return moduleName in this._entries; - } - get(...args) { - return globalThis.require(...args); - } -} - /** - * This module defines a subclass of Ember.DefaultResolver that adds two - * important features: + * This module defines a Resolver that is aware of es6 modules + * via explicit module maps. * - * 1) The resolver makes the container aware of es6 modules via the AMD - * output. The loader's _moduleEntries is consulted so that classes can be - * resolved directly via the module loader, without needing a manual - * `import`. - * 2) is able to provide injections to classes that implement `extend` - * (as is typical with Ember). + * Modules are provided via `Resolver.withModules(moduleMap)`, + * where the moduleMap keys are module paths and values are + * module objects (with `default` exports). */ export default class Resolver { static moduleBasedResolver = true; @@ -80,10 +62,9 @@ export default class Resolver { }, }; } else { - if (typeof globalThis.requirejs.entries === 'undefined') { - globalThis.requirejs.entries = globalThis.requirejs._eak_seen; - } - this._moduleRegistry = new ModuleRegistry(); + throw new Error( + 'Resolver requires explicit modules. Use Resolver.withModules(moduleMap) to provide them.' + ); } } @@ -180,10 +161,7 @@ export default class Resolver { let normalizedModuleName = this.findModuleName(parsedName); if (normalizedModuleName) { - let defaultExport = this._extractDefaultExport( - normalizedModuleName, - parsedName - ); + let defaultExport = this._extractDefaultExport(normalizedModuleName); if (defaultExport === undefined) { throw new Error( @@ -466,12 +444,7 @@ export default class Resolver { } _extractDefaultExport(normalizedModuleName) { - let module = this._moduleRegistry.get( - normalizedModuleName, - null, - null, - true /* force sync */ - ); + let module = this._moduleRegistry.get(normalizedModuleName); if (module && module['default']) { module = module['default']; diff --git a/packages/@ember/engine/package.json b/packages/@ember/engine/package.json index b08914eb32f..b042cedbf72 100644 --- a/packages/@ember/engine/package.json +++ b/packages/@ember/engine/package.json @@ -5,7 +5,9 @@ "exports": { ".": "./index.ts", "./instance": "./instance.ts", - "./parent": "./parent.ts" + "./parent": "./parent.ts", + "./lib/strict-resolver": "./lib/strict-resolver.ts", + "./lib/strict-resolver/string": "./lib/strict-resolver/string.js" }, "dependencies": { "@ember/-internals": "workspace:*", diff --git a/packages/@ember/engine/tests/resolver/-setup-resolver.js b/packages/@ember/engine/tests/resolver/-setup-resolver.js index f1b8808fa94..f21be6ddbc0 100644 --- a/packages/@ember/engine/tests/resolver/-setup-resolver.js +++ b/packages/@ember/engine/tests/resolver/-setup-resolver.js @@ -1,9 +1,9 @@ -import Resolver, { ModuleRegistry } from 'ember-resolver'; +import Resolver from '@ember/engine/lib/strict-resolver'; import { setOwner } from '@ember/owner'; export let resolver; -export let loader; +export let modules; export function setupResolver(options = {}) { let owner = options.owner; @@ -12,21 +12,11 @@ export function setupResolver(options = {}) { if (!options.namespace) { options.namespace = { modulePrefix: 'appkit' }; } - loader = { - entries: Object.create(null), - define(id, deps, callback) { - if (deps.length > 0) { - throw new Error('Test Module loader does not support dependencies'); - } - this.entries[id] = callback; - }, - }; - options._moduleRegistry = new ModuleRegistry(loader.entries); - options._moduleRegistry.get = function (moduleName) { - return loader.entries[moduleName](); - }; - resolver = Resolver.create(options); + modules = Object.create(null); + + let ResolverClass = Resolver.withModules(modules); + resolver = ResolverClass.create(options); if (owner) { setOwner(resolver, owner); diff --git a/packages/@ember/engine/tests/resolver/basic-test.js b/packages/@ember/engine/tests/resolver/basic-test.js index 858864d6a4a..5eb0e0d726e 100644 --- a/packages/@ember/engine/tests/resolver/basic-test.js +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -1,326 +1,183 @@ -/* eslint-disable no-console */ - import { module, test } from 'qunit'; -import { setupResolver, resolver, loader } from './-setup-resolver'; - -let originalConsoleInfo; +import { setupResolver, resolver, modules } from './-setup-resolver'; -module('ember-resolver/resolvers/classic', function (hooks) { +module('strict-resolver | basic', function (hooks) { hooks.beforeEach(function () { setupResolver(); }); - hooks.afterEach(function () { - if (originalConsoleInfo) { - console.info = originalConsoleInfo; - } - }); - - // ember @ 3.3 breaks this: https://github.com/emberjs/ember.js/commit/b8613c20289cc8a730e181c4c51ecfc4b6836052#r29790209 - // ember @ 3.4.0-beta.1 restores this: https://github.com/emberjs/ember.js/commit/ddd8d9b9d9f6d315185a34802618a666bb3aeaac - // test('does not require `namespace` to exist at `init` time', function(assert) { - // assert.expect(0); - - // Resolver.create({ namespace: '' }); - // }); - test('can lookup something', function (assert) { - assert.expect(2); - - loader.define('appkit/adapters/post', [], function () { - assert.ok(true, 'adapter was invoked properly'); - - return {}; - }); + let expected = {}; + modules['appkit/adapters/post'] = { default: expected }; - var adapter = resolver.resolve('adapter:post'); + let adapter = resolver.resolve('adapter:post'); assert.ok(adapter, 'adapter was returned'); + assert.strictEqual(adapter, expected, 'default export was returned'); }); test('can lookup something in another namespace', function (assert) { - assert.expect(3); - let expected = {}; + modules['other/adapters/post'] = { default: expected }; - loader.define('other/adapters/post', [], function () { - assert.ok(true, 'adapter was invoked properly'); - - return { - default: expected, - }; - }); - - var adapter = resolver.resolve('other@adapter:post'); + let adapter = resolver.resolve('other@adapter:post'); assert.ok(adapter, 'adapter was returned'); - assert.equal(adapter, expected, 'default export was returned'); + assert.strictEqual(adapter, expected, 'default export was returned'); }); test('can lookup something in another namespace with an @ scope', function (assert) { - assert.expect(3); - let expected = {}; + modules['@scope/other/adapters/post'] = { default: expected }; - loader.define('@scope/other/adapters/post', [], function () { - assert.ok(true, 'adapter was invoked properly'); - - return { - default: expected, - }; - }); - - var adapter = resolver.resolve('@scope/other@adapter:post'); + let adapter = resolver.resolve('@scope/other@adapter:post'); assert.ok(adapter, 'adapter was returned'); - assert.equal(adapter, expected, 'default export was returned'); + assert.strictEqual(adapter, expected, 'default export was returned'); }); test('can lookup something with an @ sign', function (assert) { - assert.expect(3); - let expected = {}; - loader.define('appkit/helpers/@content-helper', [], function () { - assert.ok(true, 'helper was invoked properly'); + modules['appkit/helpers/@content-helper'] = { default: expected }; - return { default: expected }; - }); - - var helper = resolver.resolve('helper:@content-helper'); + let helper = resolver.resolve('helper:@content-helper'); assert.ok(helper, 'helper was returned'); - assert.equal(helper, expected, 'default export was returned'); + assert.strictEqual(helper, expected, 'default export was returned'); }); test('can lookup something in another namespace with different syntax', function (assert) { - assert.expect(3); - let expected = {}; - loader.define('other/adapters/post', [], function () { - assert.ok(true, 'adapter was invoked properly'); + modules['other/adapters/post'] = { default: expected }; - return { default: expected }; - }); - - var adapter = resolver.resolve('adapter:other@post'); + let adapter = resolver.resolve('adapter:other@post'); assert.ok(adapter, 'adapter was returned'); - assert.equal(adapter, expected, 'default export was returned'); + assert.strictEqual(adapter, expected, 'default export was returned'); }); test('can lookup something in another namespace with an @ scope with different syntax', function (assert) { - assert.expect(3); - let expected = {}; - loader.define('@scope/other/adapters/post', [], function () { - assert.ok(true, 'adapter was invoked properly'); + modules['@scope/other/adapters/post'] = { default: expected }; - return { default: expected }; - }); - - var adapter = resolver.resolve('adapter:@scope/other@post'); + let adapter = resolver.resolve('adapter:@scope/other@post'); assert.ok(adapter, 'adapter was returned'); - assert.equal(adapter, expected, 'default export was returned'); + assert.strictEqual(adapter, expected, 'default export was returned'); }); test('can lookup a view in another namespace', function (assert) { - assert.expect(3); - let expected = { isViewFactory: true }; - loader.define('other/views/post', [], function () { - assert.ok(true, 'view was invoked properly'); + modules['other/views/post'] = { default: expected }; - return { default: expected }; - }); - - var view = resolver.resolve('other@view:post'); + let view = resolver.resolve('other@view:post'); assert.ok(view, 'view was returned'); - assert.equal(view, expected, 'default export was returned'); + assert.strictEqual(view, expected, 'default export was returned'); }); test('can lookup a view in another namespace with an @ scope', function (assert) { - assert.expect(3); - let expected = { isViewFactory: true }; - loader.define('@scope/other/views/post', [], function () { - assert.ok(true, 'view was invoked properly'); + modules['@scope/other/views/post'] = { default: expected }; - return { default: expected }; - }); - - var view = resolver.resolve('@scope/other@view:post'); + let view = resolver.resolve('@scope/other@view:post'); assert.ok(view, 'view was returned'); - assert.equal(view, expected, 'default export was returned'); + assert.strictEqual(view, expected, 'default export was returned'); }); test('can lookup a view in another namespace with different syntax', function (assert) { - assert.expect(3); - let expected = { isViewFactory: true }; - loader.define('other/views/post', [], function () { - assert.ok(true, 'view was invoked properly'); - - return { default: expected }; - }); + modules['other/views/post'] = { default: expected }; - var view = resolver.resolve('view:other@post'); + let view = resolver.resolve('view:other@post'); assert.ok(view, 'view was returned'); - assert.equal(view, expected, 'default export was returned'); + assert.strictEqual(view, expected, 'default export was returned'); }); test('can lookup a view in another namespace with an @ scope with different syntax', function (assert) { - assert.expect(3); - let expected = { isViewFactory: true }; - loader.define('@scope/other/views/post', [], function () { - assert.ok(true, 'view was invoked properly'); + modules['@scope/other/views/post'] = { default: expected }; - return { default: expected }; - }); - - var view = resolver.resolve('view:@scope/other@post'); + let view = resolver.resolve('view:@scope/other@post'); assert.ok(view, 'view was returned'); - assert.equal(view, expected, 'default export was returned'); + assert.strictEqual(view, expected, 'default export was returned'); }); test('can lookup a component template in another namespace with different syntax', function (assert) { - assert.expect(2); - let expected = { isTemplate: true }; - loader.define('other/templates/components/foo-bar', [], function () { - assert.ok(true, 'template was looked up properly'); - - return { default: expected }; - }); + modules['other/templates/components/foo-bar'] = { default: expected }; - var template = resolver.resolve('template:components/other@foo-bar'); + let template = resolver.resolve('template:components/other@foo-bar'); - assert.equal(template, expected, 'default export was returned'); + assert.strictEqual(template, expected, 'default export was returned'); }); test('can lookup a component template in another namespace with an @ scope with different syntax', function (assert) { - assert.expect(2); - let expected = { isTemplate: true }; - loader.define('@scope/other/templates/components/foo-bar', [], function () { - assert.ok(true, 'template was looked up properly'); - - return { default: expected }; - }); + modules['@scope/other/templates/components/foo-bar'] = { + default: expected, + }; - var template = resolver.resolve('template:components/@scope/other@foo-bar'); + let template = resolver.resolve('template:components/@scope/other@foo-bar'); - assert.equal(template, expected, 'default export was returned'); + assert.strictEqual(template, expected, 'default export was returned'); }); test('can lookup a view', function (assert) { - assert.expect(3); - let expected = { isViewFactory: true }; - loader.define('appkit/views/queue-list', [], function () { - assert.ok(true, 'view was invoked properly'); + modules['appkit/views/queue-list'] = { default: expected }; - return { default: expected }; - }); - - var view = resolver.resolve('view:queue-list'); + let view = resolver.resolve('view:queue-list'); assert.ok(view, 'view was returned'); - assert.equal(view, expected, 'default export was returned'); + assert.strictEqual(view, expected, 'default export was returned'); }); test('can lookup a helper', function (assert) { - assert.expect(3); - let expected = { isHelperInstance: true }; - loader.define('appkit/helpers/reverse-list', [], function () { - assert.ok(true, 'helper was invoked properly'); + modules['appkit/helpers/reverse-list'] = { default: expected }; - return { default: expected }; - }); - - var helper = resolver.resolve('helper:reverse-list'); + let helper = resolver.resolve('helper:reverse-list'); assert.ok(helper, 'helper was returned'); - assert.equal(helper, expected, 'default export was returned'); + assert.strictEqual(helper, expected, 'default export was returned'); }); test('can lookup an engine', function (assert) { - assert.expect(3); - let expected = {}; - loader.define('appkit/engine', [], function () { - assert.ok(true, 'engine was invoked properly'); - - return { default: expected }; - }); + modules['appkit/engine'] = { default: expected }; let engine = resolver.resolve('engine:appkit'); assert.ok(engine, 'engine was returned'); - assert.equal(engine, expected, 'default export was returned'); + assert.strictEqual(engine, expected, 'default export was returned'); }); test('can lookup an engine from a scoped package', function (assert) { - assert.expect(3); - let expected = {}; - loader.define('@some-scope/some-module/engine', [], function () { - assert.ok(true, 'engine was invoked properly'); + modules['@some-scope/some-module/engine'] = { default: expected }; - return { default: expected }; - }); - - var engine = resolver.resolve('engine:@some-scope/some-module'); + let engine = resolver.resolve('engine:@some-scope/some-module'); assert.ok(engine, 'engine was returned'); - assert.equal(engine, expected, 'default export was returned'); + assert.strictEqual(engine, expected, 'default export was returned'); }); test('can lookup a route-map', function (assert) { - assert.expect(3); - let expected = { isRouteMap: true }; - loader.define('appkit/routes', [], function () { - assert.ok(true, 'route-map was invoked properly'); - - return { default: expected }; - }); + modules['appkit/routes'] = { default: expected }; let routeMap = resolver.resolve('route-map:appkit'); assert.ok(routeMap, 'route-map was returned'); - assert.equal(routeMap, expected, 'default export was returned'); - }); - - // the assert.expectWarning helper no longer works - test.skip('warns if looking up a camelCase helper that has a dasherized module present', function (assert) { - assert.expect(1); - - loader.define('appkit/helpers/reverse-list', [], function () { - return { default: { isHelperInstance: true } }; - }); - - var helper = resolver.resolve('helper:reverseList'); - - assert.ok(!helper, 'no helper was returned'); - // assert.expectWarning('Attempted to lookup "helper:reverseList" which was not found. In previous versions of ember-resolver, a bug would have caused the module at "appkit/helpers/reverse-list" to be returned for this camel case helper name. This has been fixed. Use the dasherized name to resolve the module that would have been returned in previous versions.'); + assert.strictEqual(routeMap, expected, 'default export was returned'); }); test('errors if lookup of a route-map does not specify isRouteMap', function (assert) { - assert.expect(2); - - let expected = { isRouteMap: false }; - loader.define('appkit/routes', [], function () { - assert.ok(true, 'route-map was invoked properly'); - - return { default: expected }; - }); + modules['appkit/routes'] = { default: { isRouteMap: false } }; assert.throws(() => { resolver.resolve('route-map:appkit'); @@ -328,112 +185,80 @@ module('ember-resolver/resolvers/classic', function (hooks) { }); test("will return the raw value if no 'default' is available", function (assert) { - loader.define('appkit/fruits/orange', [], function () { - return 'is awesome'; - }); + modules['appkit/fruits/orange'] = 'is awesome'; - assert.equal( + assert.strictEqual( resolver.resolve('fruit:orange'), 'is awesome', - 'adapter was returned' + 'raw value was returned' ); }); test("will unwrap the 'default' export automatically", function (assert) { - loader.define('appkit/fruits/orange', [], function () { - return { default: 'is awesome' }; - }); + modules['appkit/fruits/orange'] = { default: 'is awesome' }; - assert.equal( + assert.strictEqual( resolver.resolve('fruit:orange'), 'is awesome', - 'adapter was returned' + 'default export was unwrapped' ); }); test('router:main is hard-coded to prefix/router.js', function (assert) { - assert.expect(1); + modules['appkit/router'] = 'whatever'; - loader.define('appkit/router', [], function () { - assert.ok(true, 'router:main was looked up'); - return 'whatever'; - }); + let result = resolver.resolve('router:main'); - resolver.resolve('router:main'); + assert.strictEqual(result, 'whatever', 'router:main was looked up'); }); test('store:main is looked up as prefix/store', function (assert) { - assert.expect(1); + modules['appkit/store'] = 'whatever'; - loader.define('appkit/store', [], function () { - assert.ok(true, 'store:main was looked up'); - return 'whatever'; - }); + let result = resolver.resolve('store:main'); - resolver.resolve('store:main'); + assert.strictEqual(result, 'whatever', 'store:main was looked up'); }); test('store:posts as prefix/stores/post', function (assert) { - assert.expect(1); + modules['appkit/stores/post'] = 'whatever'; - loader.define('appkit/stores/post', [], function () { - assert.ok(true, 'store:post was looked up'); - return 'whatever'; - }); + let result = resolver.resolve('store:post'); - resolver.resolve('store:post'); + assert.strictEqual(result, 'whatever', 'store:post was looked up'); }); test('will raise error if both dasherized and underscored modules exist', function (assert) { - loader.define('appkit/big-bands/steve-miller-band', [], function () { - assert.ok(true, 'dasherized version looked up'); - return 'whatever'; - }); - - loader.define('appkit/big_bands/steve_miller_band', [], function () { - assert.ok(false, 'underscored version looked up'); - return 'whatever'; - }); + modules['appkit/big-bands/steve-miller-band'] = 'whatever'; + modules['appkit/big_bands/steve_miller_band'] = 'whatever'; - try { - resolver.resolve('big-band:steve-miller-band'); - } catch (e) { - assert.equal( - e.message, + assert.throws( + () => resolver.resolve('big-band:steve-miller-band'), + (e) => + e.message === `Ambiguous module names: 'appkit/big-bands/steve-miller-band' and 'appkit/big_bands/steve_miller_band'`, - 'error with a descriptive value is thrown' - ); - } + 'error with a descriptive value is thrown' + ); }); test('will lookup an underscored version of the module name when the dasherized version is not found', function (assert) { - assert.expect(1); + modules['appkit/big_bands/steve_miller_band'] = 'whatever'; - loader.define('appkit/big_bands/steve_miller_band', [], function () { - assert.ok(true, 'underscored version looked up properly'); - return 'whatever'; - }); + let result = resolver.resolve('big-band:steve-miller-band'); - resolver.resolve('big-band:steve-miller-band'); + assert.strictEqual( + result, + 'whatever', + 'underscored version looked up properly' + ); }); - test('it provides eachForType which invokes the callback for each item found', function (assert) { - function orange() {} - loader.define('appkit/fruits/orange', [], function () { - return { default: orange }; - }); - - function apple() {} - loader.define('appkit/fruits/apple', [], function () { - return { default: apple }; - }); + test('knownForType returns known modules for a given type', function (assert) { + modules['appkit/fruits/orange'] = { default: function orange() {} }; + modules['appkit/fruits/apple'] = { default: function apple() {} }; + modules['appkit/stuffs/other'] = { default: function other() {} }; - function other() {} - loader.define('appkit/stuffs/other', [], function () { - return { default: other }; - }); - - var items = resolver.knownForType('fruit'); + let items = resolver.knownForType('fruit'); assert.deepEqual(items, { 'fruit:orange': true, @@ -441,18 +266,11 @@ module('ember-resolver/resolvers/classic', function (hooks) { }); }); - test('eachForType can find both pod and non-pod factories', function (assert) { - function orange() {} - loader.define('appkit/fruits/orange', [], function () { - return { default: orange }; - }); + test('knownForType can find both pod and non-pod factories', function (assert) { + modules['appkit/fruits/orange'] = { default: function orange() {} }; + modules['appkit/lemon/fruit'] = { default: function lemon() {} }; - function lemon() {} - loader.define('appkit/lemon/fruit', [], function () { - return { default: lemon }; - }); - - var items = resolver.knownForType('fruit'); + let items = resolver.knownForType('fruit'); assert.deepEqual(items, { 'fruit:orange': true, @@ -462,148 +280,144 @@ module('ember-resolver/resolvers/classic', function (hooks) { test('if shouldWrapInClassFactory returns true a wrapped object is returned', function (assert) { resolver.shouldWrapInClassFactory = function (defaultExport, parsedName) { - assert.equal(defaultExport, 'foo'); - assert.equal(parsedName.fullName, 'string:foo'); + assert.strictEqual(defaultExport, 'foo'); + assert.strictEqual(parsedName.fullName, 'string:foo'); return true; }; - loader.define('appkit/strings/foo', [], function () { - return { default: 'foo' }; - }); + modules['appkit/strings/foo'] = { default: 'foo' }; - var value = resolver.resolve('string:foo'); + let value = resolver.resolve('string:foo'); - assert.equal(value.create(), 'foo'); + assert.strictEqual(value.create(), 'foo'); }); test('normalization', function (assert) { assert.ok(resolver.normalize, 'resolver#normalize is present'); - assert.equal(resolver.normalize('foo:bar'), 'foo:bar'); + assert.strictEqual(resolver.normalize('foo:bar'), 'foo:bar'); - assert.equal(resolver.normalize('controller:posts'), 'controller:posts'); - assert.equal( + assert.strictEqual( + resolver.normalize('controller:posts'), + 'controller:posts' + ); + assert.strictEqual( resolver.normalize('controller:posts_index'), 'controller:posts-index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:posts.index'), 'controller:posts/index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:posts-index'), 'controller:posts-index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:posts.post.index'), 'controller:posts/post/index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:posts_post.index'), 'controller:posts-post/index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:posts.post_index'), 'controller:posts/post-index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:posts.post-index'), 'controller:posts/post-index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:postsIndex'), 'controller:posts-index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:blogPosts.index'), 'controller:blog-posts/index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:blog/posts.index'), 'controller:blog/posts/index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:blog/posts-index'), 'controller:blog/posts-index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:blog/posts.post.index'), 'controller:blog/posts/post/index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:blog/posts_post.index'), 'controller:blog/posts-post/index' ); - assert.equal( + assert.strictEqual( resolver.normalize('controller:blog/posts_post-index'), 'controller:blog/posts-post-index' ); - assert.equal( + assert.strictEqual( resolver.normalize('template:blog/posts_index'), 'template:blog/posts-index' ); - assert.equal(resolver.normalize('service:userAuth'), 'service:user-auth'); + 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.equal( + assert.strictEqual( resolver.normalize('helper:make-fabulous'), 'helper:make-fabulous' ); - assert.equal(resolver.normalize('helper:fabulize'), 'helper:fabulize'); - assert.equal( + assert.strictEqual( + resolver.normalize('helper:fabulize'), + 'helper:fabulize' + ); + assert.strictEqual( resolver.normalize('helper:make_fabulous'), 'helper:make-fabulous' ); - assert.equal( + assert.strictEqual( resolver.normalize('helper:makeFabulous'), 'helper:makeFabulous' ); // The same applies to components - assert.equal( + assert.strictEqual( resolver.normalize('component:fabulous-component'), 'component:fabulous-component' ); - assert.equal( + assert.strictEqual( resolver.normalize('component:fabulousComponent'), 'component:fabulousComponent' ); - assert.equal( + assert.strictEqual( resolver.normalize('template:components/fabulousComponent'), 'template:components/fabulousComponent' ); // and modifiers - assert.equal( + assert.strictEqual( resolver.normalize('modifier:fabulous-component'), 'modifier:fabulous-component' ); // deprecated when fabulously-missing actually exists, but normalize still returns it - assert.equal( + assert.strictEqual( resolver.normalize('modifier:fabulouslyMissing'), 'modifier:fabulouslyMissing' ); }); test('camel case modifier is not normalized', function (assert) { - assert.expect(2); - let expected = {}; - loader.define('appkit/modifiers/other-thing', [], function () { - assert.ok(false, 'appkit/modifiers/other-thing was accessed'); - - return { default: 'oh no' }; - }); - - loader.define('appkit/modifiers/otherThing', [], function () { - assert.ok(true, 'appkit/modifiers/otherThing was accessed'); - - return { default: expected }; - }); + modules['appkit/modifiers/other-thing'] = { default: 'oh no' }; + modules['appkit/modifiers/otherThing'] = { default: expected }; let modifier = resolver.resolve('modifier:otherThing'); @@ -619,7 +433,7 @@ module('ember-resolver/resolvers/classic', function (hooks) { ]; examples.forEach((example) => { - assert.equal( + assert.strictEqual( resolver.normalize(resolver.normalize(example)), resolver.normalize(example) ); diff --git a/packages/@ember/engine/tests/resolver/classify_test.js b/packages/@ember/engine/tests/resolver/classify_test.js index 7de6440c427..301a07162b0 100644 --- a/packages/@ember/engine/tests/resolver/classify_test.js +++ b/packages/@ember/engine/tests/resolver/classify_test.js @@ -1,8 +1,8 @@ import { module } from 'qunit'; -import { classify } from 'ember-resolver/string/index'; -import createTestFunction from './helpers/create-test-function'; +import { classify } from '@ember/engine/lib/strict-resolver/string'; +import createTestFunction from './create-test-function'; -module('classify', function () { +module('strict-resolver | classify', function () { const test = createTestFunction(classify); test('my favorite items', 'MyFavoriteItems', 'classify normal string'); @@ -23,21 +23,41 @@ module('classify', function () { 'PrivateDocs/OwnerInvoice', 'classify namespaced dasherized string' ); - test('-view-registry', '_ViewRegistry', 'classify prefixed dasherized string'); + test( + '-view-registry', + '_ViewRegistry', + 'classify prefixed dasherized string' + ); test( 'components/-text-field', 'Components/_TextField', 'classify namespaced prefixed dasherized string' ); - test('_Foo_Bar', '_FooBar', 'classify underscore-prefixed underscored string'); - test('_Foo-Bar', '_FooBar', 'classify underscore-prefixed dasherized string'); + test( + '_Foo_Bar', + '_FooBar', + 'classify underscore-prefixed underscored string' + ); + test( + '_Foo-Bar', + '_FooBar', + 'classify underscore-prefixed dasherized string' + ); test( '_foo/_bar', '_Foo/_Bar', 'classify underscore-prefixed-namespaced underscore-prefixed string' ); - test('-foo/_bar', '_Foo/_Bar', 'classify dash-prefixed-namespaced underscore-prefixed string'); - test('-foo/-bar', '_Foo/_Bar', 'classify dash-prefixed-namespaced dash-prefixed string'); + test( + '-foo/_bar', + '_Foo/_Bar', + 'classify dash-prefixed-namespaced underscore-prefixed string' + ); + test( + '-foo/-bar', + '_Foo/_Bar', + 'classify dash-prefixed-namespaced dash-prefixed string' + ); test('InnerHTML', 'InnerHTML', 'does nothing with classified string'); test('_FooBar', '_FooBar', 'does nothing with classified prefixed string'); }); diff --git a/packages/@ember/engine/tests/resolver/custom-prefixes-test.js b/packages/@ember/engine/tests/resolver/custom-prefixes-test.js index dc0ccde4bd3..68dfe21a4bd 100644 --- a/packages/@ember/engine/tests/resolver/custom-prefixes-test.js +++ b/packages/@ember/engine/tests/resolver/custom-prefixes-test.js @@ -1,8 +1,8 @@ import { module, test } from 'qunit'; -import { setupResolver, resolver, loader } from './-setup-resolver'; +import { setupResolver, resolver, modules } from './-setup-resolver'; -module('custom prefixes by type', function (hooks) { +module('strict-resolver | custom prefixes by type', function (hooks) { hooks.beforeEach(function () { setupResolver(); }); @@ -15,11 +15,10 @@ module('custom prefixes by type', function (hooks) { }, }); - loader.define('grovestand/fruits/orange', [], function () { - assert.ok(true, 'custom prefix used'); - return 'whatever'; - }); + modules['grovestand/fruits/orange'] = 'whatever'; + + let result = resolver.resolve('fruit:orange'); - resolver.resolve('fruit:orange'); + assert.strictEqual(result, 'whatever', 'custom prefix was used'); }); }); diff --git a/packages/@ember/engine/tests/resolver/dasherize_test.js b/packages/@ember/engine/tests/resolver/dasherize_test.js index 06431ba7f63..c06ddd1833c 100644 --- a/packages/@ember/engine/tests/resolver/dasherize_test.js +++ b/packages/@ember/engine/tests/resolver/dasherize_test.js @@ -1,15 +1,23 @@ import { module } from 'qunit'; -import { dasherize } from 'ember-resolver/string/index'; -import createTestFunction from './helpers/create-test-function'; +import { dasherize } from '@ember/engine/lib/strict-resolver/string'; +import createTestFunction from './create-test-function'; -module('dasherize', function () { +module('strict-resolver | dasherize', function () { const test = createTestFunction(dasherize); test('my favorite items', 'my-favorite-items', 'dasherize normal string'); - test('css-class-name', 'css-class-name', 'does nothing with dasherized string'); + test( + 'css-class-name', + 'css-class-name', + 'does nothing with dasherized string' + ); test('action_name', 'action-name', 'dasherize underscored string'); test('innerHTML', 'inner-html', 'dasherize camelcased string'); - test('toString', 'to-string', 'dasherize string that is the property name of Object.prototype'); + test( + 'toString', + 'to-string', + 'dasherize string that is the property name of Object.prototype' + ); test( 'PrivateDocs/OwnerInvoice', 'private-docs/owner-invoice', diff --git a/packages/@ember/engine/tests/resolver/decamelize_test.js b/packages/@ember/engine/tests/resolver/decamelize_test.js index bb9f5ef05aa..d1d1af11ffa 100644 --- a/packages/@ember/engine/tests/resolver/decamelize_test.js +++ b/packages/@ember/engine/tests/resolver/decamelize_test.js @@ -1,13 +1,25 @@ import { module } from 'qunit'; -import { decamelize } from 'ember-resolver/string/index'; -import createTestFunction from './helpers/create-test-function'; +import { decamelize } from '@ember/engine/lib/strict-resolver/string'; +import createTestFunction from './create-test-function'; -module('decamelize', function () { +module('strict-resolver | decamelize', function () { const test = createTestFunction(decamelize); - test('my favorite items', 'my favorite items', 'does nothing with normal string'); - test('css-class-name', 'css-class-name', 'does nothing with dasherized string'); - test('action_name', 'action_name', 'does nothing with underscored string'); + test( + 'my favorite items', + 'my favorite items', + 'does nothing with normal string' + ); + test( + 'css-class-name', + 'css-class-name', + 'does nothing with dasherized string' + ); + test( + 'action_name', + 'action_name', + 'does nothing with underscored string' + ); test( 'innerHTML', 'inner_html', diff --git a/packages/@ember/engine/tests/resolver/pods-test.js b/packages/@ember/engine/tests/resolver/pods-test.js index 2adfb571b00..6e8ac95079e 100644 --- a/packages/@ember/engine/tests/resolver/pods-test.js +++ b/packages/@ember/engine/tests/resolver/pods-test.js @@ -1,38 +1,28 @@ import { module, test } from 'qunit'; -import { setupResolver, resolver, loader } from './-setup-resolver'; +import { setupResolver, resolver, modules } from './-setup-resolver'; -module('pods lookup structure', function (hooks) { +module('strict-resolver | pods lookup structure', function (hooks) { hooks.beforeEach(function () { setupResolver(); }); test('will lookup modulePrefix/name/type before prefix/type/name', function (assert) { - loader.define('appkit/controllers/foo', [], function () { - assert.ok(false, 'appkit/controllers was used'); - return 'whatever'; - }); + modules['appkit/controllers/foo'] = 'non-pod'; + modules['appkit/foo/controller'] = 'pod'; - loader.define('appkit/foo/controller', [], function () { - assert.ok(true, 'appkit/foo/controllers was used'); - return 'whatever'; - }); + let result = resolver.resolve('controller:foo'); - resolver.resolve('controller:foo'); + assert.strictEqual(result, 'pod', 'pod layout was used'); }); test('will lookup names with slashes properly', function (assert) { - loader.define('appkit/controllers/foo/index', [], function () { - assert.ok(false, 'appkit/controllers was used'); - return 'whatever'; - }); + modules['appkit/controllers/foo/index'] = 'non-pod'; + modules['appkit/foo/index/controller'] = 'pod'; - loader.define('appkit/foo/index/controller', [], function () { - assert.ok(true, 'appkit/foo/index/controller was used'); - return 'whatever'; - }); + let result = resolver.resolve('controller:foo/index'); - resolver.resolve('controller:foo/index'); + assert.strictEqual(result, 'pod', 'pod layout was used'); }); test('specifying a podModulePrefix overrides the general modulePrefix', function (assert) { @@ -43,176 +33,148 @@ module('pods lookup structure', function (hooks) { }, }); - loader.define('appkit/controllers/foo', [], function () { - assert.ok(false, 'appkit/controllers was used'); - return 'whatever'; - }); + modules['appkit/controllers/foo'] = 'non-pod'; + modules['appkit/foo/controller'] = 'non-pod-prefix'; + modules['appkit/pods/foo/controller'] = 'pod-prefix'; - loader.define('appkit/foo/controller', [], function () { - assert.ok(false, 'appkit/foo/controllers was used'); - return 'whatever'; - }); - - loader.define('appkit/pods/foo/controller', [], function () { - assert.ok(true, 'appkit/pods/foo/controllers was used'); - return 'whatever'; - }); + let result = resolver.resolve('controller:foo'); - resolver.resolve('controller:foo'); + assert.strictEqual(result, 'pod-prefix', 'podModulePrefix was used'); }); test('will not use custom type prefix when using POD format', function (assert) { resolver.namespace['controllerPrefix'] = 'foobar'; - loader.define('foobar/controllers/foo', [], function () { - assert.ok(false, 'foobar/controllers was used'); - return 'whatever'; - }); - - loader.define('foobar/foo/controller', [], function () { - assert.ok(false, 'foobar/foo/controllers was used'); - return 'whatever'; - }); + modules['foobar/controllers/foo'] = 'custom-prefix-non-pod'; + modules['foobar/foo/controller'] = 'custom-prefix-pod'; + modules['appkit/foo/controller'] = 'default-prefix-pod'; - loader.define('appkit/foo/controller', [], function () { - assert.ok(true, 'appkit/foo/controllers was used'); - return 'whatever'; - }); + let result = resolver.resolve('controller:foo'); - resolver.resolve('controller:foo'); + assert.strictEqual( + result, + 'default-prefix-pod', + 'modulePrefix was used for pod layout' + ); }); test('it will find components nested in app/components/name/index.js', function (assert) { - loader.define('appkit/components/foo-bar/index', [], function () { - assert.ok(true, 'appkit/components/foo-bar was used'); + modules['appkit/components/foo-bar/index'] = 'nested-component'; - return 'whatever'; - }); + let result = resolver.resolve('component:foo-bar'); - resolver.resolve('component:foo-bar'); + assert.strictEqual( + result, + 'nested-component', + 'nested component was found' + ); }); test('will lookup a components template without being rooted in `components/`', function (assert) { - loader.define('appkit/components/foo-bar/template', [], function () { - assert.ok(false, 'appkit/components was used'); - return 'whatever'; - }); + modules['appkit/components/foo-bar/template'] = 'in-components'; + modules['appkit/foo-bar/template'] = 'at-root'; - loader.define('appkit/foo-bar/template', [], function () { - assert.ok(true, 'appkit/foo-bar/template was used'); - return 'whatever'; - }); + let result = resolver.resolve('template:components/foo-bar'); - resolver.resolve('template:components/foo-bar'); + assert.strictEqual( + result, + 'at-root', + 'template was found without components/ root' + ); }); test('will use pods format to lookup components in components/', function (assert) { - assert.expect(3); - let expectedComponent = { isComponentFactory: true }; - loader.define('appkit/components/foo-bar/template', [], function () { - assert.ok(true, 'appkit/components was used'); - return 'whatever'; - }); - - loader.define('appkit/components/foo-bar/component', [], function () { - assert.ok(true, 'appkit/components was used'); - return { default: expectedComponent }; - }); + modules['appkit/components/foo-bar/template'] = 'the-template'; + modules['appkit/components/foo-bar/component'] = { + default: expectedComponent, + }; - resolver.resolve('template:components/foo-bar'); + let template = resolver.resolve('template:components/foo-bar'); let component = resolver.resolve('component:foo-bar'); - assert.equal(component, expectedComponent, 'default export was returned'); + assert.ok(template, 'template was resolved'); + assert.strictEqual( + component, + expectedComponent, + 'default export was returned' + ); }); test('will not lookup routes in components/', function (assert) { - assert.expect(1); + modules['appkit/components/foo-bar/route'] = { isRouteFactory: true }; + modules['appkit/routes/foo-bar'] = { isRouteFactory: true }; - loader.define('appkit/components/foo-bar/route', [], function () { - assert.ok(false, 'appkit/components was used'); - return { isRouteFactory: true }; - }); + let result = resolver.resolve('route:foo-bar'); - loader.define('appkit/routes/foo-bar', [], function () { - assert.ok(true, 'appkit/routes was used'); - return { isRouteFactory: true }; - }); - - resolver.resolve('route:foo-bar'); + assert.strictEqual( + result, + modules['appkit/routes/foo-bar'], + 'routes/ was used, not components/' + ); }); test('will not lookup non component templates in components/', function (assert) { - assert.expect(1); + modules['appkit/components/foo-bar/template'] = 'component-template'; + modules['appkit/templates/foo-bar'] = 'regular-template'; - loader.define('appkit/components/foo-bar/template', [], function () { - assert.ok(false, 'appkit/components was used'); - return 'whatever'; - }); + let result = resolver.resolve('template:foo-bar'); - loader.define('appkit/templates/foo-bar', [], function () { - assert.ok(true, 'appkit/templates was used'); - return 'whatever'; - }); - - resolver.resolve('template:foo-bar'); + assert.strictEqual( + result, + 'regular-template', + 'templates/ was used, not components/' + ); }); module('custom pluralization'); test('will use the pluralization specified for a given type', function (assert) { - assert.expect(1); - setupResolver({ namespace: { modulePrefix: 'appkit', }, - pluralizedTypes: { sheep: 'sheep', octipus: 'octipii', }, }); - loader.define('appkit/sheep/baaaaaa', [], function () { - assert.ok(true, 'custom pluralization used'); - return 'whatever'; - }); + modules['appkit/sheep/baaaaaa'] = 'whatever'; + + let result = resolver.resolve('sheep:baaaaaa'); - resolver.resolve('sheep:baaaaaa'); + assert.strictEqual(result, 'whatever', 'custom pluralization was used'); }); test("will pluralize 'config' as 'config' by default", function (assert) { - assert.expect(1); - setupResolver(); - loader.define('appkit/config/environment', [], function () { - assert.ok(true, 'config/environment is found'); - return 'whatever'; - }); + modules['appkit/config/environment'] = 'whatever'; + + let result = resolver.resolve('config:environment'); - resolver.resolve('config:environment'); + assert.strictEqual(result, 'whatever', 'config/environment is found'); }); test("'config' can be overridden", function (assert) { - assert.expect(1); - setupResolver({ namespace: { modulePrefix: 'appkit', }, - pluralizedTypes: { config: 'super-duper-config', }, }); - loader.define('appkit/super-duper-config/environment', [], function () { - assert.ok(true, 'super-duper-config/environment is found'); - return 'whatever'; - }); + modules['appkit/super-duper-config/environment'] = 'whatever'; + + let result = resolver.resolve('config:environment'); - resolver.resolve('config:environment'); + assert.strictEqual( + result, + 'whatever', + 'super-duper-config/environment is found' + ); }); }); diff --git a/packages/@ember/engine/tests/resolver/registry_test.ts b/packages/@ember/engine/tests/resolver/registry_test.ts index eb755c196eb..0301ad45398 100644 --- a/packages/@ember/engine/tests/resolver/registry_test.ts +++ b/packages/@ember/engine/tests/resolver/registry_test.ts @@ -1,53 +1,57 @@ import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; +import Application from '@ember/application'; +import Resolver from '@ember/engine/lib/strict-resolver'; +import Service from '@ember/service'; +import type ApplicationInstance from '@ember/application/instance'; -module('Registry', function (hooks) { - setupTest(hooks); - - test('has the router', function (assert) { - // eslint-disable-next-line ember/no-private-routing-service - const router = this.owner.lookup('router:main'); +module('strict-resolver | Registry with Application', function () { + test('registered stuff can be looked up', async function (assert) { + class Foo { + static create() { + return new this(); + } - assert.ok(router); - }); + two = 2; + } - test('has a manually registered service', function (assert) { - const manual = this.owner.lookup('service:manual') as { weDidIt: boolean }; + let app = Application.create({ + Resolver: Resolver.withModules({}), + modulePrefix: 'test-app', + rootElement: '#qunit-fixture', + autoboot: false, + }); - assert.ok(manual); - assert.ok(manual.weDidIt); - }); + let instance = (await app.visit('/')) as ApplicationInstance; - test('has a manually registered (shorthand) service', function (assert) { - const manual = this.owner.lookup('service:manual-shorthand') as { - weDidIt: boolean; - }; + instance.register('not-standard:main', Foo); - assert.ok(manual); - assert.ok(manual.weDidIt); - }); + let value = instance.lookup('not-standard:main') as Foo; - test('has a service from import.meta.glob', function (assert) { - const metaGlob = this.owner.lookup('service:from-meta-glob') as { - weDidIt: boolean; - }; + assert.strictEqual(value.two, 2); - assert.ok(metaGlob); - assert.ok(metaGlob.weDidIt); + instance.destroy(); }); - test('registered stuff can be looked up', function (assert) { - class Foo { - static create() { - return new this(); - } - - two = 2; + test('resolves modules provided via withModules', async function (assert) { + class MyService extends Service { + weDidIt = true; } - this.owner.register('not-standard:main', Foo); - const value = this.owner.lookup('not-standard:main') as Foo; + let app = Application.create({ + Resolver: Resolver.withModules({ + 'test-app/services/my-thing': { default: MyService }, + }), + modulePrefix: 'test-app', + rootElement: '#qunit-fixture', + autoboot: false, + }); - assert.strictEqual(value.two, 2); + let 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'); + + instance.destroy(); }); }); diff --git a/packages/@ember/engine/tests/resolver/underscore_test.js b/packages/@ember/engine/tests/resolver/underscore_test.js index 9dddd912c67..6b274df3882 100644 --- a/packages/@ember/engine/tests/resolver/underscore_test.js +++ b/packages/@ember/engine/tests/resolver/underscore_test.js @@ -1,13 +1,17 @@ import { module } from 'qunit'; -import { underscore } from 'ember-resolver/string/index'; -import createTestFunction from './helpers/create-test-function'; +import { underscore } from '@ember/engine/lib/strict-resolver/string'; +import createTestFunction from './create-test-function'; -module('underscore', function () { +module('strict-resolver | underscore', function () { const test = createTestFunction(underscore); test('my favorite items', 'my_favorite_items', 'with normal string'); test('css-class-name', 'css_class_name', 'with dasherized string'); - test('action_name', 'action_name', 'does nothing with underscored string'); + test( + 'action_name', + 'action_name', + 'does nothing with underscored string' + ); test('innerHTML', 'inner_html', 'with camelcased string'); test( 'PrivateDocs/OwnerInvoice', diff --git a/packages/@ember/engine/tests/resolver/with-modues-test.js b/packages/@ember/engine/tests/resolver/with-modules-test.js similarity index 74% rename from packages/@ember/engine/tests/resolver/with-modues-test.js rename to packages/@ember/engine/tests/resolver/with-modules-test.js index 8a91d9204f1..6db7ab37ac8 100644 --- a/packages/@ember/engine/tests/resolver/with-modues-test.js +++ b/packages/@ember/engine/tests/resolver/with-modules-test.js @@ -1,9 +1,7 @@ -/* eslint-disable no-console */ - import { module, test } from 'qunit'; -import Resolver from 'ember-resolver'; +import Resolver from '@ember/engine/lib/strict-resolver'; -module('ember-resolver withModules', function () { +module('strict-resolver | withModules', function () { test('explicit withModules', function (assert) { let resolver = Resolver.withModules({ 'alpha/components/hello': { @@ -17,13 +15,18 @@ module('ember-resolver withModules', function () { }); test('can resolve self', function (assert) { - let resolver = Resolver.create({ namespace: { modulePrefix: 'alpha' } }); - assert.strictEqual(resolver, resolver.resolve('resolver:current').create()); + let resolver = Resolver.withModules({}).create({ + namespace: { modulePrefix: 'alpha' }, + }); + assert.strictEqual( + resolver, + resolver.resolve('resolver:current').create() + ); }); test('can addModules', function (assert) { let startingModules = {}; - let resolver = Resolver.withModules({}).create({ + let resolver = Resolver.withModules(startingModules).create({ namespace: { modulePrefix: 'alpha' }, }); From f90e45cb80d5e71b0922509e7bc8f130d7373a6d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:46:47 -0400 Subject: [PATCH 04/25] Replace classic resolver with StrictResolver per RFC#1132 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the classic ember-resolver copy with the StrictResolver from the polyfill/strict-resolver branch (~120 lines vs ~450) - Integrate into Engine: add modules/plurals properties, modify resolverFor() to create StrictResolver when modules is set - Modules use relative ./paths (normalized by stripping ./ and extensions) - No pods, no scoped packages, no AMD — simple type:name resolution - Remove classic-only utilities (cache, class-factory, string helpers) - Remove classic-only tests (pods, string utilities, custom prefixes) - Rewrite tests for StrictResolver API using modules = {} pattern - Application integration tests use Application.create({ modules: {...} }) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/@ember/engine/index.ts | 25 + packages/@ember/engine/lib/strict-resolver.ts | 505 +++--------------- .../engine/lib/strict-resolver/cache.js | 35 -- .../lib/strict-resolver/class-factory.js | 11 - .../engine/lib/strict-resolver/string.js | 132 ----- packages/@ember/engine/package.json | 3 +- .../engine/tests/resolver/-setup-resolver.js | 20 +- .../engine/tests/resolver/basic-test.js | 400 ++++---------- .../engine/tests/resolver/classify_test.js | 63 --- .../tests/resolver/create-test-function.js | 9 - .../tests/resolver/custom-prefixes-test.js | 24 - .../engine/tests/resolver/dasherize_test.js | 36 -- .../engine/tests/resolver/decamelize_test.js | 39 -- .../@ember/engine/tests/resolver/pods-test.js | 180 ------- .../engine/tests/resolver/registry_test.ts | 54 +- .../engine/tests/resolver/underscore_test.js | 31 -- .../tests/resolver/with-modules-test.js | 48 -- 17 files changed, 251 insertions(+), 1364 deletions(-) delete mode 100644 packages/@ember/engine/lib/strict-resolver/cache.js delete mode 100644 packages/@ember/engine/lib/strict-resolver/class-factory.js delete mode 100644 packages/@ember/engine/lib/strict-resolver/string.js delete mode 100644 packages/@ember/engine/tests/resolver/classify_test.js delete mode 100644 packages/@ember/engine/tests/resolver/create-test-function.js delete mode 100644 packages/@ember/engine/tests/resolver/custom-prefixes-test.js delete mode 100644 packages/@ember/engine/tests/resolver/dasherize_test.js delete mode 100644 packages/@ember/engine/tests/resolver/decamelize_test.js delete mode 100644 packages/@ember/engine/tests/resolver/pods-test.js delete mode 100644 packages/@ember/engine/tests/resolver/underscore_test.js delete mode 100644 packages/@ember/engine/tests/resolver/with-modules-test.js diff --git a/packages/@ember/engine/index.ts b/packages/@ember/engine/index.ts index e16240fa87d..cafb2eaf610 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'; @@ -330,6 +331,26 @@ class Engine extends Namespace.extend(RegistryProxyMixin) { */ declare Resolver: 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); this.buildRegistry(); @@ -462,6 +483,10 @@ class Engine extends Namespace.extend(RegistryProxyMixin) { @return {*} the resolved value for a given lookup */ function resolverFor(namespace: Engine) { + if (namespace.modules) { + return new StrictResolver(namespace.modules, namespace.plurals); + } + let ResolverClass = namespace.Resolver; let props = { namespace }; return ResolverClass.create(props); diff --git a/packages/@ember/engine/lib/strict-resolver.ts b/packages/@ember/engine/lib/strict-resolver.ts index 299a43c15cf..7df7de53089 100644 --- a/packages/@ember/engine/lib/strict-resolver.ts +++ b/packages/@ember/engine/lib/strict-resolver.ts @@ -1,461 +1,122 @@ -import { dasherize, classify, underscore } from './strict-resolver/string'; -import classFactory from './strict-resolver/class-factory'; - -/** - * This module defines a Resolver that is aware of es6 modules - * via explicit module maps. - * - * Modules are provided via `Resolver.withModules(moduleMap)`, - * where the moduleMap keys are module paths and values are - * module objects (with `default` exports). - */ -export default class Resolver { - static moduleBasedResolver = true; - moduleBasedResolver = true; - - _deprecatedPodModulePrefix = false; - _normalizeCache = Object.create(null); - - static create(props) { - return new this(props); - } - - /** - A listing of functions to test for moduleName's based on the provided - `parsedName`. This allows easy customization of additional module based - lookup patterns. - - @property moduleNameLookupPatterns - @returns {Ember.Array} - */ - moduleNameLookupPatterns = [ - this.podBasedModuleName, - this.podBasedComponentsInSubdir, - this.mainModuleName, - this.defaultModuleName, - this.nestedColocationComponentModuleName, - ]; - - static withModules(explicitModules) { - return class extends this { - static explicitModules = explicitModules; - }; - } - - constructor(props) { - Object.assign(this, props); - if (!this._moduleRegistry) { - let explicitModules = this.constructor.explicitModules; - if (explicitModules) { - this._moduleRegistry = { - moduleNames() { - return Object.keys(explicitModules); - }, - has(name) { - return Boolean(explicitModules[name]); - }, - get(name) { - return explicitModules[name]; - }, - addModules(modules) { - explicitModules = Object.assign({}, explicitModules, modules); - }, - }; - } else { - throw new Error( - 'Resolver requires explicit modules. Use Resolver.withModules(moduleMap) to provide them.' - ); +import type { Factory, Resolver } from '@ember/owner'; + +export class StrictResolver implements Resolver { + #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); } } - - this.pluralizedTypes = this.pluralizedTypes || Object.create(null); - - if (!this.pluralizedTypes.config) { - this.pluralizedTypes.config = 'config'; - } - } - - makeToString(factory, fullName) { - return '' + this.namespace.modulePrefix + '@' + fullName + ':'; - } - - shouldWrapInClassFactory(/* module, parsedName */) { - return false; - } - - parseName(fullName) { - if (fullName.parsedName === true) { - return fullName; - } - - let prefix, type, name; - let fullNameParts = fullName.split('@'); - - if (fullNameParts.length === 3) { - if (fullNameParts[0].length === 0) { - // leading scoped namespace: `@scope/pkg@type:name` - prefix = `@${fullNameParts[1]}`; - let prefixParts = fullNameParts[2].split(':'); - type = prefixParts[0]; - name = prefixParts[1]; - } else { - // interweaved scoped namespace: `type:@scope/pkg@name` - prefix = `@${fullNameParts[1]}`; - type = fullNameParts[0].slice(0, -1); - name = fullNameParts[2]; - } - - if (type === 'template:components') { - name = `components/${name}`; - type = 'template'; - } - } else if (fullNameParts.length === 2) { - let prefixParts = fullNameParts[0].split(':'); - - if (prefixParts.length === 2) { - if (prefixParts[1].length === 0) { - type = prefixParts[0]; - name = `@${fullNameParts[1]}`; - } else { - prefix = prefixParts[1]; - type = prefixParts[0]; - name = fullNameParts[1]; - } - } else { - let nameParts = fullNameParts[1].split(':'); - - prefix = fullNameParts[0]; - type = nameParts[0]; - name = nameParts[1]; - } - - if (type === 'template' && prefix.lastIndexOf('components/', 0) === 0) { - name = `components/${name}`; - prefix = prefix.slice(11); - } - } else { - fullNameParts = fullName.split(':'); - type = fullNameParts[0]; - name = fullNameParts[1]; - } - - let fullNameWithoutType = name; - let namespace = this.namespace; - let root = namespace; - - return { - parsedName: true, - fullName: fullName, - prefix: prefix || this.prefix({ type: type }), - type: type, - fullNameWithoutType: fullNameWithoutType, - name: name, - root: root, - resolveMethodName: 'resolve' + classify(type), - }; } - resolveOther(parsedName) { - assert('`modulePrefix` must be defined', this.namespace.modulePrefix); - - let normalizedModuleName = this.findModuleName(parsedName); - - if (normalizedModuleName) { - let defaultExport = this._extractDefaultExport(normalizedModuleName); - - if (defaultExport === undefined) { - throw new Error( - ` Expected to find: '${parsedName.fullName}' within '${normalizedModuleName}' but got 'undefined'. Did you forget to 'export default' within '${normalizedModuleName}'?` - ); - } - - if (this.shouldWrapInClassFactory(defaultExport, parsedName)) { - defaultExport = classFactory(defaultExport); - } - - return defaultExport; + addModules(modules: Record) { + for (let [moduleName, module] of Object.entries(modules)) { + this.#modules.set(this.#normalizeModule(moduleName), module); } } - normalize(fullName) { - return ( - this._normalizeCache[fullName] || - (this._normalizeCache[fullName] = this._normalize(fullName)) - ); + #normalizeModule(moduleName: string) { + return moduleName.replace(fileExtension, '').replace(leadingDotSlash, ''); } - resolve(fullName) { - if (fullName === 'resolver:current') { - return { create: () => this }; - } - let parsedName = this.parseName(fullName); - let resolveMethodName = parsedName.resolveMethodName; - let resolved; - - if (typeof this[resolveMethodName] === 'function') { - resolved = this[resolveMethodName](parsedName); - } - - if (resolved == null) { - resolved = this.resolveOther(parsedName); - } - - return resolved; + #plural(s: string) { + return this.#plurals.get(s) ?? s + 's'; } - addModules(modules) { - if (!this._moduleRegistry.addModules) { - throw new Error( - `addModules is only supported when your Resolver has been configured to use static modules via Resolver.withModules()` - ); - } - this._moduleRegistry.addModules(modules); - } - - _normalize(fullName) { - // A) Convert underscores to dashes - // B) Convert camelCase to dash-case, except for components (their - // templates) and helpers where we want to avoid shadowing camelCase - // expressions - // C) replace `.` with `/` in order to make nested controllers work in the following cases - // 1. `needs: ['posts/post']` - // 2. `{{render "posts/post"}}` - // 3. `this.render('posts/post')` from Route - - let split = fullName.split(':'); - if (split.length > 1) { - let type = split[0]; - - if ( - type === 'component' || - type === 'helper' || - type === 'modifier' || - (type === 'template' && split[1].indexOf('components/') === 0) - ) { - return type + ':' + split[1].replace(/_/g, '-'); - } else { - return type + ':' + dasherize(split[1].replace(/\./g, '/')); + 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]) { + let result = strategy.call(this, type, name); + if (result) { + return this.#extractDefaultExport(result.hit); } - } else { - return fullName; } + return undefined; } - pluralize(type) { - return ( - this.pluralizedTypes[type] || (this.pluralizedTypes[type] = type + 's') - ); - } - - podBasedLookupWithPrefix(podPrefix, parsedName) { - let fullNameWithoutType = parsedName.fullNameWithoutType; - - if (parsedName.type === 'template') { - fullNameWithoutType = fullNameWithoutType.replace(/^components\//, ''); + #extractDefaultExport(module: any): Factory | object | undefined { + if (module && module['default']) { + module = module['default']; } - - return podPrefix + '/' + fullNameWithoutType + '/' + parsedName.type; + return module as Factory | object | undefined; } - podBasedModuleName(parsedName) { - let podPrefix = - this.namespace.podModulePrefix || this.namespace.modulePrefix; - - return this.podBasedLookupWithPrefix(podPrefix, parsedName); + normalize(fullName: `${string}:${string}`): `${string}:${string}` { + let [type, name] = fullName.split(':') as [string, string]; + name = this.#normalizeName(type, name); + return `${type}:${name}`; } - podBasedComponentsInSubdir(parsedName) { - let podPrefix = - this.namespace.podModulePrefix || this.namespace.modulePrefix; - podPrefix = podPrefix + '/components'; - + #normalizeName(type: string, name: string): string { if ( - parsedName.type === 'component' || - /^components/.test(parsedName.fullNameWithoutType) - ) { - return this.podBasedLookupWithPrefix(podPrefix, parsedName); - } - } - - resolveEngine(parsedName) { - let engineName = parsedName.fullNameWithoutType; - let engineModule = engineName + '/engine'; - - if (this._moduleRegistry.has(engineModule)) { - return this._extractDefaultExport(engineModule); - } - } - - resolveRouteMap(parsedName) { - let engineName = parsedName.fullNameWithoutType; - let engineRoutesModule = engineName + '/routes'; - - if (this._moduleRegistry.has(engineRoutesModule)) { - let routeMap = this._extractDefaultExport(engineRoutesModule); - - assert( - `The route map for ${engineName} should be wrapped by 'buildRoutes' before exporting.`, - routeMap.isRouteMap - ); - - return routeMap; - } - } - - resolveTemplate(parsedName) { - return this.resolveOther(parsedName); - } - - mainModuleName(parsedName) { - if (parsedName.fullNameWithoutType === 'main') { - // if router:main or adapter:main look for a module with just the type first - return parsedName.prefix + '/' + parsedName.type; - } - } - - defaultModuleName(parsedName) { - return ( - parsedName.prefix + - '/' + - this.pluralize(parsedName.type) + - '/' + - parsedName.fullNameWithoutType - ); - } - - nestedColocationComponentModuleName(parsedName) { - if (parsedName.type === 'component') { - return ( - parsedName.prefix + - '/' + - this.pluralize(parsedName.type) + - '/' + - parsedName.fullNameWithoutType + - '/index' - ); - } - } - - prefix(parsedName) { - let tmpPrefix = this.namespace.modulePrefix; - - if (this.namespace[parsedName.type + 'Prefix']) { - tmpPrefix = this.namespace[parsedName.type + 'Prefix']; - } - - return tmpPrefix; - } - - findModuleName(parsedName) { - let moduleNameLookupPatterns = this.moduleNameLookupPatterns; - let moduleName; - - for ( - let index = 0, length = moduleNameLookupPatterns.length; - index < length; - index++ + type === 'component' || + type === 'helper' || + type === 'modifier' || + (type === 'template' && name.indexOf('components/') === 0) ) { - let item = moduleNameLookupPatterns[index]; - - let tmpModuleName = item.call(this, parsedName); - - // allow treat all dashed and all underscored as the same thing - // supports components with dashes and other stuff with underscores. - if (tmpModuleName) { - tmpModuleName = this.chooseModuleName(tmpModuleName); - } - - if (tmpModuleName && this._moduleRegistry.has(tmpModuleName)) { - moduleName = tmpModuleName; - } - - if (moduleName) { - return moduleName; - } + return name.replace(/_/g, '-'); + } else { + return dasherize(name.replace(/\./g, '/')); } } - chooseModuleName(moduleName) { - let underscoredModuleName = underscore(moduleName); - - if ( - moduleName !== underscoredModuleName && - this._moduleRegistry.has(moduleName) && - this._moduleRegistry.has(underscoredModuleName) - ) { - throw new TypeError( - `Ambiguous module names: '${moduleName}' and '${underscoredModuleName}'` - ); - } - - if (this._moduleRegistry.has(moduleName)) { - return moduleName; - } else if (this._moduleRegistry.has(underscoredModuleName)) { - return underscoredModuleName; + #resolveSelf(type: string, name: string): Result { + if (type === 'resolver' && name === 'current') { + return { + hit: { + create: () => this, + }, + }; } + return undefined; } - knownForType(type) { - let moduleKeys = this._moduleRegistry.moduleNames(); - - let items = Object.create(null); - for (let index = 0, length = moduleKeys.length; index < length; index++) { - let moduleName = moduleKeys[index]; - let fullname = this.translateToContainerFullname(type, moduleName); - - if (fullname) { - items[fullname] = true; + #mainLookup(type: string, name: string): Result { + if (name === 'main') { + let module = this.#modules.get(type); + if (module) { + return { hit: module }; } } - - return items; + return undefined; } - translateToContainerFullname(type, moduleName) { - let prefix = this.prefix({ type }); - - // Note: using string manipulation here rather than regexes for better performance. - // pod modules - // '^' + prefix + '/(.+)/' + type + '$' - let podPrefix = prefix + '/'; - let podSuffix = '/' + type; - let start = moduleName.indexOf(podPrefix); - let end = moduleName.indexOf(podSuffix); - - if ( - start === 0 && - end === moduleName.length - podSuffix.length && - moduleName.length > podPrefix.length + podSuffix.length - ) { - return type + ':' + moduleName.slice(start + podPrefix.length, end); - } - - // non-pod modules - // '^' + prefix + '/' + pluralizedType + '/(.+)$' - let pluralizedType = this.pluralize(type); - let nonPodPrefix = prefix + '/' + pluralizedType + '/'; - - if ( - moduleName.indexOf(nonPodPrefix) === 0 && - moduleName.length > nonPodPrefix.length - ) { - return type + ':' + moduleName.slice(nonPodPrefix.length); + #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; } +} - _extractDefaultExport(normalizedModuleName) { - let module = this._moduleRegistry.get(normalizedModuleName); +const fileExtension = /\.\w{1,4}$/; +const leadingDotSlash = /^\.\//; - if (module && module['default']) { - module = module['default']; - } - - return module; - } +const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; +function decamelize(str: string): string { + return str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase(); } -function assert(message, check) { - if (!check) { - throw new Error(message); - } +const STRING_DASHERIZE_REGEXP = /[ _]/g; +function dasherize(key: string): string { + return decamelize(key).replace(STRING_DASHERIZE_REGEXP, '-'); } + +type Result = + | { + hit: any; + } + | undefined; diff --git a/packages/@ember/engine/lib/strict-resolver/cache.js b/packages/@ember/engine/lib/strict-resolver/cache.js deleted file mode 100644 index 68902ada388..00000000000 --- a/packages/@ember/engine/lib/strict-resolver/cache.js +++ /dev/null @@ -1,35 +0,0 @@ -export default class Cache { - constructor(limit, func, store) { - this.limit = limit; - this.func = func; - this.store = store; - this.size = 0; - this.misses = 0; - this.hits = 0; - this.store = store || new Map(); - } - get(key) { - let value = this.store.get(key); - if (this.store.has(key)) { - this.hits++; - return this.store.get(key); - } else { - this.misses++; - value = this.set(key, this.func(key)); - } - return value; - } - set(key, value) { - if (this.limit > this.size) { - this.size++; - this.store.set(key, value); - } - return value; - } - purge() { - this.store.clear(); - this.size = 0; - this.hits = 0; - this.misses = 0; - } -} diff --git a/packages/@ember/engine/lib/strict-resolver/class-factory.js b/packages/@ember/engine/lib/strict-resolver/class-factory.js deleted file mode 100644 index 4fc4768df25..00000000000 --- a/packages/@ember/engine/lib/strict-resolver/class-factory.js +++ /dev/null @@ -1,11 +0,0 @@ -export default function classFactory(klass) { - return { - create(injections) { - if (typeof klass.extend === 'function') { - return klass.extend(injections); - } else { - return klass; - } - }, - }; -} diff --git a/packages/@ember/engine/lib/strict-resolver/string.js b/packages/@ember/engine/lib/strict-resolver/string.js deleted file mode 100644 index 9773829941d..00000000000 --- a/packages/@ember/engine/lib/strict-resolver/string.js +++ /dev/null @@ -1,132 +0,0 @@ -/* eslint-disable no-useless-escape */ -import Cache from './cache'; -let STRINGS = {}; -export function setStrings(strings) { - STRINGS = strings; -} -export function getStrings() { - return STRINGS; -} -export function getString(name) { - return STRINGS[name]; -} -const STRING_DASHERIZE_REGEXP = /[ _]/g; -const STRING_DASHERIZE_CACHE = new Cache(1000, (key) => - decamelize(key).replace(STRING_DASHERIZE_REGEXP, '-') -); -const STRING_CLASSIFY_REGEXP_1 = /^(\-|_)+(.)?/; -const STRING_CLASSIFY_REGEXP_2 = /(.)(\-|\_|\.|\s)+(.)?/g; -const STRING_CLASSIFY_REGEXP_3 = /(^|\/|\.)([a-z])/g; -const CLASSIFY_CACHE = new Cache(1000, (str) => { - const replace1 = (_match, _separator, chr) => - chr ? `_${chr.toUpperCase()}` : ''; - const replace2 = (_match, initialChar, _separator, chr) => - initialChar + (chr ? chr.toUpperCase() : ''); - const parts = str.split('/'); - for (let i = 0; i < parts.length; i++) { - parts[i] = parts[i] - .replace(STRING_CLASSIFY_REGEXP_1, replace1) - .replace(STRING_CLASSIFY_REGEXP_2, replace2); - } - return parts - .join('/') - .replace(STRING_CLASSIFY_REGEXP_3, (match /*, separator, chr */) => - match.toUpperCase() - ); -}); -const STRING_UNDERSCORE_REGEXP_1 = /([a-z\d])([A-Z]+)/g; -const STRING_UNDERSCORE_REGEXP_2 = /\-|\s+/g; -const UNDERSCORE_CACHE = new Cache(1000, (str) => - str - .replace(STRING_UNDERSCORE_REGEXP_1, '$1_$2') - .replace(STRING_UNDERSCORE_REGEXP_2, '_') - .toLowerCase() -); -const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; -const DECAMELIZE_CACHE = new Cache(1000, (str) => - str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase() -); -/** - Converts a camelized string into all lower case separated by underscores. - - ```javascript - import { decamelize } from '@ember/string'; - - decamelize('innerHTML'); // 'inner_html' - decamelize('action_name'); // 'action_name' - decamelize('css-class-name'); // 'css-class-name' - decamelize('my favorite items'); // 'my favorite items' - ``` - - @method decamelize - @param {String} str The string to decamelize. - @return {String} the decamelized string. - @public -*/ -export function decamelize(str) { - return DECAMELIZE_CACHE.get(str); -} -/** - Replaces underscores, spaces, or camelCase with dashes. - - ```javascript - import { dasherize } from '@ember/string'; - - dasherize('innerHTML'); // 'inner-html' - dasherize('action_name'); // 'action-name' - dasherize('css-class-name'); // 'css-class-name' - dasherize('my favorite items'); // 'my-favorite-items' - dasherize('privateDocs/ownerInvoice'; // 'private-docs/owner-invoice' - ``` - - @method dasherize - @param {String} str The string to dasherize. - @return {String} the dasherized string. - @public -*/ -export function dasherize(str) { - return STRING_DASHERIZE_CACHE.get(str); -} -/** - Returns the UpperCamelCase form of a string. - - ```javascript - import { classify } from '@ember/string'; - - classify('innerHTML'); // 'InnerHTML' - classify('action_name'); // 'ActionName' - classify('css-class-name'); // 'CssClassName' - classify('my favorite items'); // 'MyFavoriteItems' - classify('private-docs/owner-invoice'); // 'PrivateDocs/OwnerInvoice' - ``` - - @method classify - @param {String} str the string to classify - @return {String} the classified string - @public -*/ -export function classify(str) { - return CLASSIFY_CACHE.get(str); -} -/** - More general than decamelize. Returns the lower\_case\_and\_underscored - form of a string. - - ```javascript - import { underscore } from '@ember/string'; - - underscore('innerHTML'); // 'inner_html' - underscore('action_name'); // 'action_name' - underscore('css-class-name'); // 'css_class_name' - underscore('my favorite items'); // 'my_favorite_items' - underscore('privateDocs/ownerInvoice'); // 'private_docs/owner_invoice' - ``` - - @method underscore - @param {String} str The string to underscore. - @return {String} the underscored string. - @public -*/ -export function underscore(str) { - return UNDERSCORE_CACHE.get(str); -} diff --git a/packages/@ember/engine/package.json b/packages/@ember/engine/package.json index b042cedbf72..75142563e1e 100644 --- a/packages/@ember/engine/package.json +++ b/packages/@ember/engine/package.json @@ -6,8 +6,7 @@ ".": "./index.ts", "./instance": "./instance.ts", "./parent": "./parent.ts", - "./lib/strict-resolver": "./lib/strict-resolver.ts", - "./lib/strict-resolver/string": "./lib/strict-resolver/string.js" + "./lib/strict-resolver": "./lib/strict-resolver.ts" }, "dependencies": { "@ember/-internals": "workspace:*", diff --git a/packages/@ember/engine/tests/resolver/-setup-resolver.js b/packages/@ember/engine/tests/resolver/-setup-resolver.js index f21be6ddbc0..bfb361425d7 100644 --- a/packages/@ember/engine/tests/resolver/-setup-resolver.js +++ b/packages/@ember/engine/tests/resolver/-setup-resolver.js @@ -1,24 +1,12 @@ -import Resolver from '@ember/engine/lib/strict-resolver'; - -import { setOwner } from '@ember/owner'; +import { StrictResolver } from '@ember/engine/lib/strict-resolver'; export let resolver; export let modules; export function setupResolver(options = {}) { - let owner = options.owner; - delete options.owner; - - if (!options.namespace) { - options.namespace = { modulePrefix: 'appkit' }; - } - - modules = Object.create(null); + modules = {}; - let ResolverClass = Resolver.withModules(modules); - resolver = ResolverClass.create(options); + let plurals = options.plurals; - if (owner) { - setOwner(resolver, owner); - } + resolver = new StrictResolver(modules, plurals); } diff --git a/packages/@ember/engine/tests/resolver/basic-test.js b/packages/@ember/engine/tests/resolver/basic-test.js index 5eb0e0d726e..28bd884d315 100644 --- a/packages/@ember/engine/tests/resolver/basic-test.js +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -1,4 +1,5 @@ import { module, test } from 'qunit'; +import { StrictResolver } from '@ember/engine/lib/strict-resolver'; import { setupResolver, resolver, modules } from './-setup-resolver'; module('strict-resolver | basic', function (hooks) { @@ -8,7 +9,8 @@ module('strict-resolver | basic', function (hooks) { test('can lookup something', function (assert) { let expected = {}; - modules['appkit/adapters/post'] = { default: expected }; + modules['./adapters/post'] = { default: expected }; + resolver.addModules(modules); let adapter = resolver.resolve('adapter:post'); @@ -16,129 +18,21 @@ module('strict-resolver | basic', function (hooks) { assert.strictEqual(adapter, expected, 'default export was returned'); }); - test('can lookup something in another namespace', function (assert) { + test('can lookup a service', function (assert) { let expected = {}; - modules['other/adapters/post'] = { default: expected }; + modules['./services/session'] = { default: expected }; + resolver.addModules(modules); - let adapter = resolver.resolve('other@adapter:post'); + let service = resolver.resolve('service:session'); - assert.ok(adapter, 'adapter was returned'); - assert.strictEqual(adapter, expected, 'default export was returned'); - }); - - test('can lookup something in another namespace with an @ scope', function (assert) { - let expected = {}; - modules['@scope/other/adapters/post'] = { default: expected }; - - let adapter = resolver.resolve('@scope/other@adapter:post'); - - assert.ok(adapter, 'adapter was returned'); - assert.strictEqual(adapter, expected, 'default export was returned'); - }); - - test('can lookup something with an @ sign', function (assert) { - let expected = {}; - modules['appkit/helpers/@content-helper'] = { default: expected }; - - let helper = resolver.resolve('helper:@content-helper'); - - assert.ok(helper, 'helper was returned'); - assert.strictEqual(helper, expected, 'default export was returned'); - }); - - test('can lookup something in another namespace with different syntax', function (assert) { - let expected = {}; - modules['other/adapters/post'] = { default: expected }; - - let adapter = resolver.resolve('adapter:other@post'); - - assert.ok(adapter, 'adapter was returned'); - assert.strictEqual(adapter, expected, 'default export was returned'); - }); - - test('can lookup something in another namespace with an @ scope with different syntax', function (assert) { - let expected = {}; - modules['@scope/other/adapters/post'] = { default: expected }; - - let adapter = resolver.resolve('adapter:@scope/other@post'); - - assert.ok(adapter, 'adapter was returned'); - assert.strictEqual(adapter, expected, 'default export was returned'); - }); - - test('can lookup a view in another namespace', function (assert) { - let expected = { isViewFactory: true }; - modules['other/views/post'] = { default: expected }; - - let view = resolver.resolve('other@view:post'); - - assert.ok(view, 'view was returned'); - assert.strictEqual(view, expected, 'default export was returned'); - }); - - test('can lookup a view in another namespace with an @ scope', function (assert) { - let expected = { isViewFactory: true }; - modules['@scope/other/views/post'] = { default: expected }; - - let view = resolver.resolve('@scope/other@view:post'); - - assert.ok(view, 'view was returned'); - assert.strictEqual(view, expected, 'default export was returned'); - }); - - test('can lookup a view in another namespace with different syntax', function (assert) { - let expected = { isViewFactory: true }; - modules['other/views/post'] = { default: expected }; - - let view = resolver.resolve('view:other@post'); - - assert.ok(view, 'view was returned'); - assert.strictEqual(view, expected, 'default export was returned'); - }); - - test('can lookup a view in another namespace with an @ scope with different syntax', function (assert) { - let expected = { isViewFactory: true }; - modules['@scope/other/views/post'] = { default: expected }; - - let view = resolver.resolve('view:@scope/other@post'); - - assert.ok(view, 'view was returned'); - assert.strictEqual(view, expected, 'default export was returned'); - }); - - test('can lookup a component template in another namespace with different syntax', function (assert) { - let expected = { isTemplate: true }; - modules['other/templates/components/foo-bar'] = { default: expected }; - - let template = resolver.resolve('template:components/other@foo-bar'); - - assert.strictEqual(template, expected, 'default export was returned'); - }); - - test('can lookup a component template in another namespace with an @ scope with different syntax', function (assert) { - let expected = { isTemplate: true }; - modules['@scope/other/templates/components/foo-bar'] = { - default: expected, - }; - - let template = resolver.resolve('template:components/@scope/other@foo-bar'); - - assert.strictEqual(template, expected, 'default export was returned'); - }); - - test('can lookup a view', function (assert) { - let expected = { isViewFactory: true }; - modules['appkit/views/queue-list'] = { default: expected }; - - let view = resolver.resolve('view:queue-list'); - - assert.ok(view, 'view was returned'); - assert.strictEqual(view, expected, 'default export was returned'); + assert.ok(service, 'service was returned'); + assert.strictEqual(service, expected, 'default export was returned'); }); test('can lookup a helper', function (assert) { let expected = { isHelperInstance: true }; - modules['appkit/helpers/reverse-list'] = { default: expected }; + modules['./helpers/reverse-list'] = { default: expected }; + resolver.addModules(modules); let helper = resolver.resolve('helper:reverse-list'); @@ -146,46 +40,9 @@ module('strict-resolver | basic', function (hooks) { assert.strictEqual(helper, expected, 'default export was returned'); }); - test('can lookup an engine', function (assert) { - let expected = {}; - modules['appkit/engine'] = { default: expected }; - - let engine = resolver.resolve('engine:appkit'); - - assert.ok(engine, 'engine was returned'); - assert.strictEqual(engine, expected, 'default export was returned'); - }); - - test('can lookup an engine from a scoped package', function (assert) { - let expected = {}; - modules['@some-scope/some-module/engine'] = { default: expected }; - - let engine = resolver.resolve('engine:@some-scope/some-module'); - - assert.ok(engine, 'engine was returned'); - assert.strictEqual(engine, expected, 'default export was returned'); - }); - - test('can lookup a route-map', function (assert) { - let expected = { isRouteMap: true }; - modules['appkit/routes'] = { default: expected }; - - let routeMap = resolver.resolve('route-map:appkit'); - - assert.ok(routeMap, 'route-map was returned'); - assert.strictEqual(routeMap, expected, 'default export was returned'); - }); - - test('errors if lookup of a route-map does not specify isRouteMap', function (assert) { - modules['appkit/routes'] = { default: { isRouteMap: false } }; - - assert.throws(() => { - resolver.resolve('route-map:appkit'); - }, /The route map for appkit should be wrapped by 'buildRoutes' before exporting/); - }); - test("will return the raw value if no 'default' is available", function (assert) { - modules['appkit/fruits/orange'] = 'is awesome'; + modules['./fruits/orange'] = 'is awesome'; + resolver.addModules(modules); assert.strictEqual( resolver.resolve('fruit:orange'), @@ -195,7 +52,8 @@ module('strict-resolver | basic', function (hooks) { }); test("will unwrap the 'default' export automatically", function (assert) { - modules['appkit/fruits/orange'] = { default: 'is awesome' }; + modules['./fruits/orange'] = { default: 'is awesome' }; + resolver.addModules(modules); assert.strictEqual( resolver.resolve('fruit:orange'), @@ -204,231 +62,139 @@ module('strict-resolver | basic', function (hooks) { ); }); - test('router:main is hard-coded to prefix/router.js', function (assert) { - modules['appkit/router'] = 'whatever'; + test('router:main is looked up as just "router" key', function (assert) { + modules['./router'] = 'the-router'; + resolver.addModules(modules); let result = resolver.resolve('router:main'); - assert.strictEqual(result, 'whatever', 'router:main was looked up'); + assert.strictEqual(result, 'the-router', 'router:main was looked up'); }); - test('store:main is looked up as prefix/store', function (assert) { - modules['appkit/store'] = 'whatever'; + test('store:main is looked up as just "store" key', function (assert) { + modules['./store'] = 'the-store'; + resolver.addModules(modules); let result = resolver.resolve('store:main'); - assert.strictEqual(result, 'whatever', 'store:main was looked up'); + assert.strictEqual(result, 'the-store', 'store:main was looked up'); }); - test('store:posts as prefix/stores/post', function (assert) { - modules['appkit/stores/post'] = 'whatever'; + test('store:post is looked up as stores/post', function (assert) { + modules['./stores/post'] = 'whatever'; + resolver.addModules(modules); let result = resolver.resolve('store:post'); assert.strictEqual(result, 'whatever', 'store:post was looked up'); }); - test('will raise error if both dasherized and underscored modules exist', function (assert) { - modules['appkit/big-bands/steve-miller-band'] = 'whatever'; - modules['appkit/big_bands/steve_miller_band'] = 'whatever'; + test('returns undefined for missing modules', function (assert) { + let result = resolver.resolve('service:nonexistent'); - assert.throws( - () => resolver.resolve('big-band:steve-miller-band'), - (e) => - e.message === - `Ambiguous module names: 'appkit/big-bands/steve-miller-band' and 'appkit/big_bands/steve_miller_band'`, - 'error with a descriptive value is thrown' - ); + assert.strictEqual(result, undefined, 'undefined was returned'); }); - test('will lookup an underscored version of the module name when the dasherized version is not found', function (assert) { - modules['appkit/big_bands/steve_miller_band'] = 'whatever'; + test('can resolve self via resolver:current', function (assert) { + let self = resolver.resolve('resolver:current'); - let result = resolver.resolve('big-band:steve-miller-band'); - - assert.strictEqual( - result, - 'whatever', - 'underscored version looked up properly' - ); + assert.ok(self, 'resolver:current returned a factory'); + assert.strictEqual(self.create(), resolver, 'factory creates the resolver'); }); - test('knownForType returns known modules for a given type', function (assert) { - modules['appkit/fruits/orange'] = { default: function orange() {} }; - modules['appkit/fruits/apple'] = { default: function apple() {} }; - modules['appkit/stuffs/other'] = { default: function other() {} }; - - let items = resolver.knownForType('fruit'); + test('addModules allows adding modules after construction', function (assert) { + let expected = {}; - assert.deepEqual(items, { - 'fruit:orange': true, - 'fruit:apple': true, + resolver.addModules({ + './components/hello': { default: expected }, }); - }); - test('knownForType can find both pod and non-pod factories', function (assert) { - modules['appkit/fruits/orange'] = { default: function orange() {} }; - modules['appkit/lemon/fruit'] = { default: function lemon() {} }; + let component = resolver.resolve('component:hello'); - let items = resolver.knownForType('fruit'); + assert.strictEqual(component, expected, 'component was resolved'); + }); - assert.deepEqual(items, { - 'fruit:orange': true, - 'fruit:lemon': true, + 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('if shouldWrapInClassFactory returns true a wrapped object is returned', function (assert) { - resolver.shouldWrapInClassFactory = function (defaultExport, parsedName) { - assert.strictEqual(defaultExport, 'foo'); - assert.strictEqual(parsedName.fullName, 'string:foo'); + 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' + ); + }); - return true; - }; + test('shorthand module registration (no default wrapper)', function (assert) { + let MyService = { create() { return this; } }; - modules['appkit/strings/foo'] = { default: 'foo' }; + let resolver2 = new StrictResolver({ + './services/my-thing': MyService, + }); - let value = resolver.resolve('string:foo'); + let result = resolver2.resolve('service:my-thing'); - assert.strictEqual(value.create(), 'foo'); + assert.strictEqual(result, MyService, 'shorthand module was resolved'); }); test('normalization', function (assert) { - assert.ok(resolver.normalize, 'resolver#normalize is present'); - - assert.strictEqual(resolver.normalize('foo:bar'), 'foo:bar'); - assert.strictEqual( resolver.normalize('controller:posts'), 'controller:posts' ); assert.strictEqual( - resolver.normalize('controller:posts_index'), + 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.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:postsIndex'), - 'controller:posts-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 + // helpers preserve camelCase (avoid shadowing template expressions) assert.strictEqual( - resolver.normalize('helper:make-fabulous'), - 'helper:make-fabulous' - ); - assert.strictEqual( - resolver.normalize('helper:fabulize'), - 'helper:fabulize' + resolver.normalize('helper:makeFabulous'), + 'helper:makeFabulous' ); 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' - ); + // components preserve camelCase 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' - ); - // deprecated when fabulously-missing actually exists, but normalize still returns it + // modifiers preserve camelCase assert.strictEqual( resolver.normalize('modifier:fabulouslyMissing'), 'modifier:fabulouslyMissing' ); }); - test('camel case modifier is not normalized', function (assert) { - let expected = {}; - modules['appkit/modifiers/other-thing'] = { default: 'oh no' }; - modules['appkit/modifiers/otherThing'] = { default: expected }; - - let modifier = resolver.resolve('modifier:otherThing'); - - assert.strictEqual(modifier, expected); - }); - test('normalization is idempotent', function (assert) { let examples = [ 'controller:posts', 'controller:posts.post.index', - 'controller:blog/posts.post_index', 'template:foo_bar', ]; @@ -439,4 +205,24 @@ module('strict-resolver | basic', function (hooks) { ); }); }); + + 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'); + }); }); diff --git a/packages/@ember/engine/tests/resolver/classify_test.js b/packages/@ember/engine/tests/resolver/classify_test.js deleted file mode 100644 index 301a07162b0..00000000000 --- a/packages/@ember/engine/tests/resolver/classify_test.js +++ /dev/null @@ -1,63 +0,0 @@ -import { module } from 'qunit'; -import { classify } from '@ember/engine/lib/strict-resolver/string'; -import createTestFunction from './create-test-function'; - -module('strict-resolver | classify', function () { - const test = createTestFunction(classify); - - test('my favorite items', 'MyFavoriteItems', 'classify normal string'); - test('css-class-name', 'CssClassName', 'classify dasherized string'); - test('action_name', 'ActionName', 'classify underscored string'); - test( - 'privateDocs/ownerInvoice', - 'PrivateDocs/OwnerInvoice', - 'classify namespaced camelized string' - ); - test( - 'private_docs/owner_invoice', - 'PrivateDocs/OwnerInvoice', - 'classify namespaced underscored string' - ); - test( - 'private-docs/owner-invoice', - 'PrivateDocs/OwnerInvoice', - 'classify namespaced dasherized string' - ); - test( - '-view-registry', - '_ViewRegistry', - 'classify prefixed dasherized string' - ); - test( - 'components/-text-field', - 'Components/_TextField', - 'classify namespaced prefixed dasherized string' - ); - test( - '_Foo_Bar', - '_FooBar', - 'classify underscore-prefixed underscored string' - ); - test( - '_Foo-Bar', - '_FooBar', - 'classify underscore-prefixed dasherized string' - ); - test( - '_foo/_bar', - '_Foo/_Bar', - 'classify underscore-prefixed-namespaced underscore-prefixed string' - ); - test( - '-foo/_bar', - '_Foo/_Bar', - 'classify dash-prefixed-namespaced underscore-prefixed string' - ); - test( - '-foo/-bar', - '_Foo/_Bar', - 'classify dash-prefixed-namespaced dash-prefixed string' - ); - test('InnerHTML', 'InnerHTML', 'does nothing with classified string'); - test('_FooBar', '_FooBar', 'does nothing with classified prefixed string'); -}); diff --git a/packages/@ember/engine/tests/resolver/create-test-function.js b/packages/@ember/engine/tests/resolver/create-test-function.js deleted file mode 100644 index 43dfd8fc733..00000000000 --- a/packages/@ember/engine/tests/resolver/create-test-function.js +++ /dev/null @@ -1,9 +0,0 @@ -import { test } from 'qunit'; - -export default function (fn) { - return function (given, expected, description) { - test(description, function (assert) { - assert.deepEqual(fn(given), expected); - }); - }; -} diff --git a/packages/@ember/engine/tests/resolver/custom-prefixes-test.js b/packages/@ember/engine/tests/resolver/custom-prefixes-test.js deleted file mode 100644 index 68dfe21a4bd..00000000000 --- a/packages/@ember/engine/tests/resolver/custom-prefixes-test.js +++ /dev/null @@ -1,24 +0,0 @@ -import { module, test } from 'qunit'; - -import { setupResolver, resolver, modules } from './-setup-resolver'; - -module('strict-resolver | custom prefixes by type', function (hooks) { - hooks.beforeEach(function () { - setupResolver(); - }); - - test('will use the prefix specified for a given type if present', function (assert) { - setupResolver({ - namespace: { - fruitPrefix: 'grovestand', - modulePrefix: 'appkit', - }, - }); - - modules['grovestand/fruits/orange'] = 'whatever'; - - let result = resolver.resolve('fruit:orange'); - - assert.strictEqual(result, 'whatever', 'custom prefix was used'); - }); -}); diff --git a/packages/@ember/engine/tests/resolver/dasherize_test.js b/packages/@ember/engine/tests/resolver/dasherize_test.js deleted file mode 100644 index c06ddd1833c..00000000000 --- a/packages/@ember/engine/tests/resolver/dasherize_test.js +++ /dev/null @@ -1,36 +0,0 @@ -import { module } from 'qunit'; -import { dasherize } from '@ember/engine/lib/strict-resolver/string'; -import createTestFunction from './create-test-function'; - -module('strict-resolver | dasherize', function () { - const test = createTestFunction(dasherize); - - test('my favorite items', 'my-favorite-items', 'dasherize normal string'); - test( - 'css-class-name', - 'css-class-name', - 'does nothing with dasherized string' - ); - test('action_name', 'action-name', 'dasherize underscored string'); - test('innerHTML', 'inner-html', 'dasherize camelcased string'); - test( - 'toString', - 'to-string', - 'dasherize string that is the property name of Object.prototype' - ); - test( - 'PrivateDocs/OwnerInvoice', - 'private-docs/owner-invoice', - 'dasherize namespaced classified string' - ); - test( - 'privateDocs/ownerInvoice', - 'private-docs/owner-invoice', - 'dasherize namespaced camelized string' - ); - test( - 'private_docs/owner_invoice', - 'private-docs/owner-invoice', - 'dasherize namespaced underscored string' - ); -}); diff --git a/packages/@ember/engine/tests/resolver/decamelize_test.js b/packages/@ember/engine/tests/resolver/decamelize_test.js deleted file mode 100644 index d1d1af11ffa..00000000000 --- a/packages/@ember/engine/tests/resolver/decamelize_test.js +++ /dev/null @@ -1,39 +0,0 @@ -import { module } from 'qunit'; -import { decamelize } from '@ember/engine/lib/strict-resolver/string'; -import createTestFunction from './create-test-function'; - -module('strict-resolver | decamelize', function () { - const test = createTestFunction(decamelize); - - test( - 'my favorite items', - 'my favorite items', - 'does nothing with normal string' - ); - test( - 'css-class-name', - 'css-class-name', - 'does nothing with dasherized string' - ); - test( - 'action_name', - 'action_name', - 'does nothing with underscored string' - ); - test( - 'innerHTML', - 'inner_html', - 'converts a camelized string into all lower case separated by underscores.' - ); - test('size160Url', 'size160_url', 'decamelizes strings with numbers'); - test( - 'PrivateDocs/OwnerInvoice', - 'private_docs/owner_invoice', - 'decamelize namespaced classified string' - ); - test( - 'privateDocs/ownerInvoice', - 'private_docs/owner_invoice', - 'decamelize namespaced camelized string' - ); -}); diff --git a/packages/@ember/engine/tests/resolver/pods-test.js b/packages/@ember/engine/tests/resolver/pods-test.js deleted file mode 100644 index 6e8ac95079e..00000000000 --- a/packages/@ember/engine/tests/resolver/pods-test.js +++ /dev/null @@ -1,180 +0,0 @@ -import { module, test } from 'qunit'; - -import { setupResolver, resolver, modules } from './-setup-resolver'; - -module('strict-resolver | pods lookup structure', function (hooks) { - hooks.beforeEach(function () { - setupResolver(); - }); - - test('will lookup modulePrefix/name/type before prefix/type/name', function (assert) { - modules['appkit/controllers/foo'] = 'non-pod'; - modules['appkit/foo/controller'] = 'pod'; - - let result = resolver.resolve('controller:foo'); - - assert.strictEqual(result, 'pod', 'pod layout was used'); - }); - - test('will lookup names with slashes properly', function (assert) { - modules['appkit/controllers/foo/index'] = 'non-pod'; - modules['appkit/foo/index/controller'] = 'pod'; - - let result = resolver.resolve('controller:foo/index'); - - assert.strictEqual(result, 'pod', 'pod layout was used'); - }); - - test('specifying a podModulePrefix overrides the general modulePrefix', function (assert) { - setupResolver({ - namespace: { - modulePrefix: 'appkit', - podModulePrefix: 'appkit/pods', - }, - }); - - modules['appkit/controllers/foo'] = 'non-pod'; - modules['appkit/foo/controller'] = 'non-pod-prefix'; - modules['appkit/pods/foo/controller'] = 'pod-prefix'; - - let result = resolver.resolve('controller:foo'); - - assert.strictEqual(result, 'pod-prefix', 'podModulePrefix was used'); - }); - - test('will not use custom type prefix when using POD format', function (assert) { - resolver.namespace['controllerPrefix'] = 'foobar'; - - modules['foobar/controllers/foo'] = 'custom-prefix-non-pod'; - modules['foobar/foo/controller'] = 'custom-prefix-pod'; - modules['appkit/foo/controller'] = 'default-prefix-pod'; - - let result = resolver.resolve('controller:foo'); - - assert.strictEqual( - result, - 'default-prefix-pod', - 'modulePrefix was used for pod layout' - ); - }); - - test('it will find components nested in app/components/name/index.js', function (assert) { - modules['appkit/components/foo-bar/index'] = 'nested-component'; - - let result = resolver.resolve('component:foo-bar'); - - assert.strictEqual( - result, - 'nested-component', - 'nested component was found' - ); - }); - - test('will lookup a components template without being rooted in `components/`', function (assert) { - modules['appkit/components/foo-bar/template'] = 'in-components'; - modules['appkit/foo-bar/template'] = 'at-root'; - - let result = resolver.resolve('template:components/foo-bar'); - - assert.strictEqual( - result, - 'at-root', - 'template was found without components/ root' - ); - }); - - test('will use pods format to lookup components in components/', function (assert) { - let expectedComponent = { isComponentFactory: true }; - modules['appkit/components/foo-bar/template'] = 'the-template'; - modules['appkit/components/foo-bar/component'] = { - default: expectedComponent, - }; - - let template = resolver.resolve('template:components/foo-bar'); - let component = resolver.resolve('component:foo-bar'); - - assert.ok(template, 'template was resolved'); - assert.strictEqual( - component, - expectedComponent, - 'default export was returned' - ); - }); - - test('will not lookup routes in components/', function (assert) { - modules['appkit/components/foo-bar/route'] = { isRouteFactory: true }; - modules['appkit/routes/foo-bar'] = { isRouteFactory: true }; - - let result = resolver.resolve('route:foo-bar'); - - assert.strictEqual( - result, - modules['appkit/routes/foo-bar'], - 'routes/ was used, not components/' - ); - }); - - test('will not lookup non component templates in components/', function (assert) { - modules['appkit/components/foo-bar/template'] = 'component-template'; - modules['appkit/templates/foo-bar'] = 'regular-template'; - - let result = resolver.resolve('template:foo-bar'); - - assert.strictEqual( - result, - 'regular-template', - 'templates/ was used, not components/' - ); - }); - - module('custom pluralization'); - - test('will use the pluralization specified for a given type', function (assert) { - setupResolver({ - namespace: { - modulePrefix: 'appkit', - }, - pluralizedTypes: { - sheep: 'sheep', - octipus: 'octipii', - }, - }); - - modules['appkit/sheep/baaaaaa'] = 'whatever'; - - let result = resolver.resolve('sheep:baaaaaa'); - - assert.strictEqual(result, 'whatever', 'custom pluralization was used'); - }); - - test("will pluralize 'config' as 'config' by default", function (assert) { - setupResolver(); - - modules['appkit/config/environment'] = 'whatever'; - - let result = resolver.resolve('config:environment'); - - assert.strictEqual(result, 'whatever', 'config/environment is found'); - }); - - test("'config' can be overridden", function (assert) { - setupResolver({ - namespace: { - modulePrefix: 'appkit', - }, - pluralizedTypes: { - config: 'super-duper-config', - }, - }); - - modules['appkit/super-duper-config/environment'] = 'whatever'; - - let result = resolver.resolve('config:environment'); - - assert.strictEqual( - result, - 'whatever', - 'super-duper-config/environment is found' - ); - }); -}); diff --git a/packages/@ember/engine/tests/resolver/registry_test.ts b/packages/@ember/engine/tests/resolver/registry_test.ts index 0301ad45398..e80e2e70e17 100644 --- a/packages/@ember/engine/tests/resolver/registry_test.ts +++ b/packages/@ember/engine/tests/resolver/registry_test.ts @@ -1,10 +1,9 @@ import { module, test } from 'qunit'; import Application from '@ember/application'; -import Resolver from '@ember/engine/lib/strict-resolver'; import Service from '@ember/service'; import type ApplicationInstance from '@ember/application/instance'; -module('strict-resolver | Registry with Application', function () { +module('strict-resolver | Application with modules', function () { test('registered stuff can be looked up', async function (assert) { class Foo { static create() { @@ -15,8 +14,7 @@ module('strict-resolver | Registry with Application', function () { } let app = Application.create({ - Resolver: Resolver.withModules({}), - modulePrefix: 'test-app', + modules: {}, rootElement: '#qunit-fixture', autoboot: false, }); @@ -32,16 +30,15 @@ module('strict-resolver | Registry with Application', function () { instance.destroy(); }); - test('resolves modules provided via withModules', async function (assert) { + test('resolves modules provided via modules property', async function (assert) { class MyService extends Service { weDidIt = true; } let app = Application.create({ - Resolver: Resolver.withModules({ - 'test-app/services/my-thing': { default: MyService }, - }), - modulePrefix: 'test-app', + modules: { + './services/my-thing': { default: MyService }, + }, rootElement: '#qunit-fixture', autoboot: false, }); @@ -54,4 +51,43 @@ module('strict-resolver | Registry with Application', function () { instance.destroy(); }); + + test('resolves shorthand modules (without default wrapper)', async function (assert) { + class MyService extends Service { + shorthand = true; + } + + let app = Application.create({ + modules: { + './services/shorthand-svc': MyService, + }, + rootElement: '#qunit-fixture', + autoboot: false, + }); + + let instance = (await app.visit('/')) as ApplicationInstance; + let service = instance.lookup('service:shorthand-svc') as MyService; + + assert.ok(service, 'service was found'); + assert.ok(service.shorthand, 'service has the right property'); + + instance.destroy(); + }); + + test('resolves router:main via ./router module', async function (assert) { + let app = Application.create({ + modules: {}, + rootElement: '#qunit-fixture', + autoboot: false, + }); + + let instance = (await app.visit('/')) as ApplicationInstance; + + // eslint-disable-next-line ember/no-private-routing-service + let router = instance.lookup('router:main'); + + assert.ok(router, 'router was resolved'); + + instance.destroy(); + }); }); diff --git a/packages/@ember/engine/tests/resolver/underscore_test.js b/packages/@ember/engine/tests/resolver/underscore_test.js deleted file mode 100644 index 6b274df3882..00000000000 --- a/packages/@ember/engine/tests/resolver/underscore_test.js +++ /dev/null @@ -1,31 +0,0 @@ -import { module } from 'qunit'; -import { underscore } from '@ember/engine/lib/strict-resolver/string'; -import createTestFunction from './create-test-function'; - -module('strict-resolver | underscore', function () { - const test = createTestFunction(underscore); - - test('my favorite items', 'my_favorite_items', 'with normal string'); - test('css-class-name', 'css_class_name', 'with dasherized string'); - test( - 'action_name', - 'action_name', - 'does nothing with underscored string' - ); - test('innerHTML', 'inner_html', 'with camelcased string'); - test( - 'PrivateDocs/OwnerInvoice', - 'private_docs/owner_invoice', - 'underscore namespaced classified string' - ); - test( - 'privateDocs/ownerInvoice', - 'private_docs/owner_invoice', - 'underscore namespaced camelized string' - ); - test( - 'private-docs/owner-invoice', - 'private_docs/owner_invoice', - 'underscore namespaced dasherized string' - ); -}); diff --git a/packages/@ember/engine/tests/resolver/with-modules-test.js b/packages/@ember/engine/tests/resolver/with-modules-test.js deleted file mode 100644 index 6db7ab37ac8..00000000000 --- a/packages/@ember/engine/tests/resolver/with-modules-test.js +++ /dev/null @@ -1,48 +0,0 @@ -import { module, test } from 'qunit'; -import Resolver from '@ember/engine/lib/strict-resolver'; - -module('strict-resolver | withModules', function () { - test('explicit withModules', function (assert) { - let resolver = Resolver.withModules({ - 'alpha/components/hello': { - default: function () { - return 'it works'; - }, - }, - }).create({ namespace: { modulePrefix: 'alpha' } }); - - assert.strictEqual((0, resolver.resolve('component:hello'))(), 'it works'); - }); - - test('can resolve self', function (assert) { - let resolver = Resolver.withModules({}).create({ - namespace: { modulePrefix: 'alpha' }, - }); - assert.strictEqual( - resolver, - resolver.resolve('resolver:current').create() - ); - }); - - test('can addModules', function (assert) { - let startingModules = {}; - let resolver = Resolver.withModules(startingModules).create({ - namespace: { modulePrefix: 'alpha' }, - }); - - resolver.addModules({ - 'alpha/components/hello': { - default: function () { - return 'it works'; - }, - }, - }); - - assert.strictEqual((0, resolver.resolve('component:hello'))(), 'it works'); - assert.deepEqual( - [], - Object.keys(startingModules), - 'did not mutate starting modules' - ); - }); -}); From 0add00e988a32a0c5a288ae2d83badba68af59cf Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:19:04 -0400 Subject: [PATCH 05/25] Address review: restore string utilities, expand tests, fix setup-resolver - Restore cache.js and string.js with proper inflectors (dasherize, classify, underscore, decamelize) and their caching - Import dasherize from string utilities in StrictResolver instead of inlining - Restore string utility tests (classify, dasherize, decamelize, underscore) with create-test-function helper - Add back tests for all lookup types: component, modifier, template, view, route, controller (not just service/adapter/helper) - Restore full normalization test coverage from original test suite - Add config plural override test - Fix setup-resolver: modules stays in function scope, not module scope - Re-export string utilities from package.json Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/@ember/engine/lib/strict-resolver.ts | 11 +- .../engine/lib/strict-resolver/cache.js | 35 ++++ .../engine/lib/strict-resolver/string.js | 132 ++++++++++++ packages/@ember/engine/package.json | 3 +- .../engine/tests/resolver/-setup-resolver.js | 9 +- .../engine/tests/resolver/basic-test.js | 191 +++++++++++++++++- .../engine/tests/resolver/classify_test.js | 63 ++++++ .../tests/resolver/create-test-function.js | 9 + .../engine/tests/resolver/dasherize_test.js | 36 ++++ .../engine/tests/resolver/decamelize_test.js | 39 ++++ .../engine/tests/resolver/underscore_test.js | 31 +++ 11 files changed, 534 insertions(+), 25 deletions(-) create mode 100644 packages/@ember/engine/lib/strict-resolver/cache.js create mode 100644 packages/@ember/engine/lib/strict-resolver/string.js create mode 100644 packages/@ember/engine/tests/resolver/classify_test.js create mode 100644 packages/@ember/engine/tests/resolver/create-test-function.js create mode 100644 packages/@ember/engine/tests/resolver/dasherize_test.js create mode 100644 packages/@ember/engine/tests/resolver/decamelize_test.js create mode 100644 packages/@ember/engine/tests/resolver/underscore_test.js diff --git a/packages/@ember/engine/lib/strict-resolver.ts b/packages/@ember/engine/lib/strict-resolver.ts index 7df7de53089..35763449d47 100644 --- a/packages/@ember/engine/lib/strict-resolver.ts +++ b/packages/@ember/engine/lib/strict-resolver.ts @@ -1,4 +1,5 @@ import type { Factory, Resolver } from '@ember/owner'; +import { dasherize } from './strict-resolver/string'; export class StrictResolver implements Resolver { #modules = new Map(); @@ -105,16 +106,6 @@ export class StrictResolver implements Resolver { const fileExtension = /\.\w{1,4}$/; const leadingDotSlash = /^\.\//; -const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; -function decamelize(str: string): string { - return str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase(); -} - -const STRING_DASHERIZE_REGEXP = /[ _]/g; -function dasherize(key: string): string { - return decamelize(key).replace(STRING_DASHERIZE_REGEXP, '-'); -} - type Result = | { hit: any; diff --git a/packages/@ember/engine/lib/strict-resolver/cache.js b/packages/@ember/engine/lib/strict-resolver/cache.js new file mode 100644 index 00000000000..68902ada388 --- /dev/null +++ b/packages/@ember/engine/lib/strict-resolver/cache.js @@ -0,0 +1,35 @@ +export default class Cache { + constructor(limit, func, store) { + this.limit = limit; + this.func = func; + this.store = store; + this.size = 0; + this.misses = 0; + this.hits = 0; + this.store = store || new Map(); + } + get(key) { + let value = this.store.get(key); + if (this.store.has(key)) { + this.hits++; + return this.store.get(key); + } else { + this.misses++; + value = this.set(key, this.func(key)); + } + return value; + } + set(key, value) { + if (this.limit > this.size) { + this.size++; + this.store.set(key, value); + } + return value; + } + purge() { + this.store.clear(); + this.size = 0; + this.hits = 0; + this.misses = 0; + } +} diff --git a/packages/@ember/engine/lib/strict-resolver/string.js b/packages/@ember/engine/lib/strict-resolver/string.js new file mode 100644 index 00000000000..9773829941d --- /dev/null +++ b/packages/@ember/engine/lib/strict-resolver/string.js @@ -0,0 +1,132 @@ +/* eslint-disable no-useless-escape */ +import Cache from './cache'; +let STRINGS = {}; +export function setStrings(strings) { + STRINGS = strings; +} +export function getStrings() { + return STRINGS; +} +export function getString(name) { + return STRINGS[name]; +} +const STRING_DASHERIZE_REGEXP = /[ _]/g; +const STRING_DASHERIZE_CACHE = new Cache(1000, (key) => + decamelize(key).replace(STRING_DASHERIZE_REGEXP, '-') +); +const STRING_CLASSIFY_REGEXP_1 = /^(\-|_)+(.)?/; +const STRING_CLASSIFY_REGEXP_2 = /(.)(\-|\_|\.|\s)+(.)?/g; +const STRING_CLASSIFY_REGEXP_3 = /(^|\/|\.)([a-z])/g; +const CLASSIFY_CACHE = new Cache(1000, (str) => { + const replace1 = (_match, _separator, chr) => + chr ? `_${chr.toUpperCase()}` : ''; + const replace2 = (_match, initialChar, _separator, chr) => + initialChar + (chr ? chr.toUpperCase() : ''); + const parts = str.split('/'); + for (let i = 0; i < parts.length; i++) { + parts[i] = parts[i] + .replace(STRING_CLASSIFY_REGEXP_1, replace1) + .replace(STRING_CLASSIFY_REGEXP_2, replace2); + } + return parts + .join('/') + .replace(STRING_CLASSIFY_REGEXP_3, (match /*, separator, chr */) => + match.toUpperCase() + ); +}); +const STRING_UNDERSCORE_REGEXP_1 = /([a-z\d])([A-Z]+)/g; +const STRING_UNDERSCORE_REGEXP_2 = /\-|\s+/g; +const UNDERSCORE_CACHE = new Cache(1000, (str) => + str + .replace(STRING_UNDERSCORE_REGEXP_1, '$1_$2') + .replace(STRING_UNDERSCORE_REGEXP_2, '_') + .toLowerCase() +); +const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; +const DECAMELIZE_CACHE = new Cache(1000, (str) => + str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase() +); +/** + Converts a camelized string into all lower case separated by underscores. + + ```javascript + import { decamelize } from '@ember/string'; + + decamelize('innerHTML'); // 'inner_html' + decamelize('action_name'); // 'action_name' + decamelize('css-class-name'); // 'css-class-name' + decamelize('my favorite items'); // 'my favorite items' + ``` + + @method decamelize + @param {String} str The string to decamelize. + @return {String} the decamelized string. + @public +*/ +export function decamelize(str) { + return DECAMELIZE_CACHE.get(str); +} +/** + Replaces underscores, spaces, or camelCase with dashes. + + ```javascript + import { dasherize } from '@ember/string'; + + dasherize('innerHTML'); // 'inner-html' + dasherize('action_name'); // 'action-name' + dasherize('css-class-name'); // 'css-class-name' + dasherize('my favorite items'); // 'my-favorite-items' + dasherize('privateDocs/ownerInvoice'; // 'private-docs/owner-invoice' + ``` + + @method dasherize + @param {String} str The string to dasherize. + @return {String} the dasherized string. + @public +*/ +export function dasherize(str) { + return STRING_DASHERIZE_CACHE.get(str); +} +/** + Returns the UpperCamelCase form of a string. + + ```javascript + import { classify } from '@ember/string'; + + classify('innerHTML'); // 'InnerHTML' + classify('action_name'); // 'ActionName' + classify('css-class-name'); // 'CssClassName' + classify('my favorite items'); // 'MyFavoriteItems' + classify('private-docs/owner-invoice'); // 'PrivateDocs/OwnerInvoice' + ``` + + @method classify + @param {String} str the string to classify + @return {String} the classified string + @public +*/ +export function classify(str) { + return CLASSIFY_CACHE.get(str); +} +/** + More general than decamelize. Returns the lower\_case\_and\_underscored + form of a string. + + ```javascript + import { underscore } from '@ember/string'; + + underscore('innerHTML'); // 'inner_html' + underscore('action_name'); // 'action_name' + underscore('css-class-name'); // 'css_class_name' + underscore('my favorite items'); // 'my_favorite_items' + underscore('privateDocs/ownerInvoice'); // 'private_docs/owner_invoice' + ``` + + @method underscore + @param {String} str The string to underscore. + @return {String} the underscored string. + @public +*/ +export function underscore(str) { + return UNDERSCORE_CACHE.get(str); +} diff --git a/packages/@ember/engine/package.json b/packages/@ember/engine/package.json index 75142563e1e..b042cedbf72 100644 --- a/packages/@ember/engine/package.json +++ b/packages/@ember/engine/package.json @@ -6,7 +6,8 @@ ".": "./index.ts", "./instance": "./instance.ts", "./parent": "./parent.ts", - "./lib/strict-resolver": "./lib/strict-resolver.ts" + "./lib/strict-resolver": "./lib/strict-resolver.ts", + "./lib/strict-resolver/string": "./lib/strict-resolver/string.js" }, "dependencies": { "@ember/-internals": "workspace:*", diff --git a/packages/@ember/engine/tests/resolver/-setup-resolver.js b/packages/@ember/engine/tests/resolver/-setup-resolver.js index bfb361425d7..55174802cc4 100644 --- a/packages/@ember/engine/tests/resolver/-setup-resolver.js +++ b/packages/@ember/engine/tests/resolver/-setup-resolver.js @@ -1,12 +1,9 @@ import { StrictResolver } from '@ember/engine/lib/strict-resolver'; -export let resolver; -export let modules; - export function setupResolver(options = {}) { - modules = {}; - + let modules = {}; let plurals = options.plurals; + let resolver = new StrictResolver(modules, plurals); - resolver = new StrictResolver(modules, plurals); + return { resolver, modules }; } diff --git a/packages/@ember/engine/tests/resolver/basic-test.js b/packages/@ember/engine/tests/resolver/basic-test.js index 28bd884d315..1665381cdb5 100644 --- a/packages/@ember/engine/tests/resolver/basic-test.js +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -1,10 +1,13 @@ import { module, test } from 'qunit'; import { StrictResolver } from '@ember/engine/lib/strict-resolver'; -import { setupResolver, resolver, modules } from './-setup-resolver'; +import { setupResolver } from './-setup-resolver'; module('strict-resolver | basic', function (hooks) { + let resolver; + let modules; + hooks.beforeEach(function () { - setupResolver(); + ({ resolver, modules } = setupResolver()); }); test('can lookup something', function (assert) { @@ -40,6 +43,72 @@ module('strict-resolver | basic', function (hooks) { assert.strictEqual(helper, expected, 'default export was returned'); }); + test('can lookup a component', function (assert) { + let expected = { isComponentFactory: true }; + modules['./components/my-widget'] = { default: expected }; + resolver.addModules(modules); + + let component = resolver.resolve('component:my-widget'); + + assert.ok(component, 'component was returned'); + assert.strictEqual(component, expected, 'default export was returned'); + }); + + test('can lookup a modifier', function (assert) { + let expected = { isModifier: true }; + modules['./modifiers/auto-focus'] = { default: expected }; + resolver.addModules(modules); + + let modifier = resolver.resolve('modifier:auto-focus'); + + assert.ok(modifier, 'modifier was returned'); + assert.strictEqual(modifier, expected, 'default export was returned'); + }); + + test('can lookup a template', function (assert) { + let expected = { isTemplate: true }; + modules['./templates/application'] = { default: expected }; + resolver.addModules(modules); + + let template = resolver.resolve('template:application'); + + assert.ok(template, 'template was returned'); + assert.strictEqual(template, expected, 'default export was returned'); + }); + + test('can lookup a view', function (assert) { + let expected = { isViewFactory: true }; + modules['./views/queue-list'] = { default: expected }; + resolver.addModules(modules); + + let view = resolver.resolve('view:queue-list'); + + assert.ok(view, 'view was returned'); + assert.strictEqual(view, expected, 'default export was returned'); + }); + + test('can lookup a route', function (assert) { + let expected = { isRouteFactory: true }; + modules['./routes/index'] = { default: expected }; + resolver.addModules(modules); + + let route = resolver.resolve('route:index'); + + assert.ok(route, 'route was returned'); + assert.strictEqual(route, expected, 'default export was returned'); + }); + + test('can lookup a controller', function (assert) { + let expected = { isController: true }; + modules['./controllers/application'] = { default: expected }; + resolver.addModules(modules); + + let controller = resolver.resolve('controller:application'); + + assert.ok(controller, 'controller was returned'); + assert.strictEqual(controller, expected, 'default export was returned'); + }); + test("will return the raw value if no 'default' is available", function (assert) { modules['./fruits/orange'] = 'is awesome'; resolver.addModules(modules); @@ -139,7 +208,11 @@ module('strict-resolver | basic', function (hooks) { }); test('shorthand module registration (no default wrapper)', function (assert) { - let MyService = { create() { return this; } }; + let MyService = { + create() { + return this; + }, + }; let resolver2 = new StrictResolver({ './services/my-thing': MyService, @@ -163,38 +236,125 @@ module('strict-resolver | basic', function (hooks) { 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' ); - // helpers preserve camelCase (avoid shadowing template expressions) + // 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:makeFabulous'), - 'helper:makeFabulous' + 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' + ); - // components preserve camelCase + // 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' + ); - // modifiers preserve camelCase + // and modifiers + assert.strictEqual( + resolver.normalize('modifier:fabulous-component'), + 'modifier:fabulous-component' + ); assert.strictEqual( resolver.normalize('modifier:fabulouslyMissing'), 'modifier:fabulouslyMissing' ); }); + test('camel case modifier is not normalized to dasherized', function (assert) { + let expected = {}; + resolver.addModules({ + './modifiers/other-thing': { default: 'oh no' }, + './modifiers/otherThing': { default: expected }, + }); + + let modifier = resolver.resolve('modifier:otherThing'); + + assert.strictEqual(modifier, expected); + }); + test('normalization is idempotent', function (assert) { let examples = [ 'controller:posts', 'controller:posts.post.index', + 'controller:blog/posts.post_index', 'template:foo_bar', ]; @@ -225,4 +385,19 @@ module('strict-resolver | basic', function (hooks) { 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' + ); + }); }); diff --git a/packages/@ember/engine/tests/resolver/classify_test.js b/packages/@ember/engine/tests/resolver/classify_test.js new file mode 100644 index 00000000000..301a07162b0 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/classify_test.js @@ -0,0 +1,63 @@ +import { module } from 'qunit'; +import { classify } from '@ember/engine/lib/strict-resolver/string'; +import createTestFunction from './create-test-function'; + +module('strict-resolver | classify', function () { + const test = createTestFunction(classify); + + test('my favorite items', 'MyFavoriteItems', 'classify normal string'); + test('css-class-name', 'CssClassName', 'classify dasherized string'); + test('action_name', 'ActionName', 'classify underscored string'); + test( + 'privateDocs/ownerInvoice', + 'PrivateDocs/OwnerInvoice', + 'classify namespaced camelized string' + ); + test( + 'private_docs/owner_invoice', + 'PrivateDocs/OwnerInvoice', + 'classify namespaced underscored string' + ); + test( + 'private-docs/owner-invoice', + 'PrivateDocs/OwnerInvoice', + 'classify namespaced dasherized string' + ); + test( + '-view-registry', + '_ViewRegistry', + 'classify prefixed dasherized string' + ); + test( + 'components/-text-field', + 'Components/_TextField', + 'classify namespaced prefixed dasherized string' + ); + test( + '_Foo_Bar', + '_FooBar', + 'classify underscore-prefixed underscored string' + ); + test( + '_Foo-Bar', + '_FooBar', + 'classify underscore-prefixed dasherized string' + ); + test( + '_foo/_bar', + '_Foo/_Bar', + 'classify underscore-prefixed-namespaced underscore-prefixed string' + ); + test( + '-foo/_bar', + '_Foo/_Bar', + 'classify dash-prefixed-namespaced underscore-prefixed string' + ); + test( + '-foo/-bar', + '_Foo/_Bar', + 'classify dash-prefixed-namespaced dash-prefixed string' + ); + test('InnerHTML', 'InnerHTML', 'does nothing with classified string'); + test('_FooBar', '_FooBar', 'does nothing with classified prefixed string'); +}); diff --git a/packages/@ember/engine/tests/resolver/create-test-function.js b/packages/@ember/engine/tests/resolver/create-test-function.js new file mode 100644 index 00000000000..43dfd8fc733 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/create-test-function.js @@ -0,0 +1,9 @@ +import { test } from 'qunit'; + +export default function (fn) { + return function (given, expected, description) { + test(description, function (assert) { + assert.deepEqual(fn(given), expected); + }); + }; +} diff --git a/packages/@ember/engine/tests/resolver/dasherize_test.js b/packages/@ember/engine/tests/resolver/dasherize_test.js new file mode 100644 index 00000000000..c06ddd1833c --- /dev/null +++ b/packages/@ember/engine/tests/resolver/dasherize_test.js @@ -0,0 +1,36 @@ +import { module } from 'qunit'; +import { dasherize } from '@ember/engine/lib/strict-resolver/string'; +import createTestFunction from './create-test-function'; + +module('strict-resolver | dasherize', function () { + const test = createTestFunction(dasherize); + + test('my favorite items', 'my-favorite-items', 'dasherize normal string'); + test( + 'css-class-name', + 'css-class-name', + 'does nothing with dasherized string' + ); + test('action_name', 'action-name', 'dasherize underscored string'); + test('innerHTML', 'inner-html', 'dasherize camelcased string'); + test( + 'toString', + 'to-string', + 'dasherize string that is the property name of Object.prototype' + ); + test( + 'PrivateDocs/OwnerInvoice', + 'private-docs/owner-invoice', + 'dasherize namespaced classified string' + ); + test( + 'privateDocs/ownerInvoice', + 'private-docs/owner-invoice', + 'dasherize namespaced camelized string' + ); + test( + 'private_docs/owner_invoice', + 'private-docs/owner-invoice', + 'dasherize namespaced underscored string' + ); +}); diff --git a/packages/@ember/engine/tests/resolver/decamelize_test.js b/packages/@ember/engine/tests/resolver/decamelize_test.js new file mode 100644 index 00000000000..d1d1af11ffa --- /dev/null +++ b/packages/@ember/engine/tests/resolver/decamelize_test.js @@ -0,0 +1,39 @@ +import { module } from 'qunit'; +import { decamelize } from '@ember/engine/lib/strict-resolver/string'; +import createTestFunction from './create-test-function'; + +module('strict-resolver | decamelize', function () { + const test = createTestFunction(decamelize); + + test( + 'my favorite items', + 'my favorite items', + 'does nothing with normal string' + ); + test( + 'css-class-name', + 'css-class-name', + 'does nothing with dasherized string' + ); + test( + 'action_name', + 'action_name', + 'does nothing with underscored string' + ); + test( + 'innerHTML', + 'inner_html', + 'converts a camelized string into all lower case separated by underscores.' + ); + test('size160Url', 'size160_url', 'decamelizes strings with numbers'); + test( + 'PrivateDocs/OwnerInvoice', + 'private_docs/owner_invoice', + 'decamelize namespaced classified string' + ); + test( + 'privateDocs/ownerInvoice', + 'private_docs/owner_invoice', + 'decamelize namespaced camelized string' + ); +}); diff --git a/packages/@ember/engine/tests/resolver/underscore_test.js b/packages/@ember/engine/tests/resolver/underscore_test.js new file mode 100644 index 00000000000..6b274df3882 --- /dev/null +++ b/packages/@ember/engine/tests/resolver/underscore_test.js @@ -0,0 +1,31 @@ +import { module } from 'qunit'; +import { underscore } from '@ember/engine/lib/strict-resolver/string'; +import createTestFunction from './create-test-function'; + +module('strict-resolver | underscore', function () { + const test = createTestFunction(underscore); + + test('my favorite items', 'my_favorite_items', 'with normal string'); + test('css-class-name', 'css_class_name', 'with dasherized string'); + test( + 'action_name', + 'action_name', + 'does nothing with underscored string' + ); + test('innerHTML', 'inner_html', 'with camelcased string'); + test( + 'PrivateDocs/OwnerInvoice', + 'private_docs/owner_invoice', + 'underscore namespaced classified string' + ); + test( + 'privateDocs/ownerInvoice', + 'private_docs/owner_invoice', + 'underscore namespaced camelized string' + ); + test( + 'private-docs/owner-invoice', + 'private_docs/owner_invoice', + 'underscore namespaced dasherized string' + ); +}); From 1ab8b52bd7f5e874129db98c44ed5535696fb6c9 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:34:23 -0400 Subject: [PATCH 06/25] Address review: revert package.json, add strict-resolver scenario test - Revert package.json to original (no export changes needed) - Add strict-resolver scenario in smoke-tests/scenarios/scenarios.ts - Add strict-resolver-test.ts with acceptance + unit tests that exercise an Application using modules = {} with import.meta.glob Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/@ember/engine/package.json | 4 +- smoke-tests/scenarios/scenarios.ts | 10 ++ smoke-tests/scenarios/strict-resolver-test.ts | 141 ++++++++++++++++++ 3 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 smoke-tests/scenarios/strict-resolver-test.ts diff --git a/packages/@ember/engine/package.json b/packages/@ember/engine/package.json index b042cedbf72..b08914eb32f 100644 --- a/packages/@ember/engine/package.json +++ b/packages/@ember/engine/package.json @@ -5,9 +5,7 @@ "exports": { ".": "./index.ts", "./instance": "./instance.ts", - "./parent": "./parent.ts", - "./lib/strict-resolver": "./lib/strict-resolver.ts", - "./lib/strict-resolver/string": "./lib/strict-resolver/string.js" + "./parent": "./parent.ts" }, "dependencies": { "@ember/-internals": "workspace:*", diff --git a/smoke-tests/scenarios/scenarios.ts b/smoke-tests/scenarios/scenarios.ts index 571c33d3e62..edb3b94d4c2 100644 --- a/smoke-tests/scenarios/scenarios.ts +++ b/smoke-tests/scenarios/scenarios.ts @@ -36,6 +36,16 @@ export const v2AppScenarios = Scenarios.fromProject(() => embroiderVite, }); +function strictResolver(project: Project) {} + +export const strictAppScenarios = Scenarios.fromProject(() => + Project.fromDir(dirname(require.resolve('../v2-app-template/package.json')), { + linkDevDeps: true, + }) +).expand({ + strictResolver, +}); + function node(project: Project) { project.linkDevDependency('ember-source', { baseDir: dirname(require.resolve('../app-template/package.json')), diff --git a/smoke-tests/scenarios/strict-resolver-test.ts b/smoke-tests/scenarios/strict-resolver-test.ts new file mode 100644 index 00000000000..0f93911600a --- /dev/null +++ b/smoke-tests/scenarios/strict-resolver-test.ts @@ -0,0 +1,141 @@ +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 { + modulePrefix = config.modulePrefix; + + modules = { + './router': { default: Router }, + ...import.meta.glob('./services/**/*', { eager: true }), + ...import.meta.glob('./controllers/**/*', { eager: true }), + ...import.meta.glob('./routes/**/*', { eager: true }), + ...import.meta.glob('./components/**/*', { eager: true }), + ...import.meta.glob('./helpers/**/*', { eager: true }), + ...import.meta.glob('./templates/**/*', { 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('strict-example'); + }); + `, + services: { + 'greeter.js': ` + import Service from '@ember/service'; + + export default class GreeterService extends Service { + greeting = 'Hello from strict resolver!'; + } + `, + }, + routes: { + 'strict-example.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { + model() { + return { message: 'strict model data' }; + } + } + `, + }, + controllers: { + 'strict-example.js': ` + import Controller from '@ember/controller'; + import { service } from '@ember/service'; + + export default class extends Controller { + @service greeter; + } + `, + }, + templates: { + 'strict-example.gjs': ` + + `, + }, + }, + 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('visiting /strict-example resolves route, controller, template, and service', async function (assert) { + await visit('/strict-example'); + assert.strictEqual(currentURL(), '/strict-example'); + assert.dom('[data-test="model"]').hasText('strict model data'); + assert.dom('[data-test="greeting"]').hasText('Hello from strict resolver!'); + }); + }); + `, + }, + 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); + }); + }); + }); From c8c25f47e26e19b3a3e8fb4d850f04ea71965b6c Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:34:29 -0400 Subject: [PATCH 07/25] Expand strict-resolver scenario with full route hierarchy and component types - Add application template with service injection and hbs component - Add index route (/) with model - Add nested posts route with sub-route posts/show (/:post_id) - Add hbs component (site-header.hbs) to test classic template resolution - Add gjs component (post-card.gjs) to test modern component resolution - Acceptance tests cover: index, application, sub-routes, dynamic segments, hbs components, gjs components, service injection Co-Authored-By: Claude Opus 4.6 (1M context) --- smoke-tests/scenarios/strict-resolver-test.ts | 111 +++++++++++++++--- 1 file changed, 97 insertions(+), 14 deletions(-) diff --git a/smoke-tests/scenarios/strict-resolver-test.ts b/smoke-tests/scenarios/strict-resolver-test.ts index 0f93911600a..b43b7c6c566 100644 --- a/smoke-tests/scenarios/strict-resolver-test.ts +++ b/smoke-tests/scenarios/strict-resolver-test.ts @@ -36,7 +36,9 @@ strictAppScenarios } Router.map(function () { - this.route('strict-example'); + this.route('posts', function () { + this.route('show', { path: '/:post_id' }); + }); }); `, services: { @@ -49,17 +51,38 @@ strictAppScenarios `, }, routes: { - 'strict-example.js': ` + '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 { message: 'strict model data' }; + 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 }; + } + } + `, + }, }, controllers: { - 'strict-example.js': ` + 'application.js': ` import Controller from '@ember/controller'; import { service } from '@ember/service'; @@ -68,13 +91,46 @@ strictAppScenarios } `, }, + components: { + 'site-header.hbs': ` +
+

{{@title}}

+
+ `, + 'post-card.gjs': ` + import Component from '@glimmer/component'; + + export default class PostCard extends Component { + + } + `, + }, templates: { - 'strict-example.gjs': ` - + 'application.hbs': ` +
{{this.greeter.greeting}}
+ + {{outlet}} + `, + 'index.hbs': ` +
{{@model.welcome}}
`, + 'posts.hbs': ` +
+ {{#each @model as |post|}} + + {{/each}} +
+ {{outlet}} + `, + 'posts': { + 'show.hbs': ` +
{{@model.title}}
+ `, + }, }, }, tests: { @@ -87,11 +143,38 @@ strictAppScenarios module('Acceptance | strict resolver', function (hooks) { setupApplicationTest(hooks); - test('visiting /strict-example resolves route, controller, template, and service', async function (assert) { - await visit('/strict-example'); - assert.strictEqual(currentURL(), '/strict-example'); - assert.dom('[data-test="model"]').hasText('strict model data'); - assert.dom('[data-test="greeting"]').hasText('Hello from strict resolver!'); + 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('hbs 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('sub-route with nested model', async function (assert) { + await visit('/posts'); + assert.strictEqual(currentURL(), '/posts'); + assert.dom('[data-test="post-card"]').exists({ count: 2 }); + }); + + test('dynamic segment sub-route', async function (assert) { + await visit('/posts/42'); + assert.strictEqual(currentURL(), '/posts/42'); + assert.dom('[data-test="post-detail"]').hasText('Post 42'); + }); + + test('gjs component resolves from modules', async function (assert) { + await visit('/posts'); + assert.dom('[data-test="post-card"] h2').exists(); }); }); `, From 4355bbc7313a5102284433d21eecf0cc211dd802 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:32:31 -0400 Subject: [PATCH 08/25] Fix CI failures: lint, type-check, docs coverage, and test cleanup - Destroy Application in test afterEach so namespaces don't leak into subsequent tests (caused "Should not have any NAMESPACES after tests" failure in the Enumerable suite). - Pass required BootOptions to app.visit('/', {}) for type-check. - Drop the reference to the non-existent ember/no-private-routing-service eslint rule. - Rewrite classify/dasherize/decamelize/underscore tests to call qunit's test() directly so eslint no longer misreads the 2nd arg as an expect count and const is at module scope. - Hoist classifyReplace1/2 in strict-resolver/string.js to module scope. - Mark decamelize/underscore as @private and register all four string helpers (and Engine's modules/plurals) in tests/docs/expected.js so docs coverage passes. - Regenerate package.json with the three strict-resolver path aliases so the package preparation diff check stays clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 + .../engine/lib/strict-resolver/string.js | 23 ++-- .../engine/tests/resolver/basic-test.js | 73 +++-------- .../engine/tests/resolver/classify_test.js | 119 +++++++++--------- .../tests/resolver/create-test-function.js | 9 -- .../engine/tests/resolver/dasherize_test.js | 64 +++++----- .../engine/tests/resolver/decamelize_test.js | 61 ++++----- .../engine/tests/resolver/registry_test.ts | 40 +++--- .../engine/tests/resolver/underscore_test.js | 53 ++++---- tests/docs/expected.js | 4 + 10 files changed, 198 insertions(+), 251 deletions(-) delete mode 100644 packages/@ember/engine/tests/resolver/create-test-function.js diff --git a/package.json b/package.json index 8cf0f30d3d4..3e59dce96e8 100644 --- a/package.json +++ b/package.json @@ -229,6 +229,9 @@ "@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/lib/strict-resolver/cache.js": "ember-source/@ember/engine/lib/strict-resolver/cache.js", + "@ember/engine/lib/strict-resolver/string.js": "ember-source/@ember/engine/lib/strict-resolver/string.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/lib/strict-resolver/string.js b/packages/@ember/engine/lib/strict-resolver/string.js index 9773829941d..d5912da72e1 100644 --- a/packages/@ember/engine/lib/strict-resolver/string.js +++ b/packages/@ember/engine/lib/strict-resolver/string.js @@ -17,22 +17,17 @@ const STRING_DASHERIZE_CACHE = new Cache(1000, (key) => const STRING_CLASSIFY_REGEXP_1 = /^(\-|_)+(.)?/; const STRING_CLASSIFY_REGEXP_2 = /(.)(\-|\_|\.|\s)+(.)?/g; const STRING_CLASSIFY_REGEXP_3 = /(^|\/|\.)([a-z])/g; +const classifyReplace1 = (_match, _separator, chr) => (chr ? `_${chr.toUpperCase()}` : ''); +const classifyReplace2 = (_match, initialChar, _separator, chr) => + initialChar + (chr ? chr.toUpperCase() : ''); const CLASSIFY_CACHE = new Cache(1000, (str) => { - const replace1 = (_match, _separator, chr) => - chr ? `_${chr.toUpperCase()}` : ''; - const replace2 = (_match, initialChar, _separator, chr) => - initialChar + (chr ? chr.toUpperCase() : ''); - const parts = str.split('/'); + let parts = str.split('/'); for (let i = 0; i < parts.length; i++) { parts[i] = parts[i] - .replace(STRING_CLASSIFY_REGEXP_1, replace1) - .replace(STRING_CLASSIFY_REGEXP_2, replace2); + .replace(STRING_CLASSIFY_REGEXP_1, classifyReplace1) + .replace(STRING_CLASSIFY_REGEXP_2, classifyReplace2); } - return parts - .join('/') - .replace(STRING_CLASSIFY_REGEXP_3, (match /*, separator, chr */) => - match.toUpperCase() - ); + return parts.join('/').replace(STRING_CLASSIFY_REGEXP_3, (match) => match.toUpperCase()); }); const STRING_UNDERSCORE_REGEXP_1 = /([a-z\d])([A-Z]+)/g; const STRING_UNDERSCORE_REGEXP_2 = /\-|\s+/g; @@ -61,7 +56,7 @@ const DECAMELIZE_CACHE = new Cache(1000, (str) => @method decamelize @param {String} str The string to decamelize. @return {String} the decamelized string. - @public + @private */ export function decamelize(str) { return DECAMELIZE_CACHE.get(str); @@ -125,7 +120,7 @@ export function classify(str) { @method underscore @param {String} str The string to underscore. @return {String} the underscored string. - @public + @private */ export function underscore(str) { return UNDERSCORE_CACHE.get(str); diff --git a/packages/@ember/engine/tests/resolver/basic-test.js b/packages/@ember/engine/tests/resolver/basic-test.js index 1665381cdb5..75b684cd720 100644 --- a/packages/@ember/engine/tests/resolver/basic-test.js +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -113,11 +113,7 @@ module('strict-resolver | basic', function (hooks) { modules['./fruits/orange'] = 'is awesome'; resolver.addModules(modules); - assert.strictEqual( - resolver.resolve('fruit:orange'), - 'is awesome', - 'raw value was returned' - ); + assert.strictEqual(resolver.resolve('fruit:orange'), 'is awesome', 'raw value was returned'); }); test("will unwrap the 'default' export automatically", function (assert) { @@ -200,11 +196,7 @@ module('strict-resolver | basic', function (hooks) { './services/foo.ts': { default: 'from-ts' }, }); - assert.strictEqual( - resolver2.resolve('service:foo'), - 'from-ts', - 'file extension was stripped' - ); + assert.strictEqual(resolver2.resolve('service:foo'), 'from-ts', 'file extension was stripped'); }); test('shorthand module registration (no default wrapper)', function (assert) { @@ -224,26 +216,11 @@ module('strict-resolver | basic', function (hooks) { }); 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'), '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' @@ -289,29 +266,14 @@ module('strict-resolver | basic', function (hooks) { resolver.normalize('template:blog/posts_index'), 'template:blog/posts-index' ); - assert.strictEqual( - resolver.normalize('service:userAuth'), - 'service:user-auth' - ); + 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' - ); + 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( @@ -376,10 +338,7 @@ module('strict-resolver | basic', function (hooks) { }); test('custom plurals are supported', function (assert) { - let resolver2 = new StrictResolver( - { './sheep/baaaaaa': 'whatever' }, - { sheep: 'sheep' } - ); + let resolver2 = new StrictResolver({ './sheep/baaaaaa': 'whatever' }, { sheep: 'sheep' }); let result = resolver2.resolve('sheep:baaaaaa'); @@ -394,10 +353,6 @@ module('strict-resolver | basic', function (hooks) { let result = resolver2.resolve('config:environment'); - assert.strictEqual( - result, - 'whatever', - 'super-duper-config/environment is found' - ); + assert.strictEqual(result, 'whatever', 'super-duper-config/environment is found'); }); }); diff --git a/packages/@ember/engine/tests/resolver/classify_test.js b/packages/@ember/engine/tests/resolver/classify_test.js index 301a07162b0..de35985974a 100644 --- a/packages/@ember/engine/tests/resolver/classify_test.js +++ b/packages/@ember/engine/tests/resolver/classify_test.js @@ -1,63 +1,64 @@ -import { module } from 'qunit'; +import { module, test } from 'qunit'; import { classify } from '@ember/engine/lib/strict-resolver/string'; -import createTestFunction from './create-test-function'; module('strict-resolver | classify', function () { - const test = createTestFunction(classify); - - test('my favorite items', 'MyFavoriteItems', 'classify normal string'); - test('css-class-name', 'CssClassName', 'classify dasherized string'); - test('action_name', 'ActionName', 'classify underscored string'); - test( - 'privateDocs/ownerInvoice', - 'PrivateDocs/OwnerInvoice', - 'classify namespaced camelized string' - ); - test( - 'private_docs/owner_invoice', - 'PrivateDocs/OwnerInvoice', - 'classify namespaced underscored string' - ); - test( - 'private-docs/owner-invoice', - 'PrivateDocs/OwnerInvoice', - 'classify namespaced dasherized string' - ); - test( - '-view-registry', - '_ViewRegistry', - 'classify prefixed dasherized string' - ); - test( - 'components/-text-field', - 'Components/_TextField', - 'classify namespaced prefixed dasherized string' - ); - test( - '_Foo_Bar', - '_FooBar', - 'classify underscore-prefixed underscored string' - ); - test( - '_Foo-Bar', - '_FooBar', - 'classify underscore-prefixed dasherized string' - ); - test( - '_foo/_bar', - '_Foo/_Bar', - 'classify underscore-prefixed-namespaced underscore-prefixed string' - ); - test( - '-foo/_bar', - '_Foo/_Bar', - 'classify dash-prefixed-namespaced underscore-prefixed string' - ); - test( - '-foo/-bar', - '_Foo/_Bar', - 'classify dash-prefixed-namespaced dash-prefixed string' - ); - test('InnerHTML', 'InnerHTML', 'does nothing with classified string'); - test('_FooBar', '_FooBar', 'does nothing with classified prefixed string'); + test('classify normal string', function (assert) { + assert.deepEqual(classify('my favorite items'), 'MyFavoriteItems'); + }); + + test('classify dasherized string', function (assert) { + assert.deepEqual(classify('css-class-name'), 'CssClassName'); + }); + + test('classify underscored string', function (assert) { + assert.deepEqual(classify('action_name'), 'ActionName'); + }); + + test('classify namespaced camelized string', function (assert) { + assert.deepEqual(classify('privateDocs/ownerInvoice'), 'PrivateDocs/OwnerInvoice'); + }); + + test('classify namespaced underscored string', function (assert) { + assert.deepEqual(classify('private_docs/owner_invoice'), 'PrivateDocs/OwnerInvoice'); + }); + + test('classify namespaced dasherized string', function (assert) { + assert.deepEqual(classify('private-docs/owner-invoice'), 'PrivateDocs/OwnerInvoice'); + }); + + test('classify prefixed dasherized string', function (assert) { + assert.deepEqual(classify('-view-registry'), '_ViewRegistry'); + }); + + test('classify namespaced prefixed dasherized string', function (assert) { + assert.deepEqual(classify('components/-text-field'), 'Components/_TextField'); + }); + + test('classify underscore-prefixed underscored string', function (assert) { + assert.deepEqual(classify('_Foo_Bar'), '_FooBar'); + }); + + test('classify underscore-prefixed dasherized string', function (assert) { + assert.deepEqual(classify('_Foo-Bar'), '_FooBar'); + }); + + test('classify underscore-prefixed-namespaced underscore-prefixed string', function (assert) { + assert.deepEqual(classify('_foo/_bar'), '_Foo/_Bar'); + }); + + test('classify dash-prefixed-namespaced underscore-prefixed string', function (assert) { + assert.deepEqual(classify('-foo/_bar'), '_Foo/_Bar'); + }); + + test('classify dash-prefixed-namespaced dash-prefixed string', function (assert) { + assert.deepEqual(classify('-foo/-bar'), '_Foo/_Bar'); + }); + + test('does nothing with classified string', function (assert) { + assert.deepEqual(classify('InnerHTML'), 'InnerHTML'); + }); + + test('does nothing with classified prefixed string', function (assert) { + assert.deepEqual(classify('_FooBar'), '_FooBar'); + }); }); diff --git a/packages/@ember/engine/tests/resolver/create-test-function.js b/packages/@ember/engine/tests/resolver/create-test-function.js deleted file mode 100644 index 43dfd8fc733..00000000000 --- a/packages/@ember/engine/tests/resolver/create-test-function.js +++ /dev/null @@ -1,9 +0,0 @@ -import { test } from 'qunit'; - -export default function (fn) { - return function (given, expected, description) { - test(description, function (assert) { - assert.deepEqual(fn(given), expected); - }); - }; -} diff --git a/packages/@ember/engine/tests/resolver/dasherize_test.js b/packages/@ember/engine/tests/resolver/dasherize_test.js index c06ddd1833c..2fbec4bd3f0 100644 --- a/packages/@ember/engine/tests/resolver/dasherize_test.js +++ b/packages/@ember/engine/tests/resolver/dasherize_test.js @@ -1,36 +1,36 @@ -import { module } from 'qunit'; +import { module, test } from 'qunit'; import { dasherize } from '@ember/engine/lib/strict-resolver/string'; -import createTestFunction from './create-test-function'; module('strict-resolver | dasherize', function () { - const test = createTestFunction(dasherize); - - test('my favorite items', 'my-favorite-items', 'dasherize normal string'); - test( - 'css-class-name', - 'css-class-name', - 'does nothing with dasherized string' - ); - test('action_name', 'action-name', 'dasherize underscored string'); - test('innerHTML', 'inner-html', 'dasherize camelcased string'); - test( - 'toString', - 'to-string', - 'dasherize string that is the property name of Object.prototype' - ); - test( - 'PrivateDocs/OwnerInvoice', - 'private-docs/owner-invoice', - 'dasherize namespaced classified string' - ); - test( - 'privateDocs/ownerInvoice', - 'private-docs/owner-invoice', - 'dasherize namespaced camelized string' - ); - test( - 'private_docs/owner_invoice', - 'private-docs/owner-invoice', - 'dasherize namespaced underscored string' - ); + test('dasherize normal string', function (assert) { + assert.deepEqual(dasherize('my favorite items'), 'my-favorite-items'); + }); + + test('does nothing with dasherized string', function (assert) { + assert.deepEqual(dasherize('css-class-name'), 'css-class-name'); + }); + + test('dasherize underscored string', function (assert) { + assert.deepEqual(dasherize('action_name'), 'action-name'); + }); + + test('dasherize camelcased string', function (assert) { + assert.deepEqual(dasherize('innerHTML'), 'inner-html'); + }); + + test('dasherize string that is the property name of Object.prototype', function (assert) { + assert.deepEqual(dasherize('toString'), 'to-string'); + }); + + test('dasherize namespaced classified string', function (assert) { + assert.deepEqual(dasherize('PrivateDocs/OwnerInvoice'), 'private-docs/owner-invoice'); + }); + + test('dasherize namespaced camelized string', function (assert) { + assert.deepEqual(dasherize('privateDocs/ownerInvoice'), 'private-docs/owner-invoice'); + }); + + test('dasherize namespaced underscored string', function (assert) { + assert.deepEqual(dasherize('private_docs/owner_invoice'), 'private-docs/owner-invoice'); + }); }); diff --git a/packages/@ember/engine/tests/resolver/decamelize_test.js b/packages/@ember/engine/tests/resolver/decamelize_test.js index d1d1af11ffa..fb30a10a09e 100644 --- a/packages/@ember/engine/tests/resolver/decamelize_test.js +++ b/packages/@ember/engine/tests/resolver/decamelize_test.js @@ -1,39 +1,32 @@ -import { module } from 'qunit'; +import { module, test } from 'qunit'; import { decamelize } from '@ember/engine/lib/strict-resolver/string'; -import createTestFunction from './create-test-function'; module('strict-resolver | decamelize', function () { - const test = createTestFunction(decamelize); + test('does nothing with normal string', function (assert) { + assert.deepEqual(decamelize('my favorite items'), 'my favorite items'); + }); - test( - 'my favorite items', - 'my favorite items', - 'does nothing with normal string' - ); - test( - 'css-class-name', - 'css-class-name', - 'does nothing with dasherized string' - ); - test( - 'action_name', - 'action_name', - 'does nothing with underscored string' - ); - test( - 'innerHTML', - 'inner_html', - 'converts a camelized string into all lower case separated by underscores.' - ); - test('size160Url', 'size160_url', 'decamelizes strings with numbers'); - test( - 'PrivateDocs/OwnerInvoice', - 'private_docs/owner_invoice', - 'decamelize namespaced classified string' - ); - test( - 'privateDocs/ownerInvoice', - 'private_docs/owner_invoice', - 'decamelize namespaced camelized string' - ); + test('does nothing with dasherized string', function (assert) { + assert.deepEqual(decamelize('css-class-name'), 'css-class-name'); + }); + + test('does nothing with underscored string', function (assert) { + assert.deepEqual(decamelize('action_name'), 'action_name'); + }); + + test('converts a camelized string into all lower case separated by underscores.', function (assert) { + assert.deepEqual(decamelize('innerHTML'), 'inner_html'); + }); + + test('decamelizes strings with numbers', function (assert) { + assert.deepEqual(decamelize('size160Url'), 'size160_url'); + }); + + test('decamelize namespaced classified string', function (assert) { + assert.deepEqual(decamelize('PrivateDocs/OwnerInvoice'), 'private_docs/owner_invoice'); + }); + + test('decamelize namespaced camelized string', function (assert) { + assert.deepEqual(decamelize('privateDocs/ownerInvoice'), 'private_docs/owner_invoice'); + }); }); diff --git a/packages/@ember/engine/tests/resolver/registry_test.ts b/packages/@ember/engine/tests/resolver/registry_test.ts index e80e2e70e17..a9c0e4130b2 100644 --- a/packages/@ember/engine/tests/resolver/registry_test.ts +++ b/packages/@ember/engine/tests/resolver/registry_test.ts @@ -1,9 +1,22 @@ 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 () { +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() { @@ -13,21 +26,19 @@ module('strict-resolver | Application with modules', function () { two = 2; } - let app = Application.create({ + app = Application.create({ modules: {}, rootElement: '#qunit-fixture', autoboot: false, }); - let instance = (await app.visit('/')) as ApplicationInstance; + 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); - - instance.destroy(); }); test('resolves modules provided via modules property', async function (assert) { @@ -35,7 +46,7 @@ module('strict-resolver | Application with modules', function () { weDidIt = true; } - let app = Application.create({ + app = Application.create({ modules: { './services/my-thing': { default: MyService }, }, @@ -43,13 +54,11 @@ module('strict-resolver | Application with modules', function () { autoboot: false, }); - let instance = (await app.visit('/')) as ApplicationInstance; + 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'); - - instance.destroy(); }); test('resolves shorthand modules (without default wrapper)', async function (assert) { @@ -57,7 +66,7 @@ module('strict-resolver | Application with modules', function () { shorthand = true; } - let app = Application.create({ + app = Application.create({ modules: { './services/shorthand-svc': MyService, }, @@ -65,29 +74,24 @@ module('strict-resolver | Application with modules', function () { autoboot: false, }); - let instance = (await app.visit('/')) as ApplicationInstance; + instance = (await app.visit('/', {})) as ApplicationInstance; let service = instance.lookup('service:shorthand-svc') as MyService; assert.ok(service, 'service was found'); assert.ok(service.shorthand, 'service has the right property'); - - instance.destroy(); }); test('resolves router:main via ./router module', async function (assert) { - let app = Application.create({ + app = Application.create({ modules: {}, rootElement: '#qunit-fixture', autoboot: false, }); - let instance = (await app.visit('/')) as ApplicationInstance; + instance = (await app.visit('/', {})) as ApplicationInstance; - // eslint-disable-next-line ember/no-private-routing-service let router = instance.lookup('router:main'); assert.ok(router, 'router was resolved'); - - instance.destroy(); }); }); diff --git a/packages/@ember/engine/tests/resolver/underscore_test.js b/packages/@ember/engine/tests/resolver/underscore_test.js index 6b274df3882..7ffb2a6e87c 100644 --- a/packages/@ember/engine/tests/resolver/underscore_test.js +++ b/packages/@ember/engine/tests/resolver/underscore_test.js @@ -1,31 +1,32 @@ -import { module } from 'qunit'; +import { module, test } from 'qunit'; import { underscore } from '@ember/engine/lib/strict-resolver/string'; -import createTestFunction from './create-test-function'; module('strict-resolver | underscore', function () { - const test = createTestFunction(underscore); + test('with normal string', function (assert) { + assert.deepEqual(underscore('my favorite items'), 'my_favorite_items'); + }); - test('my favorite items', 'my_favorite_items', 'with normal string'); - test('css-class-name', 'css_class_name', 'with dasherized string'); - test( - 'action_name', - 'action_name', - 'does nothing with underscored string' - ); - test('innerHTML', 'inner_html', 'with camelcased string'); - test( - 'PrivateDocs/OwnerInvoice', - 'private_docs/owner_invoice', - 'underscore namespaced classified string' - ); - test( - 'privateDocs/ownerInvoice', - 'private_docs/owner_invoice', - 'underscore namespaced camelized string' - ); - test( - 'private-docs/owner-invoice', - 'private_docs/owner_invoice', - 'underscore namespaced dasherized string' - ); + test('with dasherized string', function (assert) { + assert.deepEqual(underscore('css-class-name'), 'css_class_name'); + }); + + test('does nothing with underscored string', function (assert) { + assert.deepEqual(underscore('action_name'), 'action_name'); + }); + + test('with camelcased string', function (assert) { + assert.deepEqual(underscore('innerHTML'), 'inner_html'); + }); + + test('underscore namespaced classified string', function (assert) { + assert.deepEqual(underscore('PrivateDocs/OwnerInvoice'), 'private_docs/owner_invoice'); + }); + + test('underscore namespaced camelized string', function (assert) { + assert.deepEqual(underscore('privateDocs/ownerInvoice'), 'private_docs/owner_invoice'); + }); + + test('underscore namespaced dasherized string', function (assert) { + assert.deepEqual(underscore('private-docs/owner-invoice'), 'private_docs/owner_invoice'); + }); }); diff --git a/tests/docs/expected.js b/tests/docs/expected.js index c6e4d990223..fa9f823d23d 100644 --- a/tests/docs/expected.js +++ b/tests/docs/expected.js @@ -142,6 +142,7 @@ module.exports = { 'debugCreationStack', 'debugger', 'debugPreviousTransition', + 'decamelize', 'decrementProperty', 'defer', 'deferReadiness', @@ -329,6 +330,7 @@ module.exports = { 'model', 'modelFor', 'modifier', + 'modules', 'mount', 'mut', 'name', @@ -365,6 +367,7 @@ module.exports = { 'parent', 'parentView', 'parentViewDidChange', + 'plurals', 'popObject', 'positionalParams', 'promise', @@ -501,6 +504,7 @@ module.exports = { 'typeOf', 'typeWatchers', 'unbound', + 'underscore', 'union', 'uniq', 'uniqBy', From a89a953fda5909c5794f1d3b242313319031c838 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:36:54 -0400 Subject: [PATCH 09/25] Shim Engine.Resolver so strict apps work with @ember/test-helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @ember/test-helpers' setApplication() does `const Resolver = application.Resolver; Resolver.create({namespace})`, which threw TypeError in strict-resolver apps because Resolver was only declared, never assigned. Provide a default Resolver with a create() that returns a StrictResolver built from namespace.modules and namespace.plurals. resolverFor() now just delegates to Resolver, so classic apps that override Resolver keep working as before. Also narrow the smoke-test globs to the extensions documented for the strict resolver (js/ts/gjs/gts for code, hbs for route templates) and convert site-header to a template-only .gjs — raw .hbs components without a paired .js file still aren't wrapped by the strict resolver. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/@ember/engine/index.ts | 9 ++++--- smoke-tests/scenarios/strict-resolver-test.ts | 24 ++++++++++--------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/@ember/engine/index.ts b/packages/@ember/engine/index.ts index cafb2eaf610..b51a336e89a 100644 --- a/packages/@ember/engine/index.ts +++ b/packages/@ember/engine/index.ts @@ -329,7 +329,10 @@ 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 @@ -483,10 +486,6 @@ class Engine extends Namespace.extend(RegistryProxyMixin) { @return {*} the resolved value for a given lookup */ function resolverFor(namespace: Engine) { - if (namespace.modules) { - return new StrictResolver(namespace.modules, namespace.plurals); - } - let ResolverClass = namespace.Resolver; let props = { namespace }; return ResolverClass.create(props); diff --git a/smoke-tests/scenarios/strict-resolver-test.ts b/smoke-tests/scenarios/strict-resolver-test.ts index b43b7c6c566..931e0d6c72c 100644 --- a/smoke-tests/scenarios/strict-resolver-test.ts +++ b/smoke-tests/scenarios/strict-resolver-test.ts @@ -17,12 +17,12 @@ strictAppScenarios modules = { './router': { default: Router }, - ...import.meta.glob('./services/**/*', { eager: true }), - ...import.meta.glob('./controllers/**/*', { eager: true }), - ...import.meta.glob('./routes/**/*', { eager: true }), - ...import.meta.glob('./components/**/*', { eager: true }), - ...import.meta.glob('./helpers/**/*', { eager: true }), - ...import.meta.glob('./templates/**/*', { eager: true }), + ...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', { eager: true }), }; } `, @@ -92,10 +92,12 @@ strictAppScenarios `, }, components: { - 'site-header.hbs': ` -
-

{{@title}}

-
+ 'site-header.gjs': ` + `, 'post-card.gjs': ` import Component from '@glimmer/component'; @@ -154,7 +156,7 @@ strictAppScenarios assert.dom('[data-test="app-greeting"]').hasText('Hello from strict resolver!'); }); - test('hbs component resolves from modules', async function (assert) { + 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'); From 7ffb39cb4aee470826de82d7ca1be1277def104a Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:36:49 -0400 Subject: [PATCH 10/25] Address review: inflection, colocation, drop unused helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the strict resolver boiled down to three things: proper pluralization with customization, nested-colocation component lookup, and dead code in strict-resolver/string.js. Pluralization: #plural now handles -s/-ss/-sh/-ch/-x/-z suffixes, consonant + y, and the common irregulars (child/children, person/people, man/men, woman/women, mouse/mice, tooth/teeth, foot/feet). The plurals constructor option still overrides anything, so a user can opt their "childs" type back in. Nested colocation: added #nestedColocationLookup so component:my-widget resolves to ./components/my-widget/index.{js,ts,gjs,gts} — the common pattern for a class paired with a template in a folder. Direct module matches still take precedence. Dead code: classify and underscore weren't used by the resolver or anything else in ember-source, so they're gone (along with their test files). decamelize is collapsed inside dasherize's implementation. The setStrings/getStrings/getString runtime string table was also unused; removed. dasherize loses its yuidoc @method tag since the same name is already documented via @ember/-internals/string. Tests: cover -s/-es suffix rules, consonant-y, irregular plurals, custom plural overriding an irregular, and the three colocation cases (component/helper/modifier, plus direct-wins-over-colocation). tests/docs/expected.js drops the entries whose only source was the trimmed strict-resolver/string.js. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/@ember/engine/lib/strict-resolver.ts | 53 +++++++- .../engine/lib/strict-resolver/string.js | 121 +----------------- .../engine/tests/resolver/basic-test.js | 83 ++++++++++++ .../engine/tests/resolver/classify_test.js | 64 --------- .../engine/tests/resolver/decamelize_test.js | 32 ----- .../engine/tests/resolver/underscore_test.js | 32 ----- tests/docs/expected.js | 2 - 7 files changed, 139 insertions(+), 248 deletions(-) delete mode 100644 packages/@ember/engine/tests/resolver/classify_test.js delete mode 100644 packages/@ember/engine/tests/resolver/decamelize_test.js delete mode 100644 packages/@ember/engine/tests/resolver/underscore_test.js diff --git a/packages/@ember/engine/lib/strict-resolver.ts b/packages/@ember/engine/lib/strict-resolver.ts index 35763449d47..e82eac04ebd 100644 --- a/packages/@ember/engine/lib/strict-resolver.ts +++ b/packages/@ember/engine/lib/strict-resolver.ts @@ -30,13 +30,18 @@ export class StrictResolver implements Resolver { } #plural(s: string) { - return this.#plurals.get(s) ?? s + 's'; + return this.#plurals.get(s) ?? pluralize(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]) { + 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); @@ -101,6 +106,50 @@ export class StrictResolver implements Resolver { } 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 `template.hbs` inside the same folder. + #nestedColocationLookup(type: string, name: string): Result { + let dir = this.#plural(type); + let target = `${dir}/${name}/index`; + let module = this.#modules.get(target); + if (module) { + return { hit: module }; + } + return undefined; + } +} + +// Handle the common irregular English plurals plus the standard -s / -es +// suffix rules. Users can override any type via the `plurals` constructor +// option (including overriding these defaults). +const IRREGULAR_PLURALS: Record = Object.freeze({ + child: 'children', + man: 'men', + woman: 'women', + person: 'people', + mouse: 'mice', + tooth: 'teeth', + foot: 'feet', +}); + +const NEEDS_ES_SUFFIX = /(s|ss|sh|ch|x|z)$/; +const ENDS_IN_CONSONANT_Y = /([^aeiou])y$/; + +function pluralize(singular: string): string { + let irregular = IRREGULAR_PLURALS[singular]; + if (irregular) { + return irregular; + } + if (ENDS_IN_CONSONANT_Y.test(singular)) { + return singular.replace(ENDS_IN_CONSONANT_Y, '$1ies'); + } + if (NEEDS_ES_SUFFIX.test(singular)) { + return singular + 'es'; + } + return singular + 's'; } const fileExtension = /\.\w{1,4}$/; diff --git a/packages/@ember/engine/lib/strict-resolver/string.js b/packages/@ember/engine/lib/strict-resolver/string.js index d5912da72e1..a2e04821b4f 100644 --- a/packages/@ember/engine/lib/strict-resolver/string.js +++ b/packages/@ember/engine/lib/strict-resolver/string.js @@ -1,127 +1,16 @@ -/* eslint-disable no-useless-escape */ import Cache from './cache'; -let STRINGS = {}; -export function setStrings(strings) { - STRINGS = strings; -} -export function getStrings() { - return STRINGS; -} -export function getString(name) { - return STRINGS[name]; -} + const STRING_DASHERIZE_REGEXP = /[ _]/g; -const STRING_DASHERIZE_CACHE = new Cache(1000, (key) => - decamelize(key).replace(STRING_DASHERIZE_REGEXP, '-') -); -const STRING_CLASSIFY_REGEXP_1 = /^(\-|_)+(.)?/; -const STRING_CLASSIFY_REGEXP_2 = /(.)(\-|\_|\.|\s)+(.)?/g; -const STRING_CLASSIFY_REGEXP_3 = /(^|\/|\.)([a-z])/g; -const classifyReplace1 = (_match, _separator, chr) => (chr ? `_${chr.toUpperCase()}` : ''); -const classifyReplace2 = (_match, initialChar, _separator, chr) => - initialChar + (chr ? chr.toUpperCase() : ''); -const CLASSIFY_CACHE = new Cache(1000, (str) => { - let parts = str.split('/'); - for (let i = 0; i < parts.length; i++) { - parts[i] = parts[i] - .replace(STRING_CLASSIFY_REGEXP_1, classifyReplace1) - .replace(STRING_CLASSIFY_REGEXP_2, classifyReplace2); - } - return parts.join('/').replace(STRING_CLASSIFY_REGEXP_3, (match) => match.toUpperCase()); -}); -const STRING_UNDERSCORE_REGEXP_1 = /([a-z\d])([A-Z]+)/g; -const STRING_UNDERSCORE_REGEXP_2 = /\-|\s+/g; -const UNDERSCORE_CACHE = new Cache(1000, (str) => - str - .replace(STRING_UNDERSCORE_REGEXP_1, '$1_$2') - .replace(STRING_UNDERSCORE_REGEXP_2, '_') - .toLowerCase() -); const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; + const DECAMELIZE_CACHE = new Cache(1000, (str) => str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase() ); -/** - Converts a camelized string into all lower case separated by underscores. - - ```javascript - import { decamelize } from '@ember/string'; - - decamelize('innerHTML'); // 'inner_html' - decamelize('action_name'); // 'action_name' - decamelize('css-class-name'); // 'css-class-name' - decamelize('my favorite items'); // 'my favorite items' - ``` - - @method decamelize - @param {String} str The string to decamelize. - @return {String} the decamelized string. - @private -*/ -export function decamelize(str) { - return DECAMELIZE_CACHE.get(str); -} -/** - Replaces underscores, spaces, or camelCase with dashes. - ```javascript - import { dasherize } from '@ember/string'; - - dasherize('innerHTML'); // 'inner-html' - dasherize('action_name'); // 'action-name' - dasherize('css-class-name'); // 'css-class-name' - dasherize('my favorite items'); // 'my-favorite-items' - dasherize('privateDocs/ownerInvoice'; // 'private-docs/owner-invoice' - ``` +const STRING_DASHERIZE_CACHE = new Cache(1000, (key) => + DECAMELIZE_CACHE.get(key).replace(STRING_DASHERIZE_REGEXP, '-') +); - @method dasherize - @param {String} str The string to dasherize. - @return {String} the dasherized string. - @public -*/ export function dasherize(str) { return STRING_DASHERIZE_CACHE.get(str); } -/** - Returns the UpperCamelCase form of a string. - - ```javascript - import { classify } from '@ember/string'; - - classify('innerHTML'); // 'InnerHTML' - classify('action_name'); // 'ActionName' - classify('css-class-name'); // 'CssClassName' - classify('my favorite items'); // 'MyFavoriteItems' - classify('private-docs/owner-invoice'); // 'PrivateDocs/OwnerInvoice' - ``` - - @method classify - @param {String} str the string to classify - @return {String} the classified string - @public -*/ -export function classify(str) { - return CLASSIFY_CACHE.get(str); -} -/** - More general than decamelize. Returns the lower\_case\_and\_underscored - form of a string. - - ```javascript - import { underscore } from '@ember/string'; - - underscore('innerHTML'); // 'inner_html' - underscore('action_name'); // 'action_name' - underscore('css-class-name'); // 'css_class_name' - underscore('my favorite items'); // 'my_favorite_items' - underscore('privateDocs/ownerInvoice'); // 'private_docs/owner_invoice' - ``` - - @method underscore - @param {String} str The string to underscore. - @return {String} the underscored string. - @private -*/ -export function underscore(str) { - return UNDERSCORE_CACHE.get(str); -} diff --git a/packages/@ember/engine/tests/resolver/basic-test.js b/packages/@ember/engine/tests/resolver/basic-test.js index 75b684cd720..74baccc4a5a 100644 --- a/packages/@ember/engine/tests/resolver/basic-test.js +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -355,4 +355,87 @@ module('strict-resolver | basic', function (hooks) { assert.strictEqual(result, 'whatever', 'super-duper-config/environment is found'); }); + + test('default plural handles -s / -ss / -sh / -ch / -x / -z suffixes', function (assert) { + let cases = { + './buses/red': 'bus:red', + './brushes/broom': 'brush:broom', + './benches/park': 'bench:park', + './boxes/cardboard': 'box:cardboard', + './buzzes/loud': 'buzz:loud', + './classes/math': 'class:math', + }; + + for (let [modulePath, lookup] of Object.entries(cases)) { + let r = new StrictResolver({ [modulePath]: modulePath }); + assert.strictEqual(r.resolve(lookup), modulePath, `${lookup} -> ${modulePath}`); + } + }); + + test('default plural handles consonant + y suffix (y -> ies)', function (assert) { + let r = new StrictResolver({ './categories/widgets': 'widgets-cat' }); + + assert.strictEqual(r.resolve('category:widgets'), 'widgets-cat'); + }); + + test('default plural handles common irregular nouns', function (assert) { + let cases = { + './children/alice': 'child:alice', + './people/bob': 'person:bob', + './men/carl': 'man:carl', + './women/dana': 'woman:dana', + './mice/squeaky': 'mouse:squeaky', + './teeth/molar': 'tooth:molar', + './feet/left': 'foot:left', + }; + + for (let [modulePath, lookup] of Object.entries(cases)) { + let r = new StrictResolver({ [modulePath]: modulePath }); + assert.strictEqual(r.resolve(lookup), modulePath, `${lookup} -> ${modulePath}`); + } + }); + + test('custom plural overrides irregular default', function (assert) { + // a user who insists on "childs" should be able to opt out of the + // built-in irregular plural + let r = new StrictResolver({ './childs/alice': 'alice' }, { child: 'childs' }); + + assert.strictEqual(r.resolve('child:alice'), 'alice'); + }); + + test('can lookup a nested-colocation component (index file)', function (assert) { + let expected = { isComponentFactory: true }; + resolver.addModules({ + './components/my-widget/index': { default: expected }, + }); + + assert.strictEqual(resolver.resolve('component:my-widget'), expected); + }); + + test('nested-colocation also works for helpers and modifiers', function (assert) { + let helper = {}; + let modifier = {}; + resolver.addModules({ + './helpers/format-date/index': { default: helper }, + './modifiers/on-intersect/index': { default: modifier }, + }); + + assert.strictEqual(resolver.resolve('helper:format-date'), helper); + assert.strictEqual(resolver.resolve('modifier:on-intersect'), modifier); + }); + + 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' + ); + }); }); diff --git a/packages/@ember/engine/tests/resolver/classify_test.js b/packages/@ember/engine/tests/resolver/classify_test.js deleted file mode 100644 index de35985974a..00000000000 --- a/packages/@ember/engine/tests/resolver/classify_test.js +++ /dev/null @@ -1,64 +0,0 @@ -import { module, test } from 'qunit'; -import { classify } from '@ember/engine/lib/strict-resolver/string'; - -module('strict-resolver | classify', function () { - test('classify normal string', function (assert) { - assert.deepEqual(classify('my favorite items'), 'MyFavoriteItems'); - }); - - test('classify dasherized string', function (assert) { - assert.deepEqual(classify('css-class-name'), 'CssClassName'); - }); - - test('classify underscored string', function (assert) { - assert.deepEqual(classify('action_name'), 'ActionName'); - }); - - test('classify namespaced camelized string', function (assert) { - assert.deepEqual(classify('privateDocs/ownerInvoice'), 'PrivateDocs/OwnerInvoice'); - }); - - test('classify namespaced underscored string', function (assert) { - assert.deepEqual(classify('private_docs/owner_invoice'), 'PrivateDocs/OwnerInvoice'); - }); - - test('classify namespaced dasherized string', function (assert) { - assert.deepEqual(classify('private-docs/owner-invoice'), 'PrivateDocs/OwnerInvoice'); - }); - - test('classify prefixed dasherized string', function (assert) { - assert.deepEqual(classify('-view-registry'), '_ViewRegistry'); - }); - - test('classify namespaced prefixed dasherized string', function (assert) { - assert.deepEqual(classify('components/-text-field'), 'Components/_TextField'); - }); - - test('classify underscore-prefixed underscored string', function (assert) { - assert.deepEqual(classify('_Foo_Bar'), '_FooBar'); - }); - - test('classify underscore-prefixed dasherized string', function (assert) { - assert.deepEqual(classify('_Foo-Bar'), '_FooBar'); - }); - - test('classify underscore-prefixed-namespaced underscore-prefixed string', function (assert) { - assert.deepEqual(classify('_foo/_bar'), '_Foo/_Bar'); - }); - - test('classify dash-prefixed-namespaced underscore-prefixed string', function (assert) { - assert.deepEqual(classify('-foo/_bar'), '_Foo/_Bar'); - }); - - test('classify dash-prefixed-namespaced dash-prefixed string', function (assert) { - assert.deepEqual(classify('-foo/-bar'), '_Foo/_Bar'); - }); - - test('does nothing with classified string', function (assert) { - assert.deepEqual(classify('InnerHTML'), 'InnerHTML'); - }); - - test('does nothing with classified prefixed string', function (assert) { - assert.deepEqual(classify('_FooBar'), '_FooBar'); - }); -}); diff --git a/packages/@ember/engine/tests/resolver/decamelize_test.js b/packages/@ember/engine/tests/resolver/decamelize_test.js deleted file mode 100644 index fb30a10a09e..00000000000 --- a/packages/@ember/engine/tests/resolver/decamelize_test.js +++ /dev/null @@ -1,32 +0,0 @@ -import { module, test } from 'qunit'; -import { decamelize } from '@ember/engine/lib/strict-resolver/string'; - -module('strict-resolver | decamelize', function () { - test('does nothing with normal string', function (assert) { - assert.deepEqual(decamelize('my favorite items'), 'my favorite items'); - }); - - test('does nothing with dasherized string', function (assert) { - assert.deepEqual(decamelize('css-class-name'), 'css-class-name'); - }); - - test('does nothing with underscored string', function (assert) { - assert.deepEqual(decamelize('action_name'), 'action_name'); - }); - - test('converts a camelized string into all lower case separated by underscores.', function (assert) { - assert.deepEqual(decamelize('innerHTML'), 'inner_html'); - }); - - test('decamelizes strings with numbers', function (assert) { - assert.deepEqual(decamelize('size160Url'), 'size160_url'); - }); - - test('decamelize namespaced classified string', function (assert) { - assert.deepEqual(decamelize('PrivateDocs/OwnerInvoice'), 'private_docs/owner_invoice'); - }); - - test('decamelize namespaced camelized string', function (assert) { - assert.deepEqual(decamelize('privateDocs/ownerInvoice'), 'private_docs/owner_invoice'); - }); -}); diff --git a/packages/@ember/engine/tests/resolver/underscore_test.js b/packages/@ember/engine/tests/resolver/underscore_test.js deleted file mode 100644 index 7ffb2a6e87c..00000000000 --- a/packages/@ember/engine/tests/resolver/underscore_test.js +++ /dev/null @@ -1,32 +0,0 @@ -import { module, test } from 'qunit'; -import { underscore } from '@ember/engine/lib/strict-resolver/string'; - -module('strict-resolver | underscore', function () { - test('with normal string', function (assert) { - assert.deepEqual(underscore('my favorite items'), 'my_favorite_items'); - }); - - test('with dasherized string', function (assert) { - assert.deepEqual(underscore('css-class-name'), 'css_class_name'); - }); - - test('does nothing with underscored string', function (assert) { - assert.deepEqual(underscore('action_name'), 'action_name'); - }); - - test('with camelcased string', function (assert) { - assert.deepEqual(underscore('innerHTML'), 'inner_html'); - }); - - test('underscore namespaced classified string', function (assert) { - assert.deepEqual(underscore('PrivateDocs/OwnerInvoice'), 'private_docs/owner_invoice'); - }); - - test('underscore namespaced camelized string', function (assert) { - assert.deepEqual(underscore('privateDocs/ownerInvoice'), 'private_docs/owner_invoice'); - }); - - test('underscore namespaced dasherized string', function (assert) { - assert.deepEqual(underscore('private-docs/owner-invoice'), 'private_docs/owner_invoice'); - }); -}); diff --git a/tests/docs/expected.js b/tests/docs/expected.js index fa9f823d23d..66f8353a2cd 100644 --- a/tests/docs/expected.js +++ b/tests/docs/expected.js @@ -142,7 +142,6 @@ module.exports = { 'debugCreationStack', 'debugger', 'debugPreviousTransition', - 'decamelize', 'decrementProperty', 'defer', 'deferReadiness', @@ -504,7 +503,6 @@ module.exports = { 'typeOf', 'typeWatchers', 'unbound', - 'underscore', 'union', 'uniq', 'uniqBy', From 2609f4f0a97e69eba8b2669c135c0638d1f5847b Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 19 Apr 2026 09:33:29 -0400 Subject: [PATCH 11/25] Scenario test: loading and error substates with strict resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/@ember/engine/lib/strict-resolver.ts | 6 + .../strict-resolver-substates-test.ts | 263 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 smoke-tests/scenarios/strict-resolver-substates-test.ts diff --git a/packages/@ember/engine/lib/strict-resolver.ts b/packages/@ember/engine/lib/strict-resolver.ts index e82eac04ebd..eb3f8820ba7 100644 --- a/packages/@ember/engine/lib/strict-resolver.ts +++ b/packages/@ember/engine/lib/strict-resolver.ts @@ -2,6 +2,12 @@ import type { Factory, Resolver } from '@ember/owner'; import { dasherize } from './strict-resolver/string'; 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; 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..e4c79cf10c1 --- /dev/null +++ b/smoke-tests/scenarios/strict-resolver-substates-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; + +// Companion to `basic-test.ts`: 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 (async model -> loading substate) +// - visiting /broken (rejected model -> error substate) +// - visiting a nested dynamic route (posts/:post_id) to prove the +// resolver handles sub-route templates via default lookup +strictAppScenarios + .map('strict-resolver-substates', (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 { + modulePrefix = config.modulePrefix; + + 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'); + this.route('posts', function () { + this.route('show', { path: '/:post_id' }); + }); + }); + `, + services: { + // A controllable gate for /slow's model. Tests call hold() to + // receive a promise and release() to resolve it — that lets the + // loading substate observably appear before the main template. + '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')); + } + } + `, + '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 }; + } + } + `, + }, + }, + templates: { + 'application.hbs': ` +
+ {{outlet}} +
+ `, + 'index.hbs': ` +
{{@model.welcome}}
+ `, + 'slow.hbs': ` +
{{@model.message}}
+ `, + // Auto-generated loading substate for /slow. Ember will resolve + // template:slow_loading -> slow-loading -> templates/slow-loading. + 'slow-loading.hbs': ` +
Loading slow route...
+ `, + // Auto-generated error substate for /broken. The error model is + // the thrown value, so we render its .message to prove the + // template received it. + 'broken-error.hbs': ` +
Caught error: {{@model.message}}
+ `, + 'posts.hbs': ` +
+ {{#each @model as |post|}} +
{{post.title}}
+ {{/each}} +
+ {{outlet}} + `, + 'posts': { + 'show.hbs': ` +
{{@model.title}}
+ `, + }, + }, + }, + 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' + ); + }); + + test('visiting /posts renders the list', async function (assert) { + await visit('/posts'); + assert.strictEqual(currentURL(), '/posts'); + assert.dom('[data-test="posts-list"]').exists(); + assert.dom('[data-test="post-card"]').exists({ count: 2 }); + }); + + test('visiting a nested dynamic sub-route renders the detail template', async function (assert) { + await visit('/posts/42'); + assert.strictEqual(currentURL(), '/posts/42'); + assert.dom('[data-test="post-detail"]').hasText('Post 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); + }); + }); + }); From 1302135bdfe07044edfb9e0d4fa7b49f1bf474b3 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:15:09 -0400 Subject: [PATCH 12/25] Address review: drop modulePrefix from scenario app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modulePrefix was only ever read by Namespace#toString for debug names, not by the strict resolver itself — the resolver keys off relative module paths. Dropping it alongside the unused config import keeps the scenario honest about what the strict resolver actually needs from an app. Co-Authored-By: Claude Opus 4.7 (1M context) --- smoke-tests/scenarios/strict-resolver-substates-test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/smoke-tests/scenarios/strict-resolver-substates-test.ts b/smoke-tests/scenarios/strict-resolver-substates-test.ts index e4c79cf10c1..7749d1e5266 100644 --- a/smoke-tests/scenarios/strict-resolver-substates-test.ts +++ b/smoke-tests/scenarios/strict-resolver-substates-test.ts @@ -20,11 +20,8 @@ strictAppScenarios '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 { - modulePrefix = config.modulePrefix; - modules = { './router': { default: Router }, ...import.meta.glob('./services/**/*.{js,ts}', { eager: true }), From bbd7bd83b3a728cb8243ea9a39cf37f39cc19c13 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:26:29 -0400 Subject: [PATCH 13/25] Address review: drop cache, simplify pluralize, collapse setup-resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback from the review of PR #21303: - "how much do we care about keeping this cache around?" (it's from ember-string) — drop the Cache helper and its test file. dasherize is now a plain `replace.toLowerCase().replace` in strict-resolver/ string.js. For the workloads the resolver sees (module lookups during boot) the cache is noise. - "we don't need to specify this list -- the class that has `modules = ` assignable on it should be able to specify their inflection rules" — drop the built-in IRREGULAR_PLURALS table and the -s/-es / consonant-y suffix rules. Pluralization is back to naive `type + 's'`, matching ember-resolver's behavior. Consumers that want children / people / buses register them up-front via the `plurals` constructor option, same as they already do for `config`. - "is this true? does ember-resolver do this?" — the regex-based rules weren't in ember-resolver either; they're gone alongside the irregulars. - "let's remove this function, I think" — delete the setupResolver helper and its file; basic-test.js now instantiates StrictResolver directly in beforeEach. - "can we also add a strict application to the v2 app scenarios? I thiiiiiink we just need to overwrite the app.js in that scenario" — yes: add `strictResolver` as a variant of v2AppScenarios (in addition to embroiderVite). basic-test.ts now runs against both v2 configurations. Tests updated to match: the suffix/irregular/y→ies cases are removed; one test left behind proves that registering a custom plural still lets you do `child: 'children'` explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/@ember/engine/lib/strict-resolver.ts | 32 +---------- .../engine/lib/strict-resolver/cache.js | 35 ------------ .../engine/lib/strict-resolver/string.js | 15 ++--- .../engine/tests/resolver/-setup-resolver.js | 9 --- .../engine/tests/resolver/basic-test.js | 55 ++++--------------- smoke-tests/scenarios/scenarios.ts | 31 ++++++++++- 6 files changed, 44 insertions(+), 133 deletions(-) delete mode 100644 packages/@ember/engine/lib/strict-resolver/cache.js delete mode 100644 packages/@ember/engine/tests/resolver/-setup-resolver.js diff --git a/packages/@ember/engine/lib/strict-resolver.ts b/packages/@ember/engine/lib/strict-resolver.ts index eb3f8820ba7..55a4dc74b83 100644 --- a/packages/@ember/engine/lib/strict-resolver.ts +++ b/packages/@ember/engine/lib/strict-resolver.ts @@ -36,7 +36,7 @@ export class StrictResolver implements Resolver { } #plural(s: string) { - return this.#plurals.get(s) ?? pluralize(s); + return this.#plurals.get(s) ?? s + 's'; } resolve(fullName: string): Factory | object | undefined { @@ -128,36 +128,6 @@ export class StrictResolver implements Resolver { } } -// Handle the common irregular English plurals plus the standard -s / -es -// suffix rules. Users can override any type via the `plurals` constructor -// option (including overriding these defaults). -const IRREGULAR_PLURALS: Record = Object.freeze({ - child: 'children', - man: 'men', - woman: 'women', - person: 'people', - mouse: 'mice', - tooth: 'teeth', - foot: 'feet', -}); - -const NEEDS_ES_SUFFIX = /(s|ss|sh|ch|x|z)$/; -const ENDS_IN_CONSONANT_Y = /([^aeiou])y$/; - -function pluralize(singular: string): string { - let irregular = IRREGULAR_PLURALS[singular]; - if (irregular) { - return irregular; - } - if (ENDS_IN_CONSONANT_Y.test(singular)) { - return singular.replace(ENDS_IN_CONSONANT_Y, '$1ies'); - } - if (NEEDS_ES_SUFFIX.test(singular)) { - return singular + 'es'; - } - return singular + 's'; -} - const fileExtension = /\.\w{1,4}$/; const leadingDotSlash = /^\.\//; diff --git a/packages/@ember/engine/lib/strict-resolver/cache.js b/packages/@ember/engine/lib/strict-resolver/cache.js deleted file mode 100644 index 68902ada388..00000000000 --- a/packages/@ember/engine/lib/strict-resolver/cache.js +++ /dev/null @@ -1,35 +0,0 @@ -export default class Cache { - constructor(limit, func, store) { - this.limit = limit; - this.func = func; - this.store = store; - this.size = 0; - this.misses = 0; - this.hits = 0; - this.store = store || new Map(); - } - get(key) { - let value = this.store.get(key); - if (this.store.has(key)) { - this.hits++; - return this.store.get(key); - } else { - this.misses++; - value = this.set(key, this.func(key)); - } - return value; - } - set(key, value) { - if (this.limit > this.size) { - this.size++; - this.store.set(key, value); - } - return value; - } - purge() { - this.store.clear(); - this.size = 0; - this.hits = 0; - this.misses = 0; - } -} diff --git a/packages/@ember/engine/lib/strict-resolver/string.js b/packages/@ember/engine/lib/strict-resolver/string.js index a2e04821b4f..b1662340293 100644 --- a/packages/@ember/engine/lib/strict-resolver/string.js +++ b/packages/@ember/engine/lib/strict-resolver/string.js @@ -1,16 +1,9 @@ -import Cache from './cache'; - const STRING_DASHERIZE_REGEXP = /[ _]/g; const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; -const DECAMELIZE_CACHE = new Cache(1000, (str) => - str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase() -); - -const STRING_DASHERIZE_CACHE = new Cache(1000, (key) => - DECAMELIZE_CACHE.get(key).replace(STRING_DASHERIZE_REGEXP, '-') -); - export function dasherize(str) { - return STRING_DASHERIZE_CACHE.get(str); + return str + .replace(STRING_DECAMELIZE_REGEXP, '$1_$2') + .toLowerCase() + .replace(STRING_DASHERIZE_REGEXP, '-'); } diff --git a/packages/@ember/engine/tests/resolver/-setup-resolver.js b/packages/@ember/engine/tests/resolver/-setup-resolver.js deleted file mode 100644 index 55174802cc4..00000000000 --- a/packages/@ember/engine/tests/resolver/-setup-resolver.js +++ /dev/null @@ -1,9 +0,0 @@ -import { StrictResolver } from '@ember/engine/lib/strict-resolver'; - -export function setupResolver(options = {}) { - let modules = {}; - let plurals = options.plurals; - let resolver = new StrictResolver(modules, plurals); - - return { resolver, modules }; -} diff --git a/packages/@ember/engine/tests/resolver/basic-test.js b/packages/@ember/engine/tests/resolver/basic-test.js index 74baccc4a5a..7af9433f2c5 100644 --- a/packages/@ember/engine/tests/resolver/basic-test.js +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -1,13 +1,13 @@ import { module, test } from 'qunit'; import { StrictResolver } from '@ember/engine/lib/strict-resolver'; -import { setupResolver } from './-setup-resolver'; module('strict-resolver | basic', function (hooks) { let resolver; let modules; hooks.beforeEach(function () { - ({ resolver, modules } = setupResolver()); + modules = {}; + resolver = new StrictResolver(modules); }); test('can lookup something', function (assert) { @@ -356,49 +356,14 @@ module('strict-resolver | basic', function (hooks) { assert.strictEqual(result, 'whatever', 'super-duper-config/environment is found'); }); - test('default plural handles -s / -ss / -sh / -ch / -x / -z suffixes', function (assert) { - let cases = { - './buses/red': 'bus:red', - './brushes/broom': 'brush:broom', - './benches/park': 'bench:park', - './boxes/cardboard': 'box:cardboard', - './buzzes/loud': 'buzz:loud', - './classes/math': 'class:math', - }; - - for (let [modulePath, lookup] of Object.entries(cases)) { - let r = new StrictResolver({ [modulePath]: modulePath }); - assert.strictEqual(r.resolve(lookup), modulePath, `${lookup} -> ${modulePath}`); - } - }); - - test('default plural handles consonant + y suffix (y -> ies)', function (assert) { - let r = new StrictResolver({ './categories/widgets': 'widgets-cat' }); - - assert.strictEqual(r.resolve('category:widgets'), 'widgets-cat'); - }); - - test('default plural handles common irregular nouns', function (assert) { - let cases = { - './children/alice': 'child:alice', - './people/bob': 'person:bob', - './men/carl': 'man:carl', - './women/dana': 'woman:dana', - './mice/squeaky': 'mouse:squeaky', - './teeth/molar': 'tooth:molar', - './feet/left': 'foot:left', - }; - - for (let [modulePath, lookup] of Object.entries(cases)) { - let r = new StrictResolver({ [modulePath]: modulePath }); - assert.strictEqual(r.resolve(lookup), modulePath, `${lookup} -> ${modulePath}`); - } - }); - - test('custom plural overrides irregular default', function (assert) { - // a user who insists on "childs" should be able to opt out of the - // built-in irregular plural - let r = new StrictResolver({ './childs/alice': 'alice' }, { child: 'childs' }); + 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/smoke-tests/scenarios/scenarios.ts b/smoke-tests/scenarios/scenarios.ts index edb3b94d4c2..5fb1cbad759 100644 --- a/smoke-tests/scenarios/scenarios.ts +++ b/smoke-tests/scenarios/scenarios.ts @@ -21,6 +21,34 @@ 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'; + + 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('./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,10 +62,9 @@ export const v2AppScenarios = Scenarios.fromProject(() => }) ).expand({ embroiderVite, + strictResolver, }); -function strictResolver(project: Project) {} - export const strictAppScenarios = Scenarios.fromProject(() => Project.fromDir(dirname(require.resolve('../v2-app-template/package.json')), { linkDevDeps: true, From a9cfe4340d8bff4dd9f2e1be20dd647d509c5139 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:34:31 -0400 Subject: [PATCH 14/25] Fix prettier + drop deleted cache.js from package.json aliases --- package.json | 1 - packages/@ember/engine/tests/resolver/basic-test.js | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/package.json b/package.json index 3e59dce96e8..07049dd9316 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,6 @@ "@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/lib/strict-resolver/cache.js": "ember-source/@ember/engine/lib/strict-resolver/cache.js", "@ember/engine/lib/strict-resolver/string.js": "ember-source/@ember/engine/lib/strict-resolver/string.js", "@ember/engine/parent.js": "ember-source/@ember/engine/parent.js", "@ember/enumerable/index.js": "ember-source/@ember/enumerable/index.js", diff --git a/packages/@ember/engine/tests/resolver/basic-test.js b/packages/@ember/engine/tests/resolver/basic-test.js index 7af9433f2c5..7cedb924424 100644 --- a/packages/@ember/engine/tests/resolver/basic-test.js +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -360,10 +360,7 @@ module('strict-resolver | basic', function (hooks) { // 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' } - ); + let r = new StrictResolver({ './children/alice': 'alice' }, { child: 'children' }); assert.strictEqual(r.resolve('child:alice'), 'alice'); }); From a54e6872599053a8d37fa50f783a2ed82eb0d7a3 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:04:03 -0400 Subject: [PATCH 15/25] Drop strictResolver from v2AppScenarios; keep dedicated strict scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI revealed that basic-test.ts includes a route template (`templates/ example-gjs-route.gjs`) that exports a `.gjs` Component class — a v2 convention that works under embroiderVite (via compat-modules) but doesn't render with the strict resolver: owner.lookup('template: example-gjs-route') hands back the Component and rendering fails to produce [data-test=\"model-field\"], so the acceptance test fails. That's a real gap worth fixing, but it belongs in a separate PR that can focus on template-as-component lookup under the strict resolver. For now, drop the v2AppScenarios variant and leave the strictResolver function available via strictAppScenarios (used by the dedicated strict-resolver-test.ts / strict-resolver-substates-test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) --- smoke-tests/scenarios/scenarios.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/smoke-tests/scenarios/scenarios.ts b/smoke-tests/scenarios/scenarios.ts index 5fb1cbad759..b20e672795b 100644 --- a/smoke-tests/scenarios/scenarios.ts +++ b/smoke-tests/scenarios/scenarios.ts @@ -62,7 +62,6 @@ export const v2AppScenarios = Scenarios.fromProject(() => }) ).expand({ embroiderVite, - strictResolver, }); export const strictAppScenarios = Scenarios.fromProject(() => From f11a87b6db95be25ea2f61320927c6a3898b3f3c Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:41:49 -0400 Subject: [PATCH 16/25] Inline dasherize into strict-resolver.ts, drop the string/ directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to review: with cache.js gone, `string.js` was a 9-line file exporting just dasherize. Inline it as a private helper in strict-resolver.ts and delete the strict-resolver/ subdirectory (plus dasherize_test.js — dasherize's behavior is exercised via resolver.normalize(...) tests in basic-test.js). Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 - packages/@ember/engine/lib/strict-resolver.ts | 7 +++- .../engine/lib/strict-resolver/string.js | 9 ----- .../engine/tests/resolver/dasherize_test.js | 36 ------------------- 4 files changed, 6 insertions(+), 47 deletions(-) delete mode 100644 packages/@ember/engine/lib/strict-resolver/string.js delete mode 100644 packages/@ember/engine/tests/resolver/dasherize_test.js diff --git a/package.json b/package.json index 07049dd9316..f1ac50823d6 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,6 @@ "@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/lib/strict-resolver/string.js": "ember-source/@ember/engine/lib/strict-resolver/string.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/lib/strict-resolver.ts b/packages/@ember/engine/lib/strict-resolver.ts index 55a4dc74b83..a865d304d93 100644 --- a/packages/@ember/engine/lib/strict-resolver.ts +++ b/packages/@ember/engine/lib/strict-resolver.ts @@ -1,5 +1,4 @@ import type { Factory, Resolver } from '@ember/owner'; -import { dasherize } from './strict-resolver/string'; export class StrictResolver implements Resolver { // Ember's router uses this flag to decide whether to auto-generate @@ -130,6 +129,12 @@ export class StrictResolver implements Resolver { 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 = | { diff --git a/packages/@ember/engine/lib/strict-resolver/string.js b/packages/@ember/engine/lib/strict-resolver/string.js deleted file mode 100644 index b1662340293..00000000000 --- a/packages/@ember/engine/lib/strict-resolver/string.js +++ /dev/null @@ -1,9 +0,0 @@ -const STRING_DASHERIZE_REGEXP = /[ _]/g; -const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; - -export function dasherize(str) { - return str - .replace(STRING_DECAMELIZE_REGEXP, '$1_$2') - .toLowerCase() - .replace(STRING_DASHERIZE_REGEXP, '-'); -} diff --git a/packages/@ember/engine/tests/resolver/dasherize_test.js b/packages/@ember/engine/tests/resolver/dasherize_test.js deleted file mode 100644 index 2fbec4bd3f0..00000000000 --- a/packages/@ember/engine/tests/resolver/dasherize_test.js +++ /dev/null @@ -1,36 +0,0 @@ -import { module, test } from 'qunit'; -import { dasherize } from '@ember/engine/lib/strict-resolver/string'; - -module('strict-resolver | dasherize', function () { - test('dasherize normal string', function (assert) { - assert.deepEqual(dasherize('my favorite items'), 'my-favorite-items'); - }); - - test('does nothing with dasherized string', function (assert) { - assert.deepEqual(dasherize('css-class-name'), 'css-class-name'); - }); - - test('dasherize underscored string', function (assert) { - assert.deepEqual(dasherize('action_name'), 'action-name'); - }); - - test('dasherize camelcased string', function (assert) { - assert.deepEqual(dasherize('innerHTML'), 'inner-html'); - }); - - test('dasherize string that is the property name of Object.prototype', function (assert) { - assert.deepEqual(dasherize('toString'), 'to-string'); - }); - - test('dasherize namespaced classified string', function (assert) { - assert.deepEqual(dasherize('PrivateDocs/OwnerInvoice'), 'private-docs/owner-invoice'); - }); - - test('dasherize namespaced camelized string', function (assert) { - assert.deepEqual(dasherize('privateDocs/ownerInvoice'), 'private-docs/owner-invoice'); - }); - - test('dasherize namespaced underscored string', function (assert) { - assert.deepEqual(dasherize('private_docs/owner_invoice'), 'private-docs/owner-invoice'); - }); -}); From 9b0f351034622d9f32dcfc27f8cf59b3c7eba040 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:23:27 -0400 Subject: [PATCH 17/25] Apply suggestion from @NullVoxPopuli --- smoke-tests/scenarios/scenarios.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/smoke-tests/scenarios/scenarios.ts b/smoke-tests/scenarios/scenarios.ts index b20e672795b..5fb1cbad759 100644 --- a/smoke-tests/scenarios/scenarios.ts +++ b/smoke-tests/scenarios/scenarios.ts @@ -62,6 +62,7 @@ export const v2AppScenarios = Scenarios.fromProject(() => }) ).expand({ embroiderVite, + strictResolver, }); export const strictAppScenarios = Scenarios.fromProject(() => From 7b1ca65f69f7ecc1bee2f8b384e0b1b6f7fed39b Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:55:20 -0400 Subject: [PATCH 18/25] Register ember-page-title service in the strict-resolver scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strictResolver variant of v2AppScenarios was added in 9b0f351 so basic-test.ts also runs against a strict app. It started failing on the Acceptance | example gjs route test with: TypeError: Cannot read properties of undefined (reading 'push') at new PageTitle (app-…js:…) at ClassicHelperManager.createHelper … Root cause: v2-app-template's application.gjs contains `{{pageTitle "V2AppTemplate"}}`. The pageTitle helper does `@service('page-title')`, so rendering the application template triggers `owner.lookup('service:page-title')`. Under the classic ember-resolver + compat-modules pipeline that service is surfaced automatically from the addon. With the strict resolver, addons don't contribute modules unless the app explicitly wires them into `modules` — which we weren't doing — so the service lookup missed, the helper constructed with `this.tokens` undefined, and the `.push` call threw. Explicitly register the addon service in the variant's app.js: import PageTitleService from 'ember-page-title/services/page-title'; modules = { './services/page-title': { default: PageTitleService }, … }; This matches the intended strict-resolver ergonomics (no auto addon registration; the app declares its modules) and is also what the RFC asks consumers to do. basic-test.ts now passes against the strictResolver variant, alongside strict-resolver-test.ts and strict-resolver-substates-test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- smoke-tests/scenarios/scenarios.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/smoke-tests/scenarios/scenarios.ts b/smoke-tests/scenarios/scenarios.ts index 5fb1cbad759..25c229230e6 100644 --- a/smoke-tests/scenarios/scenarios.ts +++ b/smoke-tests/scenarios/scenarios.ts @@ -32,10 +32,17 @@ function strictResolver(project: Project) { '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 }), From 8ec017a70a1c36e344884616e7bb36b12f3bb0f4 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:42:29 -0400 Subject: [PATCH 19/25] Add tests for the shorthand/`default` selection rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the edge cases of the resolver's "use `.default` if it exists, else use the whole value" rule (from #extractDefaultExport). The existing basic-test covers the straightforward cases — a `{ default: X }` module yields X, and a plain shorthand value is returned as-is — but not what happens when the shorthand value *itself* carries a `default` property, or when `.default` is falsy. Added three new tests: - shorthand value whose own `default` is truthy is unwrapped (documenting the gotcha that a class/object accidentally exposing a truthy `default` will be replaced by that property) - class with `default = undefined` or no `default` at all is used directly (the "else" branch of the rule) - ES-module-shaped `{ default, named, other }` returns only `default`; extra named exports are ignored All three assert today's behavior; no resolver changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../engine/tests/resolver/basic-test.js | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/@ember/engine/tests/resolver/basic-test.js b/packages/@ember/engine/tests/resolver/basic-test.js index 7cedb924424..43c7188a65b 100644 --- a/packages/@ember/engine/tests/resolver/basic-test.js +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -215,6 +215,83 @@ module('strict-resolver | basic', function (hooks) { assert.strictEqual(result, MyService, 'shorthand module was resolved'); }); + test("shorthand value with a truthy 'default' property has 'default' unwrapped", function (assert) { + // This is the gotcha of the shorthand form: the resolver's selection + // logic is "use the `default` property if it exists, else use the whole + // value". A shorthand value that happens to carry its own `default` + // property will therefore be unwrapped just like a proper ES module + // namespace object — usually not what the author meant. + let unwrapped = { iAmDefault: true }; + let RegisteredValue = { + default: unwrapped, + iAmTheWholeThing: true, + }; + + let resolver2 = new StrictResolver({ + './services/surprise': RegisteredValue, + }); + + assert.strictEqual( + resolver2.resolve('service:surprise'), + unwrapped, + '`default` wins over the containing object' + ); + }); + + 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('normalization', function (assert) { assert.strictEqual(resolver.normalize('controller:posts'), 'controller:posts'); assert.strictEqual(resolver.normalize('controller:postsIndex'), 'controller:posts-index'); From 11e09666ea0a0157546b1292935cf04395186385 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:05:01 -0400 Subject: [PATCH 20/25] Trim redundant tests from strict-resolver basic-test Four groups of redundancy made the 34-test file mostly noise: 1. Nine near-identical "can lookup a " tests (adapter, service, helper, component, modifier, template, view, route, controller) all exercised the same `type + 's' -> dir` default-lookup path. Collapsed into one table-driven test that asserts each fullName lookup hits the expected module key. 2. `router:main is looked up as just "router" key` and `store:main is looked up as just "store" key` were the same mainLookup behavior with different type strings. Merged into a single test that asserts both. 3. `store:post is looked up as stores/post` just re-tests the default pluralization path already covered by #1. 4. `shorthand module registration (no default wrapper)` is subsumed by the later `shorthand class with a falsy or missing default falls back to the class itself` test (an object without a `default` property is the "class without default" case). 5. `can lookup a nested-colocation component (index file)` and `nested-colocation also works for helpers and modifiers` were two adjacent tests each asserting the same fallback strategy for a different type. Merged into one test that covers all three types. Net: 34 tests -> 21 tests, -113 lines, identical coverage. All three strict-resolver smoke scenarios (strictResolver-basics, strictResolver-strict-resolver, strictResolver-strict-resolver- substates) still pass locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../engine/tests/resolver/basic-test.js | 181 ++++-------------- 1 file changed, 34 insertions(+), 147 deletions(-) diff --git a/packages/@ember/engine/tests/resolver/basic-test.js b/packages/@ember/engine/tests/resolver/basic-test.js index 43c7188a65b..af439f831e4 100644 --- a/packages/@ember/engine/tests/resolver/basic-test.js +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -10,103 +10,27 @@ module('strict-resolver | basic', function (hooks) { resolver = new StrictResolver(modules); }); - test('can lookup something', function (assert) { - let expected = {}; - modules['./adapters/post'] = { default: expected }; - resolver.addModules(modules); - - let adapter = resolver.resolve('adapter:post'); - - assert.ok(adapter, 'adapter was returned'); - assert.strictEqual(adapter, expected, 'default export was returned'); - }); - - test('can lookup a service', function (assert) { - let expected = {}; - modules['./services/session'] = { default: expected }; - resolver.addModules(modules); - - let service = resolver.resolve('service:session'); - - assert.ok(service, 'service was returned'); - assert.strictEqual(service, expected, 'default export was returned'); - }); - - test('can lookup a helper', function (assert) { - let expected = { isHelperInstance: true }; - modules['./helpers/reverse-list'] = { default: expected }; - resolver.addModules(modules); - - let helper = resolver.resolve('helper:reverse-list'); - - assert.ok(helper, 'helper was returned'); - assert.strictEqual(helper, expected, 'default export was returned'); - }); - - test('can lookup a component', function (assert) { - let expected = { isComponentFactory: true }; - modules['./components/my-widget'] = { default: expected }; - resolver.addModules(modules); - - let component = resolver.resolve('component:my-widget'); - - assert.ok(component, 'component was returned'); - assert.strictEqual(component, expected, 'default export was returned'); - }); - - test('can lookup a modifier', function (assert) { - let expected = { isModifier: true }; - modules['./modifiers/auto-focus'] = { default: expected }; - resolver.addModules(modules); - - let modifier = resolver.resolve('modifier:auto-focus'); - - assert.ok(modifier, 'modifier was returned'); - assert.strictEqual(modifier, expected, 'default export was returned'); - }); - - test('can lookup a template', function (assert) { - let expected = { isTemplate: true }; - modules['./templates/application'] = { default: expected }; - resolver.addModules(modules); - - let template = resolver.resolve('template:application'); - - assert.ok(template, 'template was returned'); - assert.strictEqual(template, expected, 'default export was returned'); - }); - - test('can lookup a view', function (assert) { - let expected = { isViewFactory: true }; - modules['./views/queue-list'] = { default: expected }; - resolver.addModules(modules); - - let view = resolver.resolve('view:queue-list'); - - assert.ok(view, 'view was returned'); - assert.strictEqual(view, expected, 'default export was returned'); - }); - - test('can lookup a route', function (assert) { - let expected = { isRouteFactory: true }; - modules['./routes/index'] = { default: expected }; - resolver.addModules(modules); - - let route = resolver.resolve('route:index'); - - assert.ok(route, 'route was returned'); - assert.strictEqual(route, expected, 'default export was returned'); - }); - - test('can lookup a controller', function (assert) { - let expected = { isController: true }; - modules['./controllers/application'] = { default: expected }; - resolver.addModules(modules); + 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' }, + ]; - let controller = resolver.resolve('controller:application'); + for (let { fullName, key } of cases) { + let expected = { fullName }; + let r = new StrictResolver({ [key]: { default: expected } }); - assert.ok(controller, 'controller was returned'); - assert.strictEqual(controller, expected, 'default export was returned'); + assert.strictEqual(r.resolve(fullName), expected, `${fullName} -> ${key}`); + } }); test("will return the raw value if no 'default' is available", function (assert) { @@ -127,31 +51,16 @@ module('strict-resolver | basic', function (hooks) { ); }); - test('router:main is looked up as just "router" key', function (assert) { - modules['./router'] = 'the-router'; - resolver.addModules(modules); - - let result = resolver.resolve('router:main'); - - assert.strictEqual(result, 'the-router', 'router:main was looked up'); - }); - - test('store:main is looked up as just "store" key', function (assert) { - modules['./store'] = 'the-store'; - resolver.addModules(modules); - - let result = resolver.resolve('store:main'); - - assert.strictEqual(result, 'the-store', 'store:main was looked up'); - }); - - test('store:post is looked up as stores/post', function (assert) { - modules['./stores/post'] = 'whatever'; - resolver.addModules(modules); - - let result = resolver.resolve('store:post'); + 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(result, 'whatever', 'store:post was looked up'); + assert.strictEqual(resolver.resolve('router:main'), 'the-router'); + assert.strictEqual(resolver.resolve('store:main'), 'the-store'); }); test('returns undefined for missing modules', function (assert) { @@ -199,22 +108,6 @@ module('strict-resolver | basic', function (hooks) { assert.strictEqual(resolver2.resolve('service:foo'), 'from-ts', 'file extension was stripped'); }); - test('shorthand module registration (no default wrapper)', function (assert) { - let MyService = { - create() { - return this; - }, - }; - - let resolver2 = new StrictResolver({ - './services/my-thing': MyService, - }); - - let result = resolver2.resolve('service:my-thing'); - - assert.strictEqual(result, MyService, 'shorthand module was resolved'); - }); - test("shorthand value with a truthy 'default' property has 'default' unwrapped", function (assert) { // This is the gotcha of the shorthand form: the resolver's selection // logic is "use the `default` property if it exists, else use the whole @@ -442,23 +335,17 @@ module('strict-resolver | basic', function (hooks) { assert.strictEqual(r.resolve('child:alice'), 'alice'); }); - test('can lookup a nested-colocation component (index file)', function (assert) { - let expected = { isComponentFactory: true }; - resolver.addModules({ - './components/my-widget/index': { default: expected }, - }); - - assert.strictEqual(resolver.resolve('component:my-widget'), expected); - }); - - test('nested-colocation also works for helpers and modifiers', function (assert) { - let helper = {}; - let modifier = {}; + test('nested-colocation: `type:name` falls back to `types/name/index`', function (assert) { + let component = { component: true }; + let helper = { helper: true }; + let modifier = { modifier: true }; resolver.addModules({ + './components/my-widget/index': { default: component }, './helpers/format-date/index': { default: helper }, './modifiers/on-intersect/index': { default: modifier }, }); + assert.strictEqual(resolver.resolve('component:my-widget'), component); assert.strictEqual(resolver.resolve('helper:format-date'), helper); assert.strictEqual(resolver.resolve('modifier:on-intersect'), modifier); }); From b6e004bd18fe43e3562b354c1f9a59e7da0ecef7 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:35:05 -0400 Subject: [PATCH 21/25] Trim cross-file redundancies in the strict-resolver tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the basic-test.js cleanup, three cross-file overlaps remain. None change coverage — each dropped case is already asserted by another test. registry_test.ts: - `resolves shorthand modules (without default wrapper)` is already covered by basic-test's three shorthand-edge-case tests, and the Application integration is already proven by `resolves modules provided via modules property` just above it. - `resolves router:main via ./router module` just re-checks the mainLookup path that basic-test covers via `\`type:main\` resolves to the unpluralized \`type\` module key`; router:main is registered by Ember itself, not by the strict resolver. strict-resolver-test.ts: - `gjs component resolves from modules` and `sub-route with nested model` both visit `/posts` and inspect `[data-test=post-card]`. Folded the h2-exists assertion into the count test and dropped the standalone gjs test. strict-resolver-substates-test.ts: - `visiting /posts renders the list` and `visiting a nested dynamic sub-route renders the detail template` repeat what strict-resolver-test.ts already checks; the substates scenario should focus on loading/error state behaviour. - Removed the now-unused posts/show scaffolding (router entry, routes/posts.js, routes/posts/show.js, templates/posts.hbs, templates/posts/show.hbs) to keep the scenario app minimal. Net: −85 lines across three files, no loss of coverage. All three strict-resolver smoke scenarios still pass locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../engine/tests/resolver/registry_test.ts | 34 ----------- .../strict-resolver-substates-test.ts | 61 ++----------------- smoke-tests/scenarios/strict-resolver-test.ts | 10 ++- 3 files changed, 10 insertions(+), 95 deletions(-) diff --git a/packages/@ember/engine/tests/resolver/registry_test.ts b/packages/@ember/engine/tests/resolver/registry_test.ts index a9c0e4130b2..aaedda0b37c 100644 --- a/packages/@ember/engine/tests/resolver/registry_test.ts +++ b/packages/@ember/engine/tests/resolver/registry_test.ts @@ -60,38 +60,4 @@ module('strict-resolver | Application with modules', function (hooks) { assert.ok(service, 'service was found'); assert.ok(service.weDidIt, 'service has the right property'); }); - - test('resolves shorthand modules (without default wrapper)', async function (assert) { - class MyService extends Service { - shorthand = true; - } - - app = Application.create({ - modules: { - './services/shorthand-svc': MyService, - }, - rootElement: '#qunit-fixture', - autoboot: false, - }); - - instance = (await app.visit('/', {})) as ApplicationInstance; - let service = instance.lookup('service:shorthand-svc') as MyService; - - assert.ok(service, 'service was found'); - assert.ok(service.shorthand, 'service has the right property'); - }); - - test('resolves router:main via ./router module', async function (assert) { - app = Application.create({ - modules: {}, - rootElement: '#qunit-fixture', - autoboot: false, - }); - - instance = (await app.visit('/', {})) as ApplicationInstance; - - let router = instance.lookup('router:main'); - - assert.ok(router, 'router was resolved'); - }); }); diff --git a/smoke-tests/scenarios/strict-resolver-substates-test.ts b/smoke-tests/scenarios/strict-resolver-substates-test.ts index 7749d1e5266..4563f559a25 100644 --- a/smoke-tests/scenarios/strict-resolver-substates-test.ts +++ b/smoke-tests/scenarios/strict-resolver-substates-test.ts @@ -8,11 +8,12 @@ const { module: Qmodule, test } = QUnit; // Ember's auto-generated loading/error substates through real route // transitions. Covers: // -// - visiting / (plain route + template) -// - visiting /slow (async model -> loading substate) -// - visiting /broken (rejected model -> error substate) -// - visiting a nested dynamic route (posts/:post_id) to prove the -// resolver handles sub-route templates via default lookup +// - visiting / (plain route + template — sanity) +// - visiting /slow (async model -> loading substate) +// - visiting /broken (rejected model -> error substate) +// +// Nested/dynamic route coverage lives in strict-resolver-test.ts so we +// don't pay to rebuild it here. strictAppScenarios .map('strict-resolver-substates', (project) => { project.mergeFiles({ @@ -43,9 +44,6 @@ strictAppScenarios Router.map(function () { this.route('slow'); this.route('broken'); - this.route('posts', function () { - this.route('show', { path: '/:post_id' }); - }); }); `, services: { @@ -103,27 +101,6 @@ strictAppScenarios } } `, - '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 }; - } - } - `, - }, }, templates: { 'application.hbs': ` @@ -148,19 +125,6 @@ strictAppScenarios 'broken-error.hbs': `
Caught error: {{@model.message}}
`, - 'posts.hbs': ` -
- {{#each @model as |post|}} -
{{post.title}}
- {{/each}} -
- {{outlet}} - `, - 'posts': { - 'show.hbs': ` -
{{@model.title}}
- `, - }, }, }, tests: { @@ -226,19 +190,6 @@ strictAppScenarios 'Caught error: intentional model failure' ); }); - - test('visiting /posts renders the list', async function (assert) { - await visit('/posts'); - assert.strictEqual(currentURL(), '/posts'); - assert.dom('[data-test="posts-list"]').exists(); - assert.dom('[data-test="post-card"]').exists({ count: 2 }); - }); - - test('visiting a nested dynamic sub-route renders the detail template', async function (assert) { - await visit('/posts/42'); - assert.strictEqual(currentURL(), '/posts/42'); - assert.dom('[data-test="post-detail"]').hasText('Post 42'); - }); }); `, }, diff --git a/smoke-tests/scenarios/strict-resolver-test.ts b/smoke-tests/scenarios/strict-resolver-test.ts index 931e0d6c72c..e952d0a0213 100644 --- a/smoke-tests/scenarios/strict-resolver-test.ts +++ b/smoke-tests/scenarios/strict-resolver-test.ts @@ -162,10 +162,13 @@ strictAppScenarios assert.dom('[data-test="site-header"] h1').hasText('Strict App'); }); - test('sub-route with nested model', async function (assert) { + test('sub-route with nested model renders a gjs component per item', async function (assert) { await visit('/posts'); assert.strictEqual(currentURL(), '/posts'); 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' + ); }); test('dynamic segment sub-route', async function (assert) { @@ -173,11 +176,6 @@ strictAppScenarios assert.strictEqual(currentURL(), '/posts/42'); assert.dom('[data-test="post-detail"]').hasText('Post 42'); }); - - test('gjs component resolves from modules', async function (assert) { - await visit('/posts'); - assert.dom('[data-test="post-card"] h2').exists(); - }); }); `, }, From 4924964623f119315151ecc53b3d032e8242ab0c Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:43:36 -0400 Subject: [PATCH 22/25] Fill the nested-route gaps in the strict-resolver scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing tests exercise a two-level nested route (`posts.show` via `/posts/42`) but only assert the child template renders. Three concrete gaps: 1. The parent template isn't asserted to render alongside the child — a bug where the parent's `{{outlet}}` silently broke would pass. 2. There's no `templates/posts/index.hbs`, so we don't prove the resolver can walk `template:posts.index` -> `templates/posts/index` (the `name.index -> name/index` nested folder path). 3. No 3-level nesting coverage, so a regression in the `type:a.b.c -> a/b/c` normalization wouldn't be caught. Changes: - Add `this.route('comments')` under `posts.show` and the matching `routes/posts/show/comments.js` + `templates/posts/show/comments.hbs`. Give `posts/show.hbs` a `{{outlet}}` so the grandchild has somewhere to render. - Add a `templates/posts/index.hbs`. - Promote the two sub-route tests to also check that the parent template is still in the DOM (post-cards + `[data-test="posts"]`). - Add a new "three-level nested route resolves every level" test that visits `/posts/42/comments` and asserts all three templates (posts.hbs, posts/show.hbs, posts/show/comments.hbs) resolved and rendered. No resolver changes. All three strict-resolver smoke scenarios still pass locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- smoke-tests/scenarios/strict-resolver-test.ts | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/smoke-tests/scenarios/strict-resolver-test.ts b/smoke-tests/scenarios/strict-resolver-test.ts index e952d0a0213..8e11b52a026 100644 --- a/smoke-tests/scenarios/strict-resolver-test.ts +++ b/smoke-tests/scenarios/strict-resolver-test.ts @@ -37,7 +37,9 @@ strictAppScenarios Router.map(function () { this.route('posts', function () { - this.route('show', { path: '/:post_id' }); + this.route('show', { path: '/:post_id' }, function () { + this.route('comments'); + }); }); }); `, @@ -79,6 +81,16 @@ strictAppScenarios } } `, + 'show': { + 'comments.js': ` + import Route from '@ember/routing/route'; + export default class extends Route { + model() { + return ['great post!', 'meh']; + } + } + `, + }, }, }, controllers: { @@ -129,9 +141,20 @@ strictAppScenarios {{outlet}} `, 'posts': { + 'index.hbs': ` +
posts index page
+ `, 'show.hbs': `
{{@model.title}}
+ {{outlet}} `, + 'show': { + 'comments.hbs': ` +
    + {{#each @model as |c|}}
  • {{c}}
  • {{/each}} +
+ `, + }, }, }, }, @@ -162,20 +185,51 @@ strictAppScenarios assert.dom('[data-test="site-header"] h1').hasText('Strict App'); }); - test('sub-route with nested model renders a gjs component per item', async function (assert) { + test('parent route with auto-generated index renders both templates', async function (assert) { + // Visiting /posts activates posts + posts.index. Both + // templates must resolve: posts.hbs (with {{outlet}}) and + // posts/index.hbs (nested under a folder). Proves the + // strict resolver handles both the parent and the nested + // \`type:name.index\` -> \`type/name/index\` path. 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 segment sub-route', async function (assert) { + test('dynamic nested child renders alongside its parent template', async function (assert) { + // /posts/42 activates both posts (parent) and posts.show + // (child). The parent template must still be present — + // the child renders into its {{outlet}}. 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) { + // /posts/42/comments activates posts -> posts.show -> + // posts.show.comments. Every template in the chain must + // resolve, and the strict resolver must walk + // template:posts.show.comments -> templates/posts/show/comments. + 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' + ); + }); }); `, }, From 656f2cde041a3f3d98b7d66940e680970767f7e4 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:20:30 -0400 Subject: [PATCH 23/25] Fix some logic, re-organize tests --- packages/@ember/engine/lib/strict-resolver.ts | 4 +- .../engine/tests/resolver/basic-test.js | 522 ++++++++---------- 2 files changed, 236 insertions(+), 290 deletions(-) diff --git a/packages/@ember/engine/lib/strict-resolver.ts b/packages/@ember/engine/lib/strict-resolver.ts index a865d304d93..8fcd93da130 100644 --- a/packages/@ember/engine/lib/strict-resolver.ts +++ b/packages/@ember/engine/lib/strict-resolver.ts @@ -115,8 +115,10 @@ export class StrictResolver implements Resolver { // 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 `template.hbs` inside the same folder. + // 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); diff --git a/packages/@ember/engine/tests/resolver/basic-test.js b/packages/@ember/engine/tests/resolver/basic-test.js index af439f831e4..2074f9ab3c6 100644 --- a/packages/@ember/engine/tests/resolver/basic-test.js +++ b/packages/@ember/engine/tests/resolver/basic-test.js @@ -1,7 +1,7 @@ import { module, test } from 'qunit'; import { StrictResolver } from '@ember/engine/lib/strict-resolver'; -module('strict-resolver | basic', function (hooks) { +module('StrictResolver', function (hooks) { let resolver; let modules; @@ -10,82 +10,155 @@ module('strict-resolver | basic', function (hooks) { resolver = new StrictResolver(modules); }); - 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 r = new StrictResolver({ [key]: { default: expected } }); - - assert.strictEqual(r.resolve(fullName), expected, `${fullName} -> ${key}`); - } - }); - - test("will return the raw value if no 'default' is available", function (assert) { - modules['./fruits/orange'] = 'is awesome'; - resolver.addModules(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}`); + } + }); - assert.strictEqual(resolver.resolve('fruit:orange'), 'is awesome', 'raw value was returned'); - }); + 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', + }); - test("will unwrap the 'default' export automatically", function (assert) { - modules['./fruits/orange'] = { default: 'is awesome' }; - resolver.addModules(modules); + assert.strictEqual(resolver.resolve('router:main'), 'the-router'); + assert.strictEqual(resolver.resolve('store:main'), 'the-store'); + }); - assert.strictEqual( - resolver.resolve('fruit:orange'), - 'is awesome', - 'default export was unwrapped' - ); - }); + test('returns undefined for missing modules', function (assert) { + let result = resolver.resolve('service:nonexistent'); - 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(result, undefined, 'undefined was returned'); }); - 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'); + test('can resolve self via resolver:current', function (assert) { + let self = resolver.resolve('resolver:current'); - assert.strictEqual(result, undefined, 'undefined was returned'); + assert.ok(self, 'resolver:current returned a factory'); + assert.strictEqual(self.create(), resolver, 'factory creates the resolver'); + }); }); - test('can resolve self via resolver:current', function (assert) { - let self = resolver.resolve('resolver:current'); + 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'); - assert.ok(self, 'resolver:current returned a factory'); - assert.strictEqual(self.create(), resolver, 'factory creates the resolver'); - }); + // 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'); - test('addModules allows adding modules after construction', function (assert) { - let expected = {}; + // 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' + ); - resolver.addModules({ - './components/hello': { default: expected }, + // and modifiers + assert.strictEqual( + resolver.normalize('modifier:fabulous-component'), + 'modifier:fabulous-component' + ); + assert.strictEqual( + resolver.normalize('modifier:fabulouslyMissing'), + 'modifier:fabulouslyMissing' + ); }); - let component = resolver.resolve('component:hello'); + 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'); + assert.strictEqual(component, expected, 'component was resolved'); + }); }); test('module paths with ./ prefix are normalized', function (assert) { @@ -108,260 +181,131 @@ module('strict-resolver | basic', function (hooks) { assert.strictEqual(resolver2.resolve('service:foo'), 'from-ts', 'file extension was stripped'); }); - test("shorthand value with a truthy 'default' property has 'default' unwrapped", function (assert) { - // This is the gotcha of the shorthand form: the resolver's selection - // logic is "use the `default` property if it exists, else use the whole - // value". A shorthand value that happens to carry its own `default` - // property will therefore be unwrapped just like a proper ES module - // namespace object — usually not what the author meant. - let unwrapped = { iAmDefault: true }; - let RegisteredValue = { - default: unwrapped, - iAmTheWholeThing: true, - }; - - let resolver2 = new StrictResolver({ - './services/surprise': RegisteredValue, - }); - - assert.strictEqual( - resolver2.resolve('service:surprise'), - unwrapped, - '`default` wins over the containing object' - ); - }); - - 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(); + 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(); + 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 r = new StrictResolver({ + './services/with-undefined-default': ClassWithUndefinedDefault, + './services/without-default': ClassWithoutDefault, + }); - let resolver2 = new StrictResolver({ - './services/extras': registered, + 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' + ); }); - assert.strictEqual(resolver2.resolve('service:extras'), defaultExport); - }); - - 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('camel case modifier is not normalized to dasherized', function (assert) { - let expected = {}; - resolver.addModules({ - './modifiers/other-thing': { default: 'oh no' }, - './modifiers/otherThing': { default: expected }, + 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); }); - let modifier = resolver.resolve('modifier:otherThing'); - - assert.strictEqual(modifier, expected); - }); + 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('normalization is idempotent', function (assert) { - let examples = [ - 'controller:posts', - 'controller:posts.post.index', - 'controller:blog/posts.post_index', - 'template:foo_bar', - ]; + 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 }, + }); - examples.forEach((example) => { assert.strictEqual( - resolver.normalize(resolver.normalize(example)), - resolver.normalize(example) + resolver.resolve('component:my-widget'), + direct, + 'direct match wins over the colocation fallback' ); }); }); - 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' }); + module('pluralization', function () { + test('config type pluralizes as config by default', function (assert) { + modules['./config/environment'] = 'env-config'; + resolver.addModules(modules); - let result = resolver2.resolve('sheep:baaaaaa'); + let result = resolver.resolve('config:environment'); - assert.strictEqual(result, 'whatever', 'custom plural was used'); - }); + assert.strictEqual(result, 'env-config', 'config/environment is found'); + }); - test("'config' plural can be overridden", function (assert) { - let resolver2 = new StrictResolver( - { './super-duper-config/environment': 'whatever' }, - { config: 'super-duper-config' } - ); + test('custom plurals are supported', function (assert) { + let resolver2 = new StrictResolver({ './sheep/baaaaaa': 'whatever' }, { sheep: 'sheep' }); - let result = resolver2.resolve('config:environment'); + let result = resolver2.resolve('sheep:baaaaaa'); - assert.strictEqual(result, 'whatever', 'super-duper-config/environment is found'); - }); + assert.strictEqual(result, 'whatever', 'custom plural was used'); + }); - 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' }); + test("'config' plural can be overridden", function (assert) { + let resolver2 = new StrictResolver( + { './super-duper-config/environment': 'whatever' }, + { config: 'super-duper-config' } + ); - assert.strictEqual(r.resolve('child:alice'), 'alice'); - }); + let result = resolver2.resolve('config:environment'); - test('nested-colocation: `type:name` falls back to `types/name/index`', function (assert) { - let component = { component: true }; - let helper = { helper: true }; - let modifier = { modifier: true }; - resolver.addModules({ - './components/my-widget/index': { default: component }, - './helpers/format-date/index': { default: helper }, - './modifiers/on-intersect/index': { default: modifier }, + assert.strictEqual(result, 'whatever', 'super-duper-config/environment is found'); }); - assert.strictEqual(resolver.resolve('component:my-widget'), component); - assert.strictEqual(resolver.resolve('helper:format-date'), helper); - assert.strictEqual(resolver.resolve('modifier:on-intersect'), modifier); - }); + 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' }); - 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(r.resolve('child:alice'), 'alice'); }); - - assert.strictEqual( - resolver.resolve('component:my-widget'), - direct, - 'direct match wins over the colocation fallback' - ); }); }); From f57675e2b48cbc58e1a9677e4bed491f1b1a797f Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:41:37 -0400 Subject: [PATCH 24/25] Apply suggestion from @NullVoxPopuli --- smoke-tests/scenarios/strict-resolver-test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/smoke-tests/scenarios/strict-resolver-test.ts b/smoke-tests/scenarios/strict-resolver-test.ts index 8e11b52a026..f6109db4a1b 100644 --- a/smoke-tests/scenarios/strict-resolver-test.ts +++ b/smoke-tests/scenarios/strict-resolver-test.ts @@ -13,7 +13,6 @@ strictAppScenarios import config from 'v2-app-template/config/environment'; export default class App extends Application { - modulePrefix = config.modulePrefix; modules = { './router': { default: Router }, From a29216890e9147a7dcc15692807a64673be2911d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:45:00 -0400 Subject: [PATCH 25/25] Cleanup --- .../strict-resolver-substates-test.ts | 20 +------------------ smoke-tests/scenarios/strict-resolver-test.ts | 14 ------------- 2 files changed, 1 insertion(+), 33 deletions(-) diff --git a/smoke-tests/scenarios/strict-resolver-substates-test.ts b/smoke-tests/scenarios/strict-resolver-substates-test.ts index 4563f559a25..e6511dff293 100644 --- a/smoke-tests/scenarios/strict-resolver-substates-test.ts +++ b/smoke-tests/scenarios/strict-resolver-substates-test.ts @@ -3,17 +3,6 @@ import type { PreparedApp } from 'scenario-tester'; import * as QUnit from 'qunit'; const { module: Qmodule, test } = QUnit; -// Companion to `basic-test.ts`: 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 — sanity) -// - visiting /slow (async model -> loading substate) -// - visiting /broken (rejected model -> error substate) -// -// Nested/dynamic route coverage lives in strict-resolver-test.ts so we -// don't pay to rebuild it here. strictAppScenarios .map('strict-resolver-substates', (project) => { project.mergeFiles({ @@ -47,9 +36,7 @@ strictAppScenarios }); `, services: { - // A controllable gate for /slow's model. Tests call hold() to - // receive a promise and release() to resolve it — that lets the - // loading substate observably appear before the main template. + // RSVP.defer-as-a-service 'gate.js': ` import Service from '@ember/service'; @@ -114,14 +101,9 @@ strictAppScenarios 'slow.hbs': `
{{@model.message}}
`, - // Auto-generated loading substate for /slow. Ember will resolve - // template:slow_loading -> slow-loading -> templates/slow-loading. 'slow-loading.hbs': `
Loading slow route...
`, - // Auto-generated error substate for /broken. The error model is - // the thrown value, so we render its .message to prove the - // template received it. 'broken-error.hbs': `
Caught error: {{@model.message}}
`, diff --git a/smoke-tests/scenarios/strict-resolver-test.ts b/smoke-tests/scenarios/strict-resolver-test.ts index f6109db4a1b..b086e53c108 100644 --- a/smoke-tests/scenarios/strict-resolver-test.ts +++ b/smoke-tests/scenarios/strict-resolver-test.ts @@ -19,8 +19,6 @@ strictAppScenarios ...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', { eager: true }), }; } @@ -185,11 +183,6 @@ strictAppScenarios }); test('parent route with auto-generated index renders both templates', async function (assert) { - // Visiting /posts activates posts + posts.index. Both - // templates must resolve: posts.hbs (with {{outlet}}) and - // posts/index.hbs (nested under a folder). Proves the - // strict resolver handles both the parent and the nested - // \`type:name.index\` -> \`type/name/index\` path. await visit('/posts'); assert.strictEqual(currentURL(), '/posts'); assert.dom('[data-test="posts"]').exists('parent template rendered'); @@ -203,9 +196,6 @@ strictAppScenarios }); test('dynamic nested child renders alongside its parent template', async function (assert) { - // /posts/42 activates both posts (parent) and posts.show - // (child). The parent template must still be present — - // the child renders into its {{outlet}}. await visit('/posts/42'); assert.strictEqual(currentURL(), '/posts/42'); assert.dom('[data-test="posts"]').exists('parent template still rendered'); @@ -214,10 +204,6 @@ strictAppScenarios }); test('three-level nested route resolves every level', async function (assert) { - // /posts/42/comments activates posts -> posts.show -> - // posts.show.comments. Every template in the chain must - // resolve, and the strict resolver must walk - // template:posts.show.comments -> templates/posts/show/comments. await visit('/posts/42/comments'); assert.strictEqual(currentURL(), '/posts/42/comments'); assert.dom('[data-test="posts"]').exists('level 1: posts.hbs');