diff --git a/babel.test.config.mjs b/babel.test.config.mjs index dad707aeaf6..b2705501e39 100644 --- a/babel.test.config.mjs +++ b/babel.test.config.mjs @@ -14,12 +14,17 @@ import { buildMacros } from '@embroider/macros/babel'; // eslint-disable-next-line no-redeclare const require = createRequire(import.meta.url); const buildDebugMacroPlugin = require('./broccoli/build-debug-macro-plugin.cjs'); +const appEmberSatisfiesPlugin = require('./broccoli/app-ember-satisfies-plugin.cjs'); const isProduction = process.env.EMBER_ENV === 'production'; -// @ember/test-helpers and @ember/test-waiters use @embroider/macros. +// @ember/test-helpers, @ember/test-waiters and ember-qunit use @embroider/macros. // // buildMacros() reads NODE_ENV, but our builds select the mode with EMBER_ENV. const macros = buildMacros({ + setConfig: { + // index.html loads the QUnit styles, and tests render into #qunit-fixture. + 'ember-qunit': { theme: 'none', disableContainerStyles: true }, + }, configure(config) { if (!isProduction) { config.enablePackageDevelopment(process.cwd()); @@ -39,5 +44,10 @@ export default { ], ], - plugins: [...baseConfig.plugins, ...macros.babelMacros, ...buildDebugMacroPlugin(!isProduction)], + plugins: [ + ...baseConfig.plugins, + appEmberSatisfiesPlugin, + ...macros.babelMacros, + ...buildDebugMacroPlugin(!isProduction), + ], }; diff --git a/broccoli/app-ember-satisfies-plugin.cjs b/broccoli/app-ember-satisfies-plugin.cjs new file mode 100644 index 00000000000..c1217c19000 --- /dev/null +++ b/broccoli/app-ember-satisfies-plugin.cjs @@ -0,0 +1,32 @@ +const semver = require('semver'); +const { version } = require('../package.json'); + +/* + `appEmberSatisfies` from @embroider/macros reads the ember-source version + from the dependencies of the app. + + In this repo the app is ember-source, and a package cannot depend on itself. + So this plugin answers the macro with the version of this repo, before + @embroider/macros sees it. +*/ +module.exports = function appEmberSatisfiesPlugin({ types: t }) { + return { + name: 'app-ember-satisfies', + visitor: { + Program(program) { + program.traverse({ + CallExpression(call) { + if (!call.get('callee').referencesImport('@embroider/macros', 'appEmberSatisfies')) { + return; + } + + let [range] = call.node.arguments; + let satisfied = semver.satisfies(version, range.value, { includePrerelease: true }); + + call.replaceWith(t.booleanLiteral(satisfied)); + }, + }); + }, + }, + }; +}; diff --git a/index.html b/index.html index ba2b0bcf2fe..d67e07a7834 100644 --- a/index.html +++ b/index.html @@ -7,9 +7,14 @@
diff --git a/package.json b/package.json index 26b3917add8..ee5d718b1b7 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "ember-cli-browserstack": "^4.0.0", "ember-cli-dependency-checker": "^3.3.1", "ember-cli-yuidoc": "^0.9.1", + "ember-qunit": "^9.1.0", "eslint": "^9.21.0", "eslint-import-resolver-node": "^0.3.10", "eslint-plugin-disable-features": "^0.1.3", diff --git a/packages/@ember/-internals/glimmer/tests/integration/helpers/element-test.gjs b/packages/@ember/-internals/glimmer/tests/integration/helpers/element-test.gjs new file mode 100644 index 00000000000..75294e27315 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/helpers/element-test.gjs @@ -0,0 +1,195 @@ +import { DEBUG } from '@glimmer/env'; +import { tracked } from '@glimmer/tracking'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, settled } from '@ember/test-helpers'; +import { element, hash } from '@ember/helper'; +import { on } from '@ember/modifier'; + +module('Helpers test: {{element}}', function (hooks) { + setupRenderingTest(hooks); + + test('it renders a tag with the given tag name', async function (assert) { + await render( + + ); + + assert.strictEqual(this.element.innerHTML, '

hello world!

'); + }); + + test('it does not render any tags when passed an empty string', async function (assert) { + await render( + + ); + + assert.strictEqual(this.element.innerHTML, 'hello world!'); + }); + + test('it throws when passed null', async function (assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + let nil = null; + + await assert.rejects( + render(), + /The argument passed to the `element` helper must be a string/ + ); + }); + + test('it throws when passed undefined', async function (assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + let undef = undefined; + + await assert.rejects( + render(), + /The argument passed to the `element` helper must be a string/ + ); + }); + + test('it works with element modifiers', async function (assert) { + let didClick = () => {}; + + await render( + + ); + + assert.strictEqual( + this.element.innerHTML, + '' + ); + }); + + test('it can be rendered multiple times', async function (assert) { + await render( + + ); + + assert.strictEqual( + this.element.innerHTML, + '

hello

world

!!!!!

' + ); + }); + + test('it renders when the tag name changes', async function (assert) { + class State { + @tracked htmlTag = 'h1'; + } + + let state = new State(); + + await render( + + ); + + assert.strictEqual(this.element.innerHTML, '

hello

'); + + state.htmlTag = 'h2'; + await settled(); + assert.strictEqual(this.element.innerHTML, '

hello

'); + + state.htmlTag = 'h3'; + await settled(); + assert.strictEqual(this.element.innerHTML, '

hello

'); + + state.htmlTag = ''; + await settled(); + assert.strictEqual(this.element.textContent, 'hello'); + + state.htmlTag = 'h1'; + await settled(); + assert.strictEqual(this.element.innerHTML, '

hello

'); + }); + + test('it can be passed as argument and works with ...attributes', async function (assert) { + let Inner = ; + + await render(); + + assert.strictEqual(this.element.innerHTML, '

Test

'); + }); + + test('it requires at least one argument', async function (assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + await assert.rejects( + render(), + /The `element` helper takes a single positional argument/ + ); + }); + + test('it requires no more than one argument', async function (assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + await assert.rejects( + render(), + /The `element` helper takes a single positional argument/ + ); + }); + + test('it does not take any named arguments', async function (assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + await assert.rejects( + render( + + ), + /The `element` helper does not take any named arguments/ + ); + }); + + test('it throws when passed a number', async function (assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + let num = 123; + + await assert.rejects( + render(), + /The argument passed to the `element` helper must be a string \(you passed `123`\)/ + ); + }); + + test('it throws when passed a boolean', async function (assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + let bool = false; + + await assert.rejects( + render(), + /The argument passed to the `element` helper must be a string \(you passed `false`\)/ + ); + }); + + test('it throws when passed an object', async function (assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + await assert.rejects( + render(), + /The argument passed to the `element` helper must be a string/ + ); + }); +}); diff --git a/packages/@ember/-internals/glimmer/tests/integration/helpers/element-test.js b/packages/@ember/-internals/glimmer/tests/integration/helpers/element-test.js deleted file mode 100644 index 32618bf1869..00000000000 --- a/packages/@ember/-internals/glimmer/tests/integration/helpers/element-test.js +++ /dev/null @@ -1,207 +0,0 @@ -import { DEBUG } from '@glimmer/env'; -import { tracked } from '@glimmer/tracking'; -import { RenderingTestCase, moduleFor, runTask } from 'internal-test-helpers'; -import { element as elementHelper, hash } from '@ember/helper'; -import { on } from '@ember/modifier'; -import { template } from '@ember/template-compiler/runtime'; - -moduleFor( - 'Helpers test: {{element}}', - class extends RenderingTestCase { - '@test it renders a tag with the given tag name'() { - let AComponent = template( - `{{#let (element "h1") as |Tag|}}hello world!{{/let}}`, - { scope: () => ({ element: elementHelper }) } - ); - this.renderComponent(AComponent, { expect: '

hello world!

' }); - } - - '@test it does not render any tags when passed an empty string'() { - let AComponent = template( - `{{#let (element "") as |Tag|}}hello world!{{/let}}`, - { scope: () => ({ element: elementHelper }) } - ); - this.renderComponent(AComponent, { expect: 'hello world!' }); - } - - ['@test it throws when passed null']() { - if (!DEBUG) { - this.assert.expect(0); - return; - } - - let nil = null; - this.assert.throws(() => { - let AComponent = template(`{{#let (element nil) as |Tag|}}hello{{/let}}`, { - scope: () => ({ element: elementHelper, nil }), - }); - this.renderComponent(AComponent, { expect: '' }); - }, /The argument passed to the `element` helper must be a string/); - } - - ['@test it throws when passed undefined']() { - if (!DEBUG) { - this.assert.expect(0); - return; - } - - let undef = undefined; - this.assert.throws(() => { - let AComponent = template(`{{#let (element undef) as |Tag|}}hello{{/let}}`, { - scope: () => ({ element: elementHelper, undef }), - }); - this.renderComponent(AComponent, { expect: '' }); - }, /The argument passed to the `element` helper must be a string/); - } - - '@test it works with element modifiers'() { - let didClick = () => {}; - let AComponent = template( - `{{#let (element "button") as |Tag|}}hello world!{{/let}}`, - { scope: () => ({ element: elementHelper, on, didClick }) } - ); - this.renderComponent(AComponent, { - expect: '', - }); - } - - '@test it can be rendered multiple times'() { - let AComponent = template( - `{{#let (element "h1") as |Tag|}}helloworld!!!!!{{/let}}`, - { scope: () => ({ element: elementHelper }) } - ); - this.renderComponent(AComponent, { - expect: - '

hello

world

!!!!!

', - }); - } - - '@test it renders when the tag name changes'() { - class State { - @tracked htmlTag = 'h1'; - } - - let state = new State(); - - let AComponent = template( - `{{#let (element state.htmlTag) as |Tag|}}hello{{/let}}`, - { scope: () => ({ element: elementHelper, state }) } - ); - this.renderComponent(AComponent, { - expect: '

hello

', - }); - - runTask(() => (state.htmlTag = 'h2')); - this.assertHTML('

hello

'); - - runTask(() => (state.htmlTag = 'h3')); - this.assertHTML('

hello

'); - - runTask(() => (state.htmlTag = '')); - this.assertText('hello'); - - runTask(() => (state.htmlTag = 'h1')); - this.assertHTML('

hello

'); - } - - '@test it can be passed as argument and works with ...attributes'() { - let Inner = template( - `{{#let @tag as |Tag|}}{{yield}}{{/let}}`, - { scope: () => ({ element: elementHelper }) } - ); - - let Outer = template(`Test`, { - scope: () => ({ Inner, element: elementHelper }), - }); - - this.renderComponent(Outer, { expect: '

Test

' }); - } - - ['@test it requires at least one argument']() { - if (!DEBUG) { - this.assert.expect(0); - return; - } - - this.assert.throws(() => { - let AComponent = template(`{{#let (element) as |Tag|}}hello{{/let}}`, { - scope: () => ({ element: elementHelper }), - }); - this.renderComponent(AComponent, { expect: '' }); - }, /The `element` helper takes a single positional argument/); - } - - ['@test it requires no more than one argument']() { - if (!DEBUG) { - this.assert.expect(0); - return; - } - - this.assert.throws(() => { - let AComponent = template(`{{#let (element "h1" "h2") as |Tag|}}hello{{/let}}`, { - scope: () => ({ element: elementHelper }), - }); - this.renderComponent(AComponent, { expect: '' }); - }, /The `element` helper takes a single positional argument/); - } - - ['@test it does not take any named arguments']() { - if (!DEBUG) { - this.assert.expect(0); - return; - } - - this.assert.throws(() => { - let AComponent = template( - `{{#let (element "h1" id="content") as |Tag|}}hello{{/let}}`, - { scope: () => ({ element: elementHelper }) } - ); - this.renderComponent(AComponent, { expect: '' }); - }, /The `element` helper does not take any named arguments/); - } - - ['@test it throws when passed a number']() { - if (!DEBUG) { - this.assert.expect(0); - return; - } - - let num = 123; - this.assert.throws(() => { - let AComponent = template(`{{#let (element num) as |Tag|}}hello{{/let}}`, { - scope: () => ({ element: elementHelper, num }), - }); - this.renderComponent(AComponent, { expect: '' }); - }, /The argument passed to the `element` helper must be a string \(you passed `123`\)/); - } - - ['@test it throws when passed a boolean']() { - if (!DEBUG) { - this.assert.expect(0); - return; - } - - let bool = false; - this.assert.throws(() => { - let AComponent = template(`{{#let (element bool) as |Tag|}}hello{{/let}}`, { - scope: () => ({ element: elementHelper, bool }), - }); - this.renderComponent(AComponent, { expect: '' }); - }, /The argument passed to the `element` helper must be a string \(you passed `false`\)/); - } - - ['@test it throws when passed an object']() { - if (!DEBUG) { - this.assert.expect(0); - return; - } - - this.assert.throws(() => { - let AComponent = template(`{{#let (element (hash)) as |Tag|}}hello{{/let}}`, { - scope: () => ({ element: elementHelper, hash }), - }); - this.renderComponent(AComponent, { expect: '' }); - }, /The argument passed to the `element` helper must be a string/); - } - } -); diff --git a/packages/internal-test-helpers/lib/ember-dev/setup-test-helpers.js b/packages/internal-test-helpers/lib/ember-dev/setup-test-helpers.js new file mode 100644 index 00000000000..da75c506bb1 --- /dev/null +++ b/packages/internal-test-helpers/lib/ember-dev/setup-test-helpers.js @@ -0,0 +1,55 @@ +import Application from '@ember/application'; +import { setTesting } from '@ember/debug'; +import { _backburner } from '@ember/runloop'; +import { setApplication } from '@ember/test-helpers'; +import { registerDestructor } from '@glimmer/destroyable'; +import { ModuleBasedResolver } from '../test-resolver'; + +/* + The setup that the `tests/test-helper.js` of an app does for + `@ember/test-helpers` and `ember-qunit`. + + An app gives one `Application` to `setApplication()`, for all tests. + + This test suite cannot keep an `Application` between tests, because + `moduleFor` fails a test that leaves a namespace behind. So each test + gets its own `Application`, which is destroyed with its instance. + + This file is JavaScript, because the type declarations of + `@ember/test-helpers` do not resolve the `@ember/*` packages in this repo. +*/ +export default function setupTestHelpers() { + let options = { + autoboot: false, + rootElement: '#qunit-fixture', + Resolver: ModuleBasedResolver, + }; + + let applicationPerTest = { + Resolver: ModuleBasedResolver, + + boot() { + return Promise.resolve(this); + }, + + buildInstance() { + let application = Application.create(options); + let instance = application.buildInstance(); + + registerDestructor(instance, () => application.destroy()); + + return { + boot: () => application.boot().then(() => instance.boot()), + }; + }, + }; + + setApplication(applicationPerTest); + + // `teardownContext()` turns testing mode off, and `setupContext()` leaves + // the backburner debug mode on. + QUnit.testStart(() => { + setTesting(true); + _backburner.DEBUG = false; + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f53d3c07d5..2fef94be9d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,9 @@ importers: ember-cli-yuidoc: specifier: ^0.9.1 version: 0.9.1 + ember-qunit: + specifier: ^9.1.0 + version: 9.1.0(@ember/test-helpers@5.5.0(@babel/core@7.29.7))(qunit@2.26.0) eslint: specifier: ^9.21.0 version: 9.39.4 diff --git a/rollup.config.mjs b/rollup.config.mjs index 95266380d4d..1162c7291b1 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -19,6 +19,7 @@ const testDependencies = [ '@ember/test-helpers', '@ember/test-waiters', 'dom-element-descriptors', + 'ember-qunit', 'qunit', 'vite', 'js-reporters', diff --git a/vite.config.mjs b/vite.config.mjs index d610bfe1619..768765fc057 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -67,7 +67,7 @@ export default defineConfig(({ mode }) => { }, ], }, - optimizeDeps: { noDiscovery: true, include: ['expect-type'] }, + optimizeDeps: { noDiscovery: true, include: ['expect-type', 'qunit'] }, publicDir: 'tests/public', build,