@jskit-ai/ui-generator 0.1.49 → 0.1.50

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.
@@ -75,6 +75,8 @@ import { computed } from "vue";
75
75
  assert.match(topologySource, /compact: \{/);
76
76
  assert.match(topologySource, /medium: \{/);
77
77
  assert.match(topologySource, /expanded: \{/);
78
+ assert.match(topologySource, /renderers: \{/);
79
+ assert.match(topologySource, /link: "local\.main\.ui\.surface-aware-menu-link-item"/);
78
80
 
79
81
  const rerun = await runGeneratorSubcommand({
80
82
  appRoot,
@@ -90,6 +92,224 @@ import { computed } from "vue";
90
92
  });
91
93
  });
92
94
 
95
+ test("ui-generator outlet can inject a concrete outlet without topology", async () => {
96
+ await withTempApp(async (appRoot) => {
97
+ const targetFile = "src/components/ReportsPanel.vue";
98
+ const targetPath = path.join(appRoot, targetFile);
99
+
100
+ await mkdir(path.dirname(targetPath), { recursive: true });
101
+ await writeFile(targetPath, "<template><section /></template>\n", "utf8");
102
+
103
+ const result = await runGeneratorSubcommand({
104
+ appRoot,
105
+ subcommand: "outlet",
106
+ args: [targetFile],
107
+ options: {
108
+ target: "reports:bottom-actions"
109
+ }
110
+ });
111
+
112
+ assert.deepEqual(result.touchedFiles, [targetFile]);
113
+ const output = await readFile(targetPath, "utf8");
114
+ assert.match(output, /<ShellOutlet target="reports:bottom-actions" \/>/);
115
+ const topologySource = await readFile(path.join(appRoot, "src", "placementTopology.js"), "utf8");
116
+ assert.doesNotMatch(topologySource, /reports\.actions/);
117
+ });
118
+ });
119
+
120
+ test("ui-generator topology creates adaptive component topology without injecting outlets", async () => {
121
+ await withTempApp(async (appRoot) => {
122
+ const result = await runGeneratorSubcommand({
123
+ appRoot,
124
+ subcommand: "topology",
125
+ args: [],
126
+ options: {
127
+ placement: "reports.actions",
128
+ kind: "component",
129
+ "compact-target": "reports:bottom-actions",
130
+ "medium-target": "reports:toolbar-actions",
131
+ "expanded-target": "reports:side-actions",
132
+ surface: "admin",
133
+ description: "Report action controls."
134
+ }
135
+ });
136
+
137
+ assert.deepEqual(result.touchedFiles, ["src/placementTopology.js"]);
138
+ const topologySource = await readFile(path.join(appRoot, "src", "placementTopology.js"), "utf8");
139
+ assert.match(topologySource, /id: "reports\.actions"/);
140
+ assert.match(topologySource, /description: "Report action controls\."/);
141
+ assert.match(topologySource, /surfaces: \["admin"\]/);
142
+ assert.match(topologySource, /compact: \{\n {6}outlet: "reports:bottom-actions"\n {4}\}/);
143
+ assert.match(topologySource, /medium: \{\n {6}outlet: "reports:toolbar-actions"\n {4}\}/);
144
+ assert.match(topologySource, /expanded: \{\n {6}outlet: "reports:side-actions"\n {4}\}/);
145
+ assert.doesNotMatch(topologySource, /renderers: \{/);
146
+ });
147
+ });
148
+
149
+ test("ui-generator topology creates link topology with owner inference for one target", async () => {
150
+ await withTempApp(async (appRoot) => {
151
+ const result = await runGeneratorSubcommand({
152
+ appRoot,
153
+ subcommand: "topology",
154
+ args: [],
155
+ options: {
156
+ placement: "page.section-nav",
157
+ kind: "link",
158
+ target: "report-view:sub-pages",
159
+ surface: "admin"
160
+ }
161
+ });
162
+
163
+ assert.deepEqual(result.touchedFiles, ["src/placementTopology.js"]);
164
+ const topologySource = await readFile(path.join(appRoot, "src", "placementTopology.js"), "utf8");
165
+ assert.match(topologySource, /id: "page\.section-nav"/);
166
+ assert.match(topologySource, /owner: "report-view"/);
167
+ assert.match(topologySource, /compact: \{\n {6}outlet: "report-view:sub-pages",/);
168
+ assert.match(topologySource, /medium: \{\n {6}outlet: "report-view:sub-pages",/);
169
+ assert.match(topologySource, /expanded: \{\n {6}outlet: "report-view:sub-pages",/);
170
+ assert.match(topologySource, /link: "local\.main\.ui\.surface-aware-menu-link-item"/);
171
+ });
172
+ });
173
+
174
+ test("ui-generator topology treats existing app topology id and owner as already present without a marker", async () => {
175
+ await withTempApp(async (appRoot) => {
176
+ await writeFile(
177
+ path.join(appRoot, "src", "placementTopology.js"),
178
+ `export default {
179
+ placements: [
180
+ {
181
+ id: "page.actions",
182
+ owner: "customer-view",
183
+ surfaces: ["admin"],
184
+ variants: {
185
+ compact: { outlet: "customer-view:summary-actions" },
186
+ medium: { outlet: "customer-view:summary-actions" },
187
+ expanded: { outlet: "customer-view:summary-actions" }
188
+ }
189
+ }
190
+ ]
191
+ };
192
+ `,
193
+ "utf8"
194
+ );
195
+
196
+ const result = await runGeneratorSubcommand({
197
+ appRoot,
198
+ subcommand: "topology",
199
+ args: [],
200
+ options: {
201
+ placement: "page.actions",
202
+ kind: "component",
203
+ target: "customer-view:summary-actions"
204
+ }
205
+ });
206
+
207
+ assert.deepEqual(result.touchedFiles, []);
208
+ const topologySource = await readFile(path.join(appRoot, "src", "placementTopology.js"), "utf8");
209
+ assert.equal((topologySource.match(/id: "page\.actions"/g) || []).length, 1);
210
+ assert.doesNotMatch(topologySource, /jskit:ui-generator\.topology:page\.actions:customer-view/);
211
+ });
212
+ });
213
+
214
+ test("ui-generator topology rejects an existing semantic id and owner with different outlets", async () => {
215
+ await withTempApp(async (appRoot) => {
216
+ await writeFile(
217
+ path.join(appRoot, "src", "placementTopology.js"),
218
+ `export default {
219
+ placements: [
220
+ {
221
+ id: "page.actions",
222
+ owner: "customer-view",
223
+ surfaces: ["admin"],
224
+ variants: {
225
+ compact: { outlet: "customer-view:summary-actions" },
226
+ medium: { outlet: "customer-view:summary-actions" },
227
+ expanded: { outlet: "customer-view:summary-actions" }
228
+ }
229
+ }
230
+ ]
231
+ };
232
+ `,
233
+ "utf8"
234
+ );
235
+
236
+ await assert.rejects(
237
+ runGeneratorSubcommand({
238
+ appRoot,
239
+ subcommand: "topology",
240
+ args: [],
241
+ options: {
242
+ placement: "page.actions",
243
+ kind: "component",
244
+ target: "customer-view:footer-actions"
245
+ }
246
+ }),
247
+ /semantic placement "page\.actions" for owner "customer-view" already exists with different outlet mapping/
248
+ );
249
+ });
250
+ });
251
+
252
+ test("ui-generator topology requires owner for owner-scoped placements across multiple hosts", async () => {
253
+ await withTempApp(async (appRoot) => {
254
+ await assert.rejects(
255
+ runGeneratorSubcommand({
256
+ appRoot,
257
+ subcommand: "topology",
258
+ args: [],
259
+ options: {
260
+ placement: "page.section-nav",
261
+ kind: "link",
262
+ "compact-target": "report-compact:sub-pages",
263
+ "medium-target": "report-medium:sub-pages",
264
+ "expanded-target": "report-expanded:sub-pages",
265
+ surface: "admin"
266
+ }
267
+ }),
268
+ /ui-generator topology requires --owner because semantic placement "page\.section-nav" maps to multiple outlet hosts/
269
+ );
270
+ });
271
+ });
272
+
273
+ test("ui-generator outlet validates topology before writing the concrete outlet", async () => {
274
+ await withTempApp(async (appRoot) => {
275
+ const targetFile = "src/components/ContactDetailsPanel.vue";
276
+ const targetPath = path.join(appRoot, targetFile);
277
+ const originalSource = "<template><div /></template>\n";
278
+
279
+ await mkdir(path.dirname(targetPath), { recursive: true });
280
+ await writeFile(targetPath, originalSource, "utf8");
281
+ await writeFile(
282
+ path.join(appRoot, "src", "placementTopology.js"),
283
+ `export default {
284
+ placements: [
285
+ {
286
+ id: "page.actions",
287
+ owner: "contact-view",
288
+ variants: {}
289
+ }
290
+ ]
291
+ };
292
+ `,
293
+ "utf8"
294
+ );
295
+
296
+ await assert.rejects(
297
+ runGeneratorSubcommand({
298
+ appRoot,
299
+ subcommand: "outlet",
300
+ args: [targetFile],
301
+ options: {
302
+ target: "contact-view:summary-actions",
303
+ placement: "page.actions"
304
+ }
305
+ }),
306
+ /requires compact topology variant/
307
+ );
308
+
309
+ assert.equal(await readFile(targetPath, "utf8"), originalSource);
310
+ });
311
+ });
312
+
93
313
  test("ui-generator outlet does not inject a second matching outlet", async () => {
94
314
  await withTempApp(async (appRoot) => {
95
315
  const targetFile = "src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/index.vue";
@@ -162,6 +382,44 @@ test("ui-generator outlet creates script setup when missing", async () => {
162
382
  });
163
383
  });
164
384
 
385
+ test("ui-generator outlet creates script setup instead of adding template imports to normal script", async () => {
386
+ await withTempApp(async (appRoot) => {
387
+ const targetFile = "src/components/OptionsPanel.vue";
388
+ const targetPath = path.join(appRoot, targetFile);
389
+
390
+ await mkdir(path.dirname(targetPath), { recursive: true });
391
+ await writeFile(
392
+ targetPath,
393
+ `<script>
394
+ export default {
395
+ name: "OptionsPanel"
396
+ };
397
+ </script>
398
+
399
+ <template>
400
+ <div>Options</div>
401
+ </template>
402
+ `,
403
+ "utf8"
404
+ );
405
+
406
+ await runGeneratorSubcommand({
407
+ appRoot,
408
+ subcommand: "outlet",
409
+ args: [targetFile],
410
+ options: {
411
+ target: "options:footer-actions",
412
+ placement: "page.actions"
413
+ }
414
+ });
415
+
416
+ const output = await readFile(targetPath, "utf8");
417
+ assert.match(output, /<script setup>\nimport ShellOutlet from "@jskit-ai\/shell-web\/client\/components\/ShellOutlet";\n<\/script>/);
418
+ assert.match(output, /<script>\nexport default/);
419
+ assert.match(output, /<ShellOutlet target="options:footer-actions" \/>/);
420
+ });
421
+ });
422
+
165
423
  test("ui-generator outlet inserts generated script after existing route block", async () => {
166
424
  await withTempApp(async (appRoot) => {
167
425
  const targetFile = "src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/index.vue";
@@ -8,4 +8,15 @@ test("ui-generator surface options validate against enabled surface ids", () =>
8
8
  assert.equal(descriptor.metadata?.generatorSubcommands?.["placed-element"]?.optionNames?.includes("surface"), true);
9
9
  assert.equal(descriptor.metadata?.generatorSubcommands?.["placed-element"]?.requiredOptionNames?.includes("surface"), false);
10
10
  assert.equal(descriptor.metadata?.generatorSubcommands?.page?.optionNames?.includes("force"), true);
11
+ assert.equal(descriptor.options?.kind?.validationType, "enum");
12
+ assert.equal(descriptor.options?.["navigation-role"]?.validationType, "enum");
13
+ assert.deepEqual(
14
+ descriptor.options?.["navigation-role"]?.allowedValues,
15
+ ["primary", "secondary", "utility", "detail", "workflow", "none"]
16
+ );
17
+ assert.equal(descriptor.metadata?.generatorSubcommands?.page?.optionNames?.includes("navigation-role"), true);
18
+ assert.equal(descriptor.metadata?.generatorSubcommands?.outlet?.requiredOptionNames?.includes("placement"), false);
19
+ assert.equal(descriptor.metadata?.generatorSubcommands?.topology?.entrypoint, "src/server/subcommands/outlet.js");
20
+ assert.equal(descriptor.metadata?.generatorSubcommands?.topology?.optionNames?.includes("compact-target"), true);
21
+ assert.equal(descriptor.metadata?.generatorSubcommands?.topology?.requiredOptionNames?.includes("kind"), true);
11
22
  });
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import test from "node:test";
6
+ import { assertGeneratedUiSourceContract } from "@jskit-ai/kernel/shared/support/generatedUiContract";
6
7
  import { runGeneratorSubcommand } from "../src/server/subcommands/page.js";
7
8
 
8
9
  async function withTempApp(run) {
@@ -65,6 +66,18 @@ async function writePlacementTopology(appRoot, entries = []) {
65
66
  surfaces: ["*"],
66
67
  outlet: "shell-layout:top-right",
67
68
  linkRenderer: "local.main.ui.surface-aware-menu-link-item"
69
+ }),
70
+ renderTopologyEntry({
71
+ id: "shell.global-actions",
72
+ surfaces: ["*"],
73
+ outlet: "shell-layout:top-right",
74
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
75
+ }),
76
+ renderTopologyEntry({
77
+ id: "shell.secondary-nav",
78
+ surfaces: ["*"],
79
+ outlet: "shell-layout:secondary-menu",
80
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
68
81
  })
69
82
  ];
70
83
  await writeFile(
@@ -104,6 +117,7 @@ async function writeAppFixture(appRoot, { configSource = "" } = {}) {
104
117
  default
105
118
  />
106
119
  <ShellOutlet target="shell-layout:top-right" />
120
+ <ShellOutlet target="shell-layout:secondary-menu" />
107
121
  </div>
108
122
  </template>
109
123
  `,
@@ -141,7 +155,16 @@ test("ui-generator page subcommand creates an index page from an explicit target
141
155
  assert.equal(result.summary, 'Generated UI page "/practice" at src/pages/w/[workspaceSlug]/admin/practice/index.vue.');
142
156
 
143
157
  const pageSource = await readFile(path.join(appRoot, toPagePath(targetFile)), "utf8");
144
- assert.match(pageSource, /<h1 class="text-h5 mb-2">Practice<\/h1>/);
158
+ assertGeneratedUiSourceContract(pageSource, {
159
+ profile: "page",
160
+ sourceName: targetFile
161
+ });
162
+ assert.match(pageSource, /generated-ui-screen generated-ui-screen--operator generated-page-screen/);
163
+ assert.match(pageSource, /<p class="text-overline text-medium-emphasis mb-1">Workspace tool<\/p>/);
164
+ assert.match(pageSource, /<h1 class="generated-page-screen__title">Practice<\/h1>/);
165
+ assert.match(pageSource, /<v-sheet rounded="lg" border class="generated-page-screen__empty-state">/);
166
+ assert.match(pageSource, /No Practice activity yet/);
167
+ assert.doesNotMatch(pageSource, /Replace this scaffold|Use this area|This is your page|<v-card\b|v-card-title/);
145
168
 
146
169
  const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
147
170
  assert.match(placementSource, /id: "ui-generator\.page\.admin\.practice\.link"/);
@@ -150,7 +173,90 @@ test("ui-generator page subcommand creates an index page from an explicit target
150
173
  });
151
174
  });
152
175
 
153
- test("ui-generator page subcommand creates a file route and derives label from the file path", async () => {
176
+ test("ui-generator page subcommand maps secondary navigation role to shell.secondary-nav", async () => {
177
+ await withTempApp(async (appRoot) => {
178
+ await writeAppFixture(appRoot);
179
+
180
+ const targetFile = "w/[workspaceSlug]/admin/reports/index.vue";
181
+ await runGeneratorSubcommand({
182
+ appRoot,
183
+ subcommand: "page",
184
+ args: [targetFile],
185
+ options: {
186
+ name: "Reports",
187
+ "navigation-role": "secondary"
188
+ }
189
+ });
190
+
191
+ const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
192
+ assert.match(placementSource, /target: "shell\.secondary-nav"/);
193
+ assert.match(placementSource, /kind: "link"/);
194
+ });
195
+ });
196
+
197
+ test("ui-generator page subcommand maps utility navigation role to shell.global-actions", async () => {
198
+ await withTempApp(async (appRoot) => {
199
+ await writeAppFixture(appRoot);
200
+
201
+ const targetFile = "w/[workspaceSlug]/admin/help/index.vue";
202
+ await runGeneratorSubcommand({
203
+ appRoot,
204
+ subcommand: "page",
205
+ args: [targetFile],
206
+ options: {
207
+ name: "Help",
208
+ "navigation-role": "utility"
209
+ }
210
+ });
211
+
212
+ const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
213
+ assert.match(placementSource, /target: "shell\.global-actions"/);
214
+ assert.match(placementSource, /kind: "link"/);
215
+ });
216
+ });
217
+
218
+ test("ui-generator page subcommand can generate detail pages without navigation placement", async () => {
219
+ await withTempApp(async (appRoot) => {
220
+ await writeAppFixture(appRoot);
221
+
222
+ const targetFile = "w/[workspaceSlug]/admin/reports/[reportId].vue";
223
+ const result = await runGeneratorSubcommand({
224
+ appRoot,
225
+ subcommand: "page",
226
+ args: [targetFile],
227
+ options: {
228
+ name: "Report",
229
+ "navigation-role": "detail"
230
+ }
231
+ });
232
+
233
+ assert.deepEqual(result.touchedFiles, [toPagePath(targetFile)]);
234
+
235
+ const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
236
+ assert.doesNotMatch(placementSource, /ui-generator\.page\.admin\.reports\.report-id\.link/);
237
+ });
238
+ });
239
+
240
+ test("ui-generator page subcommand rejects no-link navigation roles with link placement options", async () => {
241
+ await withTempApp(async (appRoot) => {
242
+ await writeAppFixture(appRoot);
243
+
244
+ await assert.rejects(
245
+ runGeneratorSubcommand({
246
+ appRoot,
247
+ subcommand: "page",
248
+ args: ["w/[workspaceSlug]/admin/reports/[reportId].vue"],
249
+ options: {
250
+ "navigation-role": "detail",
251
+ "link-placement": "shell.primary-nav"
252
+ }
253
+ }),
254
+ /navigation-role "detail" cannot be combined with --link-placement/
255
+ );
256
+ });
257
+ });
258
+
259
+ test("ui-generator page subcommand treats dynamic file routes as detail pages by default", async () => {
154
260
  await withTempApp(async (appRoot) => {
155
261
  await writeAppFixture(appRoot);
156
262
 
@@ -162,10 +268,33 @@ test("ui-generator page subcommand creates a file route and derives label from t
162
268
  options: {}
163
269
  });
164
270
 
165
- assert.deepEqual(result.touchedFiles, [toPagePath(targetFile), "src/placement.js"]);
271
+ assert.deepEqual(result.touchedFiles, [toPagePath(targetFile)]);
166
272
 
167
273
  const pageSource = await readFile(path.join(appRoot, toPagePath(targetFile)), "utf8");
168
- assert.match(pageSource, /<h1 class="text-h5 mb-2">Contact Id<\/h1>/);
274
+ assert.match(pageSource, /generated-ui-screen generated-ui-screen--operator generated-page-screen/);
275
+ assert.match(pageSource, /<h1 class="generated-page-screen__title">Contact Id<\/h1>/);
276
+ assert.match(pageSource, /No Contact Id activity yet/);
277
+
278
+ const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
279
+ assert.doesNotMatch(placementSource, /ui-generator\.page\.admin\.contacts\.contact-id\.link/);
280
+ });
281
+ });
282
+
283
+ test("ui-generator page subcommand allows explicit primary navigation for dynamic routes", async () => {
284
+ await withTempApp(async (appRoot) => {
285
+ await writeAppFixture(appRoot);
286
+
287
+ const targetFile = "w/[workspaceSlug]/admin/contacts/[contactId].vue";
288
+ const result = await runGeneratorSubcommand({
289
+ appRoot,
290
+ subcommand: "page",
291
+ args: [targetFile],
292
+ options: {
293
+ "navigation-role": "primary"
294
+ }
295
+ });
296
+
297
+ assert.deepEqual(result.touchedFiles, [toPagePath(targetFile), "src/placement.js"]);
169
298
 
170
299
  const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
171
300
  assert.match(placementSource, /scopedSuffix: "\/contacts\/\[contactId\]"/);
@@ -489,7 +618,7 @@ test("ui-generator page subcommand overwrites an existing page when --force is p
489
618
  assert.equal(result.summary, 'Regenerated UI page "/practice" at src/pages/w/[workspaceSlug]/admin/practice/index.vue.');
490
619
 
491
620
  const pageSource = await readFile(path.join(appRoot, toPagePath(targetFile)), "utf8");
492
- assert.match(pageSource, /<h1 class="text-h5 mb-2">Practice<\/h1>/);
621
+ assert.match(pageSource, /<h1 class="generated-page-screen__title">Practice<\/h1>/);
493
622
  assert.doesNotMatch(pageSource, /custom practice page/);
494
623
  });
495
624
  });