ember.nvp 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.github/workflows/ci.yml +5 -1
  2. package/.release-plan.json +51 -9
  3. package/CHANGELOG.md +30 -0
  4. package/README.md +41 -28
  5. package/RELEASE.md +16 -14
  6. package/package.json +1 -1
  7. package/packages/build-tooling-utils/README.md +7 -12
  8. package/packages/build-tooling-utils/package.json +1 -1
  9. package/packages/build-tooling-utils/src/extensions.ts +4 -3
  10. package/packages/build-tooling-utils/src/maybe-babel.ts +18 -15
  11. package/packages/rolldown/README.md +138 -98
  12. package/packages/rolldown/index.ts +66 -28
  13. package/packages/rolldown/package.json +1 -1
  14. package/packages/rolldown/src/app-reexports.test.ts +4 -3
  15. package/packages/rolldown/src/app-reexports.ts +37 -27
  16. package/packages/rolldown/src/babel.test.ts +10 -5
  17. package/packages/rolldown/src/babel.ts +117 -72
  18. package/packages/rolldown/src/bundle.test.ts +149 -0
  19. package/packages/rolldown/src/bundle.ts +71 -0
  20. package/packages/rolldown/src/config.ts +42 -18
  21. package/packages/rolldown/src/dts.test.ts +42 -26
  22. package/packages/rolldown/src/externals.test.ts +16 -11
  23. package/packages/rolldown/src/externals.ts +45 -28
  24. package/packages/rolldown/src/isolated-declarations.ts +56 -43
  25. package/packages/rolldown/src/plugin-like.ts +18 -15
  26. package/packages/rolldown/src/published-types.test.ts +18 -13
  27. package/packages/rolldown/src/transform.test.ts +26 -17
  28. package/packages/rolldown/src/transform.ts +68 -48
  29. package/packages/vite/README.md +10 -11
  30. package/packages/vite/package.json +1 -1
  31. package/packages/vite/src/ember.ts +44 -36
  32. package/packages/vite/src/test-html.ts +13 -11
  33. package/src/bases/README.md +6 -3
  34. package/src/bases/minimal-app/app.test.ts +7 -4
  35. package/src/bases/minimal-app/files/app/app.ts +2 -3
  36. package/src/bases/minimal-app/files/app/router.ts +3 -3
  37. package/src/bases/minimal-app/index.js +10 -6
  38. package/src/bases/minimal-custom-element/files/README.md +95 -0
  39. package/src/bases/minimal-custom-element/files/package.json +47 -0
  40. package/src/bases/minimal-custom-element/files/src/components/counter.gts +27 -0
  41. package/src/bases/minimal-custom-element/files/src/element.ts +45 -0
  42. package/src/bases/minimal-custom-element/files/src/index.ts +2 -0
  43. package/src/bases/minimal-custom-element/files/src/register.ts +5 -0
  44. package/src/bases/minimal-custom-element/files/tsconfig.json +10 -0
  45. package/src/bases/minimal-custom-element/files/tsdown.config.js +7 -0
  46. package/src/bases/minimal-custom-element/index.js +212 -0
  47. package/src/bases/minimal-extension/files/README.md +13 -5
  48. package/src/bases/minimal-extension/files/app/app.ts +2 -3
  49. package/src/bases/minimal-extension/files/app/config.ts +5 -3
  50. package/src/bases/minimal-extension/index.js +16 -11
  51. package/src/bases/minimal-library/files/README.md +8 -7
  52. package/src/bases/minimal-library/index.js +21 -15
  53. package/src/cli/args.js +1 -1
  54. package/src/cli/generator.js +4 -0
  55. package/src/cli/index.js +23 -18
  56. package/src/cli/questions/project-type.js +17 -2
  57. package/src/cli/questions/write.js +12 -10
  58. package/src/layers/are-the-types-wrong/index.js +6 -6
  59. package/src/layers/github-actions/index.js +7 -4
  60. package/src/layers/inspector-support/index.js +2 -2
  61. package/src/layers/publint/index.js +2 -2
  62. package/src/layers/qunit/index.js +21 -17
  63. package/src/layers/readme/index.js +55 -4
  64. package/src/layers/renovate/index.js +5 -5
  65. package/src/layers/typescript/index.js +20 -13
  66. package/src/layers/vitest/index.js +8 -4
  67. package/src/utils/babel.js +3 -2
  68. package/src/utils/buffer.js +2 -2
  69. package/src/utils/cli.js +4 -4
  70. package/src/utils/fs.js +23 -14
  71. package/src/utils/inspector-support.js +22 -19
  72. package/src/utils/npm.js +15 -12
  73. package/src/utils/project-type.js +19 -0
  74. package/src/utils/project.js +14 -2
  75. package/src/utils/remove-types.js +2 -4
  76. package/src/utils/rewrite-imports.js +43 -31
  77. package/src/utils/stage.js +44 -33
  78. package/src/utils/tsdown-config.js +6 -5
  79. package/src/utils/types.ts +10 -9
  80. package/test/ember.nvp/base-custom-element.test.ts +440 -0
  81. package/test/ember.nvp/base-library.test.ts +6 -4
  82. package/test/ember.nvp/generator-replace-or-update.test.ts +6 -4
  83. package/test/ember.nvp/inspector-support.test.ts +4 -3
  84. package/test/ember.nvp/layer-inspector-support.test.ts +2 -3
  85. package/test/ember.nvp/layer-publish-checks.test.ts +2 -3
  86. package/test/ember.nvp/layer-qunit.test.ts +2 -2
  87. package/test/ember.nvp/layer-readme.test.ts +63 -3
  88. package/test/ember.nvp/layer-vitest.test.ts +16 -9
  89. package/test/ember.nvp/layers-load.test.ts +26 -0
  90. package/test/ember.nvp/library-src-fixtures.ts +7 -3
  91. package/test/ember.nvp/permutations/custom-element-1.test.ts +3 -0
  92. package/test/ember.nvp/permutations/custom-element-2.test.ts +3 -0
  93. package/test/ember.nvp/permutations/custom-element-checks.test.ts +3 -0
  94. package/test/ember.nvp/permutations/shared.ts +22 -12
  95. package/test/ember.nvp/publish-configs.test.ts +44 -31
  96. package/test/ember.nvp/rewrite-imports.test.ts +4 -3
  97. package/test/ember.nvp/scoped-css-library.test.ts +6 -4
  98. package/test/test-helpers/permutate.test.ts +3 -3
  99. package/test/test-helpers.ts +12 -7
@@ -0,0 +1,440 @@
1
+ import { describe, it, beforeAll, afterAll, expect } from "vitest";
2
+ import { generate, listFiles } from "#test-helpers";
3
+ import { tagNameFor } from "#bases/minimal-custom-element";
4
+ import { execa } from "execa";
5
+ import { mkdir, rm, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+
8
+ import type { Project } from "ember.nvp";
9
+
10
+ /**
11
+ * The minimal-custom-element base ships a working pattern:
12
+ * a counter component wrapped in a custom element.
13
+ *
14
+ * So unlike the library base, there is no example source to write in.
15
+ * These tests:
16
+ * - build the generated project as-is
17
+ * - run the element in a real browser, through the vitest layer
18
+ * (from source, and from the built dist)
19
+ */
20
+
21
+ async function install(project: Project) {
22
+ let result = await execa("pnpm install", { cwd: project.directory, shell: true });
23
+ expect(result.exitCode).toBe(0);
24
+ }
25
+
26
+ async function build(project: Project) {
27
+ let result = await execa("pnpm build", { cwd: project.directory, shell: true });
28
+ expect(result.exitCode).toBe(0);
29
+ }
30
+
31
+ async function emit(project: Project, files: Record<string, string>) {
32
+ for (let [path, contents] of Object.entries(files)) {
33
+ let filePath = join(project.directory, path);
34
+
35
+ await mkdir(dirname(filePath), { recursive: true });
36
+ await writeFile(filePath, contents);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * The shared chunk carries a content hash,
42
+ * which would change with every dependency bump.
43
+ *
44
+ * Stable names only.
45
+ */
46
+ async function listDist(project: Project) {
47
+ let files = await listFiles(join(project.directory, "dist"));
48
+
49
+ return files.map((file) => file.replace(/^src-[\w-]+\.js/, "src-[hash].js"));
50
+ }
51
+
52
+ /**
53
+ * The bundled chunk's own imports are hoisted above its first region marker.
54
+ *
55
+ * ember-source's doc comments below it also start lines with `import`,
56
+ * so only that head is inspected.
57
+ */
58
+ async function chunkImports(project: Project) {
59
+ let files = await listFiles(join(project.directory, "dist"));
60
+ let chunk = files.find((file) => /^src-[\w-]+\.js$/.test(file));
61
+
62
+ expect(chunk).toBeDefined();
63
+
64
+ let code = (await project.read(`dist/${chunk}`)) ?? "";
65
+ let head = code.split("//#region")[0] ?? "";
66
+
67
+ return head.match(/^import .*$/gm) ?? [];
68
+ }
69
+
70
+ /**
71
+ * Drives the generated element the way a consumer would:
72
+ * - import the register entry
73
+ * - put the tag on the page
74
+ * - click
75
+ * - change attributes
76
+ *
77
+ * `from` is the module that provides the element:
78
+ * - `src`: the source
79
+ * - `dist`: the self-contained output, after a build
80
+ */
81
+ function elementTests(ext: "ts" | "js", tagName: string, from: "src" | "dist" = "src") {
82
+ let register = from === "src" ? `../src/register.${ext}` : "../dist/register.js";
83
+
84
+ return {
85
+ [`tests/${from}-element-test.${ext}`]: `import { describe, test, expect, afterEach } from "vitest";
86
+ import { renderSettled } from "@ember/renderer";
87
+
88
+ import "${register}";
89
+
90
+ afterEach(() => {
91
+ document.body.innerHTML = "";
92
+ });
93
+
94
+ function mount(attributes${ext === "ts" ? ": Record<string, string>" : ""} = {}) {
95
+ let element = document.createElement("${tagName}");
96
+
97
+ for (let [name, value] of Object.entries(attributes)) {
98
+ element.setAttribute(name, value);
99
+ }
100
+
101
+ document.body.appendChild(element);
102
+
103
+ return element;
104
+ }
105
+
106
+ describe("<${tagName}>", () => {
107
+ test("renders the counter with default args", async () => {
108
+ let element = mount();
109
+
110
+ await renderSettled();
111
+
112
+ expect(element.querySelector(".label")?.textContent).toBe("Count");
113
+ expect(element.querySelector("output")?.textContent).toBe("0");
114
+ expect(element.querySelector("button")?.textContent).toBe("+1");
115
+ });
116
+
117
+ test("counts clicks", async () => {
118
+ let element = mount();
119
+
120
+ await renderSettled();
121
+
122
+ element.querySelector("button")?.click();
123
+ await renderSettled();
124
+ element.querySelector("button")?.click();
125
+ await renderSettled();
126
+
127
+ expect(element.querySelector("output")?.textContent).toBe("2");
128
+ });
129
+
130
+ test("passes initial attributes as args", async () => {
131
+ let element = mount({ label: "Clicks", step: "5" });
132
+
133
+ await renderSettled();
134
+
135
+ expect(element.querySelector(".label")?.textContent).toBe("Clicks");
136
+ expect(element.querySelector("button")?.textContent).toBe("+5");
137
+
138
+ element.querySelector("button")?.click();
139
+ await renderSettled();
140
+
141
+ expect(element.querySelector("output")?.textContent).toBe("5");
142
+ });
143
+
144
+ test("re-renders when attributes change", async () => {
145
+ let element = mount();
146
+
147
+ await renderSettled();
148
+
149
+ element.setAttribute("label", "Taps");
150
+ element.setAttribute("step", "3");
151
+ await renderSettled();
152
+
153
+ expect(element.querySelector(".label")?.textContent).toBe("Taps");
154
+ expect(element.querySelector("button")?.textContent).toBe("+3");
155
+
156
+ element.querySelector("button")?.click();
157
+ await renderSettled();
158
+
159
+ expect(element.querySelector("output")?.textContent).toBe("3");
160
+ });
161
+
162
+ test("renders again after being moved", async () => {
163
+ let element = mount();
164
+
165
+ await renderSettled();
166
+
167
+ element.querySelector("button")?.click();
168
+ await renderSettled();
169
+
170
+ element.remove();
171
+ document.body.appendChild(element);
172
+ await renderSettled();
173
+
174
+ expect(element.querySelector("output")?.textContent).toBe("0");
175
+ });
176
+ });
177
+ `,
178
+ };
179
+ }
180
+
181
+ describe("base: minimal-custom-element", () => {
182
+ const dirs: string[] = [];
183
+
184
+ afterAll(async () => {
185
+ if (process.env.CI) return;
186
+
187
+ for (const dir of dirs) {
188
+ await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
189
+ }
190
+ });
191
+
192
+ describe("tag name", () => {
193
+ it("is the package name", () => {
194
+ expect(tagNameFor("my-counter")).toBe("my-counter");
195
+ });
196
+
197
+ it("drops the scope", () => {
198
+ expect(tagNameFor("@acme/my-counter")).toBe("my-counter");
199
+ });
200
+
201
+ it("gets a suffix when the name has no hyphen", () => {
202
+ expect(tagNameFor("counter")).toBe("counter-element");
203
+ expect(tagNameFor("@acme/counter")).toBe("counter-element");
204
+ });
205
+ });
206
+
207
+ describe("JavaScript", () => {
208
+ let project: Project;
209
+
210
+ beforeAll(async () => {
211
+ project = await generate({ type: "custom-element", name: "my-counter", layers: [] });
212
+ dirs.push(project.directory);
213
+ });
214
+
215
+ it("generates the expected files", async () => {
216
+ expect(await listFiles(project.directory)).toMatchInlineSnapshot(`
217
+ [
218
+ ".gitignore",
219
+ "README.md",
220
+ "package.json",
221
+ "src/components/counter.gjs",
222
+ "src/element.js",
223
+ "src/index.js",
224
+ "src/register.js",
225
+ "tsdown.config.js",
226
+ ]
227
+ `);
228
+ });
229
+
230
+ it("registers the tag under the project name", async () => {
231
+ expect(await project.read("src/register.js")).toMatchInlineSnapshot(`
232
+ "import { CounterElement } from "./index.js";
233
+ if (!customElements.get('my-counter')) {
234
+ customElements.define('my-counter', CounterElement);
235
+ }"
236
+ `);
237
+ expect(await project.read("README.md")).toContain("<my-counter label=");
238
+ });
239
+
240
+ it("exports the element and the component", async () => {
241
+ expect(await project.read("src/index.js")).toMatchInlineSnapshot(`
242
+ "export { CounterElement } from "./element.js";
243
+ export { default as Counter } from "./components/counter.gjs";"
244
+ `);
245
+ });
246
+
247
+ it("is publishable", async () => {
248
+ let manifest = JSON.parse((await project.read("package.json"))!);
249
+
250
+ expect(manifest).not.toHaveProperty("private");
251
+ expect(manifest.exports["./register"]).toEqual({ default: "./dist/register.js" });
252
+ });
253
+
254
+ it("has no TypeScript leftovers", async () => {
255
+ let manifest = JSON.parse((await project.read("package.json"))!);
256
+
257
+ expect(manifest.devDependencies).not.toHaveProperty("typescript");
258
+ expect(manifest.devDependencies).not.toHaveProperty("@ember/library-tsconfig");
259
+ expect(JSON.stringify(manifest.exports)).not.toContain("types");
260
+
261
+ expect(await project.read("tsdown.config.js")).toMatchInlineSnapshot(`
262
+ "import { defineConfig } from "tsdown";
263
+ import { ember } from "@nullvoxpopuli/ember-rolldown";
264
+
265
+ export default defineConfig({
266
+ entry: ["./src/index.js", "./src/register.js"],
267
+ dts: false,
268
+ plugins: [ember({ bundle: true })],
269
+ });
270
+ "
271
+ `);
272
+ });
273
+
274
+ it("builds a self-contained package", async () => {
275
+ await install(project);
276
+ await build(project);
277
+
278
+ expect(await listDist(project)).toMatchInlineSnapshot(`
279
+ [
280
+ "index.js",
281
+ "register.js",
282
+ "register.js.map",
283
+ "src-[hash].js",
284
+ "src-[hash].js.map",
285
+ ]
286
+ `);
287
+
288
+ let register = await project.read("dist/register.js");
289
+
290
+ expect(register).toContain('customElements.define("my-counter"');
291
+ // Both entries share one chunk holding the element, the component,
292
+ // and ember itself
293
+ expect(register).toMatch(/from "\.\/src-[\w-]+\.js"/);
294
+ expect(await project.read("dist/index.js")).toMatch(/from "\.\/src-[\w-]+\.js"/);
295
+
296
+ // Nothing is left for a consumer to resolve
297
+ expect(await chunkImports(project)).toEqual([]);
298
+
299
+ let files = await listFiles(join(project.directory, "dist"));
300
+ let chunk = (await project.read(`dist/${files.find((file) => file.startsWith("src-"))}`))!;
301
+
302
+ // The template ships compiled for the bundled ember-source, so the
303
+ // page needs no template compiler
304
+ expect(chunk).not.toContain("precompileTemplate(");
305
+ expect(chunk).toContain('"block":');
306
+ expect(chunk).toContain('observedAttributes = ["label", "step"]');
307
+ expect(chunk).toContain("renderComponent");
308
+ });
309
+ });
310
+
311
+ describe("TypeScript", () => {
312
+ let project: Project;
313
+
314
+ beforeAll(async () => {
315
+ project = await generate({ type: "custom-element", name: "counter", layers: ["typescript"] });
316
+ dirs.push(project.directory);
317
+ });
318
+
319
+ it("generates the expected files", async () => {
320
+ expect(await listFiles(project.directory)).toMatchInlineSnapshot(`
321
+ [
322
+ ".gitignore",
323
+ "README.md",
324
+ "package.json",
325
+ "src/components/counter.gts",
326
+ "src/element.ts",
327
+ "src/index.ts",
328
+ "src/register.ts",
329
+ "tsconfig.json",
330
+ "tsdown.config.js",
331
+ ]
332
+ `);
333
+ });
334
+
335
+ it("registers the tag with a suffix when the name has no hyphen", async () => {
336
+ expect(await project.read("src/register.ts")).toMatchInlineSnapshot(`
337
+ "import { CounterElement } from "./index.ts";
338
+
339
+ if (!customElements.get("counter-element")) {
340
+ customElements.define("counter-element", CounterElement);
341
+ }
342
+ "
343
+ `);
344
+ });
345
+
346
+ it("type checks", async () => {
347
+ await install(project);
348
+
349
+ let types = await execa("pnpm lint:types", { cwd: project.directory, shell: true });
350
+ expect(types.exitCode).toBe(0);
351
+ });
352
+
353
+ it("builds, including declarations", async () => {
354
+ await build(project);
355
+
356
+ expect(await listDist(project)).toMatchInlineSnapshot(`
357
+ [
358
+ "index.d.ts",
359
+ "index.d.ts.map",
360
+ "index.js",
361
+ "register.d.ts",
362
+ "register.js",
363
+ "register.js.map",
364
+ "src-[hash].js",
365
+ "src-[hash].js.map",
366
+ ]
367
+ `);
368
+
369
+ expect(await chunkImports(project)).toEqual([]);
370
+
371
+ let declarations = (await project.read("dist/index.d.ts"))!;
372
+
373
+ expect(declarations).toContain("declare class CounterElement extends HTMLElement");
374
+ // The component's base class is bundled in, like the runtime.
375
+ // (ember-source's own types are ambient, so those stay by name)
376
+ expect(declarations).toContain("declare class Component<");
377
+ expect(declarations).not.toContain('from "@glimmer/component"');
378
+ expect(await project.read("dist/register.d.ts")).toMatchInlineSnapshot(`"export {}"`);
379
+ });
380
+ });
381
+
382
+ describe("in a browser (vitest layer)", () => {
383
+ describe("TypeScript", () => {
384
+ let project: Project;
385
+
386
+ beforeAll(async () => {
387
+ project = await generate({
388
+ type: "custom-element",
389
+ name: "my-counter",
390
+ layers: ["typescript", "vitest"],
391
+ });
392
+ dirs.push(project.directory);
393
+ });
394
+
395
+ it("renders, counts, and reacts to attributes", { timeout: 300_000 }, async () => {
396
+ await emit(project, elementTests("ts", "my-counter"));
397
+ await install(project);
398
+
399
+ let test = await execa("pnpm test", { cwd: project.directory, shell: true });
400
+ expect(test.exitCode).toBe(0);
401
+ });
402
+
403
+ it(
404
+ "runs from the built package, with no ember on the page",
405
+ { timeout: 300_000 },
406
+ async () => {
407
+ await build(project);
408
+ // Only the dist spec: the vitest run must not also load the source
409
+ // copy, or the tag would already be defined by whichever wins
410
+ await rm(join(project.directory, "tests"), { recursive: true, force: true });
411
+ await emit(project, elementTests("ts", "my-counter", "dist"));
412
+
413
+ let test = await execa("pnpm test", { cwd: project.directory, shell: true });
414
+ expect(test.exitCode).toBe(0);
415
+ },
416
+ );
417
+ });
418
+
419
+ describe("JavaScript", () => {
420
+ let project: Project;
421
+
422
+ beforeAll(async () => {
423
+ project = await generate({
424
+ type: "custom-element",
425
+ name: "my-counter",
426
+ layers: ["vitest"],
427
+ });
428
+ dirs.push(project.directory);
429
+ });
430
+
431
+ it("renders, counts, and reacts to attributes", { timeout: 300_000 }, async () => {
432
+ await emit(project, elementTests("js", "my-counter"));
433
+ await install(project);
434
+
435
+ let test = await execa("pnpm test", { cwd: project.directory, shell: true });
436
+ expect(test.exitCode).toBe(0);
437
+ });
438
+ });
439
+ });
440
+ });
@@ -8,10 +8,12 @@ import { join } from "node:path";
8
8
  import type { Project } from "ember.nvp";
9
9
 
10
10
  /**
11
- * The minimal-library base has to produce a *buildable* library: these tests
12
- * generate both flavors (JavaScript and TypeScript), snapshot the interesting
13
- * generated files, and run the real `pnpm build` (tsdown + rolldown) against
14
- * them.
11
+ * The minimal-library base has to produce a *buildable* library.
12
+ *
13
+ * These tests:
14
+ * - generate both flavors (JavaScript and TypeScript)
15
+ * - snapshot the interesting generated files
16
+ * - run the real `pnpm build` (tsdown + rolldown) against them
15
17
  */
16
18
 
17
19
  async function installAndBuild(project: Project) {
@@ -7,10 +7,12 @@ import { join } from "node:path";
7
7
 
8
8
  /**
9
9
  * The replace-or-update question only matters when a project already exists
10
- * at the target path. These tests generate a project, assert the baseline
11
- * state, then regenerate over it the way the CLI does once the user has
12
- * answered the question, and assert the resulting project output is correct
13
- * for each answer.
10
+ * at the target path.
11
+ *
12
+ * These tests:
13
+ * - generate a project and assert the baseline state
14
+ * - regenerate over it, the way the CLI does once the user has answered
15
+ * - assert the resulting project is correct for each answer
14
16
  */
15
17
  describe("generating over an existing project", () => {
16
18
  const prettier = layers.find((layer) => layer.name === "prettier")!;
@@ -3,9 +3,10 @@ import { describe, it, expect } from "vitest";
3
3
  import { hasInspectorSupport, wireInspectorSupport } from "#utils/inspector-support.js";
4
4
 
5
5
  /**
6
- * The Application definition is found structurally, so every shape an app
7
- * author may have refactored their app file into gets wired the same way
8
- * the freshly generated shape does.
6
+ * The Application definition is found structurally.
7
+ *
8
+ * So every shape an app author has refactored their app file into
9
+ * gets wired the same way the freshly generated shape does.
9
10
  */
10
11
  describe("wireInspectorSupport", () => {
11
12
  it("wires the generated shape (export default class extends Application)", () => {
@@ -43,11 +43,10 @@ describe("layer: inspector-support", () => {
43
43
  "/**
44
44
  * Looking for services that come from addons?
45
45
  *
46
+ * App-tree merging from libraries is not supported.
46
47
  * See: https://github.com/embroider-build/embroider/issues/2659
47
48
  *
48
- * We currently don't support app-tree merging from libraries.
49
- *
50
- * For services, I highly recommend looking in to either of
49
+ * For services, look in to either of:
51
50
  * - https://github.com/chancancode/ember-polaris-service-
52
51
  * - https://ember-primitives.pages.dev/6-utils/createService.md
53
52
  * - https://ember-primitives.pages.dev/6-utils/createAsyncService.md
@@ -6,9 +6,8 @@ import { rm } from "node:fs/promises";
6
6
  import type { Project } from "ember.nvp";
7
7
 
8
8
  /**
9
- * publint and are-the-types-wrong run inside the library's own build
10
- * (tsdown), so the real assertion is their report lines in `pnpm build`
11
- * output.
9
+ * publint and are-the-types-wrong run inside the library's own build (tsdown),
10
+ * so the real assertion is their report lines in `pnpm build` output.
12
11
  */
13
12
 
14
13
  describe("layers: publint + are-the-types-wrong", () => {
@@ -16,8 +16,8 @@ async function installAndTest(project: Project) {
16
16
  }
17
17
 
18
18
  /**
19
- * The layer ships testing infrastructure only; the tests exercising the
20
- * base's example exports are generated here, in both flavors.
19
+ * The layer ships testing infrastructure only.
20
+ * The tests exercising the base's example exports are generated here, in both flavors.
21
21
  */
22
22
  const exampleTests = {
23
23
  typescript: {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import { generate } from "#test-helpers";
2
+ import { generate, mktemp } from "#test-helpers";
3
3
  import { rm } from "node:fs/promises";
4
4
  import type { Project } from "ember.nvp";
5
5
 
@@ -168,13 +168,13 @@ describe("layer: readme", () => {
168
168
 
169
169
  ### Building
170
170
 
171
- To build the library:
171
+ To build the extension:
172
172
 
173
173
  \`\`\`sh
174
174
  pnpm build
175
175
  \`\`\`
176
176
 
177
- or
177
+ or, to rebuild on change:
178
178
 
179
179
  \`\`\`sh
180
180
  pnpm build:watch
@@ -192,4 +192,64 @@ describe("layer: readme", () => {
192
192
  `);
193
193
  });
194
194
  });
195
+
196
+ describe("Custom element project README generation", () => {
197
+ let project: Project;
198
+
199
+ beforeAll(async () => {
200
+ project = await generate({
201
+ directory: await mktemp("acme-counter"),
202
+ type: "custom-element",
203
+ name: "@acme/counter",
204
+ packageManager: "npm",
205
+ layers: ["readme"],
206
+ });
207
+ dirs.push(project.directory);
208
+ });
209
+
210
+ it("generates README.md with the registered tag name", async () => {
211
+ expect(await project.read("README.md")).toMatchInlineSnapshot(`
212
+ "# @acme/counter
213
+
214
+ An Ember component, packaged as a custom element, created with \`ember.nvp\`.
215
+ The built package contains ember, so the page that uses the element needs nothing else.
216
+
217
+ ## Usage
218
+
219
+ Import the \`register\` entry once. It defines the \`<counter-element>\` tag.
220
+
221
+ \`\`\`js
222
+ import "@acme/counter/register";
223
+ \`\`\`
224
+
225
+ \`\`\`html
226
+ <counter-element label="Clicks" step="2"></counter-element>
227
+ \`\`\`
228
+
229
+ To choose your own tag name, import the class and define it yourself:
230
+
231
+ \`\`\`js
232
+ import { CounterElement } from "@acme/counter";
233
+
234
+ customElements.define("my-counter", CounterElement);
235
+ \`\`\`
236
+
237
+ ## Getting Started
238
+
239
+ ### Prerequisites
240
+
241
+ - Node.js >= 24
242
+ - npm
243
+
244
+ ### Development & Building
245
+
246
+ To build the package:
247
+
248
+ \`\`\`sh
249
+ npm run build
250
+ \`\`\`
251
+ "
252
+ `);
253
+ });
254
+ });
195
255
  });
@@ -9,9 +9,12 @@ import type { Project } from "ember.nvp";
9
9
 
10
10
  /**
11
11
  * The vitest layer ships infrastructure only (config, deps, scripts).
12
- * These tests generate projects with the layer, emit specs that exercise
13
- * each project's real exports, and run the real `pnpm test` (vitest in
14
- * headless-browser mode), asserting the specs genuinely run and pass.
12
+ *
13
+ * These tests:
14
+ * - generate projects with the layer
15
+ * - emit specs that exercise each project's real exports
16
+ * - run the real `pnpm test` (vitest in headless-browser mode),
17
+ * and assert the specs run and pass
15
18
  */
16
19
 
17
20
  async function emit(project: Project, files: Record<string, string>) {
@@ -32,9 +35,11 @@ async function installAndTest(project: Project) {
32
35
  }
33
36
 
34
37
  /**
35
- * Specs against the library base's real exports, in the flavor matching
36
- * the generated project (these files are emitted by this test, not
37
- * shipped by the layer, so no automatic conversion applies).
38
+ * Specs against the library base's real exports,
39
+ * in the flavor matching the generated project.
40
+ *
41
+ * These files are emitted by this test, not shipped by the layer,
42
+ * so no automatic conversion applies.
38
43
  */
39
44
  function libraryTests(ext: "ts" | "js") {
40
45
  return {
@@ -82,9 +87,11 @@ describe("add", () => {
82
87
  }
83
88
 
84
89
  /**
85
- * Specs against the app base's real modules: boots an app made from the
86
- * app's own router class (pointed at a test-friendly location) and
87
- * application template, and visits its application route.
90
+ * Specs against the app base's real modules.
91
+ *
92
+ * Boots an app made from the app's own router class
93
+ * (pointed at a test-friendly location) and application template,
94
+ * then visits its application route.
88
95
  */
89
96
  function appTests(ext: "ts" | "js") {
90
97
  return {
@@ -0,0 +1,26 @@
1
+ import { test, expect } from "vitest";
2
+ import { execa } from "execa";
3
+ import { join } from "node:path";
4
+
5
+ const root = join(import.meta.dirname, "../..");
6
+
7
+ /**
8
+ * vitest runs modules through its own loader,
9
+ * so it does not see import cycles the way Node does.
10
+ *
11
+ * `#layers` awaits a dynamic import of each layer at the top level.
12
+ * If a layer imports a module that imports `#layers` back,
13
+ * the top-level await never settles and Node exits with code 13.
14
+ *
15
+ * Node's loader is the only thing that catches that,
16
+ * so load the module in a child process.
17
+ */
18
+ test("the layers module loads in a plain node process", async () => {
19
+ const result = await execa(process.execPath, ["src/layers/index.js"], {
20
+ cwd: root,
21
+ reject: false,
22
+ });
23
+
24
+ expect(result.stderr).not.toContain("unsettled top-level await");
25
+ expect(result.exitCode).toBe(0);
26
+ });