@jskit-ai/shell-web 0.1.151 → 0.1.153

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.
@@ -177,22 +177,56 @@ async function expectContentAwareDrawerFit(drawer, expect) {
177
177
  }
178
178
  }
179
179
 
180
- async function expectCentredRailNavigation(page, drawer, expect) {
180
+ async function readNavigationIconCenters(drawer) {
181
+ const icons = drawer.locator("a.shell-menu-link-item[href] .v-icon");
182
+ await icons.first().waitFor({ state: "visible" });
183
+ const centers = [];
184
+ const iconCount = await icons.count();
185
+ for (let index = 0; index < iconCount; index += 1) {
186
+ const box = await icons.nth(index).boundingBox();
187
+ if (!box) {
188
+ throw new Error("Shell navigation exposed an unmeasurable icon.");
189
+ }
190
+ centers.push(box.x + box.width / 2);
191
+ }
192
+ return centers;
193
+ }
194
+
195
+ function isRequestedOrDescendantLocation(actualUrl, targetUrl) {
196
+ const normalizePathname = (value) => {
197
+ const pathname = String(value || "/");
198
+ return pathname === "/" ? pathname : pathname.replace(/\/+$/u, "");
199
+ };
200
+ const actualPathname = normalizePathname(actualUrl.pathname);
201
+ const targetPathname = normalizePathname(targetUrl.pathname);
202
+ const pathMatches = actualPathname === targetPathname || (
203
+ targetPathname !== "/" && actualPathname.startsWith(`${targetPathname}/`)
204
+ );
205
+
206
+ return pathMatches && actualUrl.search === targetUrl.search;
207
+ }
208
+
209
+ async function expectCentredRailNavigation(page, drawer, expect, expandedIconCenters) {
181
210
  const links = drawer.locator("a.shell-menu-link-item[href]");
182
211
  await expect(links.first()).toBeVisible();
183
212
  const firstLink = links.first();
184
- const firstIcon = firstLink.locator(".v-icon").first();
185
- const geometry = await Promise.all([drawer.boundingBox(), firstLink.boundingBox(), firstIcon.boundingBox()]);
186
- const [drawerBox, linkBox, iconBox] = geometry;
213
+ const geometry = await Promise.all([drawer.boundingBox(), firstLink.boundingBox()]);
214
+ const [drawerBox, linkBox] = geometry;
187
215
 
188
216
  expect(drawerBox).not.toBeNull();
189
217
  expect(linkBox).not.toBeNull();
190
- expect(iconBox).not.toBeNull();
191
218
  expect(linkBox.width).toBeGreaterThanOrEqual(48);
192
219
  expect(linkBox.height).toBeGreaterThanOrEqual(48);
193
- expect(Math.abs(
194
- iconBox.x + iconBox.width / 2 - (drawerBox.x + drawerBox.width / 2)
195
- )).toBeLessThanOrEqual(1);
220
+ const railIconCenters = await readNavigationIconCenters(drawer);
221
+ expect(railIconCenters.length).toBe(expandedIconCenters.length);
222
+ for (let index = 0; index < railIconCenters.length; index += 1) {
223
+ expect(Math.abs(
224
+ railIconCenters[index] - (drawerBox.x + drawerBox.width / 2)
225
+ )).toBeLessThanOrEqual(1);
226
+ expect(Math.abs(
227
+ railIconCenters[index] - expandedIconCenters[index]
228
+ )).toBeLessThanOrEqual(1);
229
+ }
196
230
 
197
231
  const currentUrl = new URL(page.url());
198
232
  const linkCount = await links.count();
@@ -216,8 +250,14 @@ async function expectCentredRailNavigation(page, drawer, expect) {
216
250
  await navigationLink.locator(".v-icon").first().click();
217
251
  await expect.poll(() => {
218
252
  const actual = new URL(page.url());
219
- return `${actual.pathname}${actual.search}`;
220
- }).toBe(`${targetUrl.pathname}${targetUrl.search}`);
253
+ const leftOriginalLocation = (
254
+ actual.pathname !== currentUrl.pathname ||
255
+ actual.search !== currentUrl.search
256
+ );
257
+ return leftOriginalLocation && isRequestedOrDescendantLocation(actual, targetUrl);
258
+ }, {
259
+ message: "Shell navigation did not reach the requested route or its canonical descendant."
260
+ }).toBe(true);
221
261
  }
222
262
 
223
263
  async function openCompactDrawer(page, expect) {
@@ -288,13 +328,14 @@ async function runAdaptiveShellSmokeCase({
288
328
  await expect(drawer).toHaveAttribute("data-presentation", "drawer");
289
329
  await expectDrawerWidth(drawer, expect, await readConfiguredWidth(drawer, "data-drawer-width"));
290
330
  await expectContentAwareDrawerFit(drawer, expect);
331
+ const expandedIconCenters = await readNavigationIconCenters(drawer);
291
332
  await page.keyboard.press("Escape");
292
333
  await expect(drawer).toHaveAttribute("data-presentation", "drawer");
293
334
 
294
335
  await toggle.click();
295
336
  await expect(drawer).toHaveAttribute("data-presentation", "rail");
296
337
  await expectDrawerWidth(drawer, expect, await readConfiguredWidth(drawer, "data-rail-width"));
297
- await expectCentredRailNavigation(page, drawer, expect);
338
+ await expectCentredRailNavigation(page, drawer, expect, expandedIconCenters);
298
339
  await expectNoHorizontalOverflow(page, expect);
299
340
  }
300
341
  }
@@ -318,4 +359,9 @@ function runAdaptiveShellSmoke({
318
359
  });
319
360
  }
320
361
 
321
- export { DEFAULT_VIEWPORTS, runAdaptiveShellSmoke, runAdaptiveShellSmokeCase };
362
+ export {
363
+ DEFAULT_VIEWPORTS,
364
+ isRequestedOrDescendantLocation,
365
+ runAdaptiveShellSmoke,
366
+ runAdaptiveShellSmokeCase
367
+ };
@@ -1,110 +1,23 @@
1
1
  import assert from "node:assert/strict";
2
- import { spawn } from "node:child_process";
3
- import net from "node:net";
4
2
  import path from "node:path";
5
3
  import process from "node:process";
6
4
  import test from "node:test";
7
5
  import { fileURLToPath } from "node:url";
8
6
  import { chromium, expect } from "@playwright/test";
7
+ import {
8
+ createChromiumLaunchOptions,
9
+ startViteFixture,
10
+ stopProcess
11
+ } from "../../../tooling/testUtils/browserFixture.mjs";
9
12
  import {
10
13
  DEFAULT_VIEWPORTS,
11
14
  runAdaptiveShellSmokeCase
12
15
  } from "../src/test/adaptiveShellSmoke.js";
13
16
 
14
17
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
15
- const REPOSITORY_ROOT = path.resolve(TEST_DIRECTORY, "../../..");
16
18
  const FIXTURE_ROOT = path.resolve(TEST_DIRECTORY, "../fixtures/adaptive-shell");
17
- const VITE_CLI = path.join(REPOSITORY_ROOT, "node_modules", "vite", "bin", "vite.js");
18
19
  const RUN_BROWSER_TEST = process.env.JSKIT_SHELL_WEB_BROWSER_INTEGRATION === "1";
19
20
 
20
- function startVite(port) {
21
- const child = spawn(process.execPath, [
22
- VITE_CLI,
23
- "--config",
24
- path.join(FIXTURE_ROOT, "vite.config.mjs"),
25
- "--port",
26
- String(port),
27
- "--clearScreen",
28
- "false"
29
- ], {
30
- cwd: FIXTURE_ROOT,
31
- stdio: ["ignore", "pipe", "pipe"],
32
- env: {
33
- ...process.env,
34
- NO_COLOR: "1",
35
- FORCE_COLOR: "0"
36
- }
37
- });
38
- let output = "";
39
- child.stdout.on("data", collectOutput);
40
- child.stderr.on("data", collectOutput);
41
-
42
- function collectOutput(chunk) {
43
- output += chunk.toString("utf8");
44
- }
45
-
46
- async function waitUntilReady() {
47
- await new Promise((resolve, reject) => {
48
- const startedAt = Date.now();
49
- const timer = setInterval(checkReadiness, 50);
50
-
51
- function checkReadiness() {
52
- if (new RegExp(`http://127\\.0\\.0\\.1:${port}/`, "u").test(output)) {
53
- clearInterval(timer);
54
- resolve();
55
- return;
56
- }
57
- if (child.exitCode !== null) {
58
- clearInterval(timer);
59
- reject(new Error(`Vite exited before becoming ready.\n${output}`));
60
- return;
61
- }
62
- if (Date.now() - startedAt >= 30_000) {
63
- clearInterval(timer);
64
- reject(new Error(`Timed out waiting for Vite.\n${output}`));
65
- }
66
- }
67
- });
68
- }
69
-
70
- return Object.freeze({ child, waitUntilReady });
71
- }
72
-
73
- async function stopVite(runtime) {
74
- const child = runtime?.child;
75
- if (!child || child.exitCode !== null || child.signalCode !== null) {
76
- return;
77
- }
78
- await new Promise((resolve) => {
79
- const forceTimer = setTimeout(forceStop, 5_000);
80
- child.once("exit", finishStop);
81
- child.kill("SIGTERM");
82
-
83
- function forceStop() {
84
- child.kill("SIGKILL");
85
- }
86
-
87
- function finishStop() {
88
- clearTimeout(forceTimer);
89
- resolve();
90
- }
91
- });
92
- }
93
-
94
- async function reservePort() {
95
- const server = net.createServer();
96
- await new Promise((resolve, reject) => {
97
- server.once("error", reject);
98
- server.listen(0, "127.0.0.1", resolve);
99
- });
100
- const address = server.address();
101
- assert.ok(address && typeof address === "object");
102
- await new Promise((resolve, reject) => {
103
- server.close((error) => error ? reject(error) : resolve());
104
- });
105
- return address.port;
106
- }
107
-
108
21
  function parseRgb(color) {
109
22
  const channels = String(color || "").match(/[\d.]+/gu)?.map(Number) || [];
110
23
  return {
@@ -166,6 +79,11 @@ async function assertTooltipContrast(page, linkName, interaction) {
166
79
  async function assertDrawerFit(page) {
167
80
  const drawer = page.getByTestId("jskit-shell-drawer");
168
81
  await expect(drawer).toHaveAttribute("data-presentation", "drawer");
82
+ assert.equal(
83
+ await drawer.locator(".v-list-subheader").count(),
84
+ 0,
85
+ "The drawer must not repeat the surface label from the top app bar."
86
+ );
169
87
  const configuredSpacing = Number(await drawer.getAttribute("data-navigation-item-spacing"));
170
88
  assert.ok(Number.isFinite(configuredSpacing));
171
89
  await expect.poll(async () => {
@@ -255,25 +173,22 @@ test("shell-web adaptive navigation passes package-owned browser contracts", {
255
173
  : "set JSKIT_SHELL_WEB_BROWSER_INTEGRATION=1 to run shell-web browser integration",
256
174
  timeout: 180_000
257
175
  }, async () => {
258
- const port = await reservePort();
259
- const vite = startVite(port);
176
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
260
177
  let browser = null;
261
178
 
262
179
  try {
263
- await vite.waitUntilReady();
264
- const executablePath = String(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "").trim();
265
- browser = await chromium.launch({
266
- headless: true,
267
- ...(executablePath ? { executablePath } : {})
268
- });
180
+ browser = await chromium.launch(createChromiumLaunchOptions());
269
181
  const context = await browser.newContext({
270
- baseURL: `http://127.0.0.1:${port}`,
182
+ baseURL: vite.baseURL,
271
183
  locale: "en-US"
272
184
  });
273
185
 
274
186
  for (const viewport of DEFAULT_VIEWPORTS) {
275
187
  const page = await context.newPage();
276
188
  await runAdaptiveShellSmokeCase({ page, expect, viewport });
189
+ if (viewport.name !== "compact") {
190
+ await expect(page).toHaveURL(/\/home\/settings\/general$/u);
191
+ }
277
192
  await page.close();
278
193
  }
279
194
 
@@ -319,6 +234,6 @@ test("shell-web adaptive navigation passes package-owned browser contracts", {
319
234
  await context.close();
320
235
  } finally {
321
236
  await browser?.close();
322
- await stopVite(vite);
237
+ await stopProcess(vite);
323
238
  }
324
239
  });
@@ -3,14 +3,16 @@ import path from "node:path";
3
3
  import test from "node:test";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
6
- import descriptor from "../package.descriptor.mjs";
6
+ import packageJson from "../package.json" with { type: "json" };
7
+
8
+ const packageMetadata = packageJson.jskit;
7
9
 
8
10
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
9
11
  const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
10
12
  const CREATE_APP_TEMPLATE_DIR = path.resolve(PACKAGE_DIR, "..", "..", "tooling", "create-app", "templates", "minimal-shell");
11
13
 
12
14
  function findFileMutation(id) {
13
- const files = descriptor?.mutations?.files;
15
+ const files = packageMetadata?.mutations?.files;
14
16
  return Array.isArray(files)
15
17
  ? files.find((entry) => String(entry?.id || "").trim() === id) || null
16
18
  : null;
@@ -3,7 +3,9 @@ import path from "node:path";
3
3
  import test from "node:test";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
6
- import descriptor from "../package.descriptor.mjs";
6
+ import packageJson from "../package.json" with { type: "json" };
7
+
8
+ const packageMetadata = packageJson.jskit;
7
9
  import {
8
10
  LOCAL_LINK_ITEM_COMPONENT_DEFINITIONS,
9
11
  findLocalLinkItemDefinition,
@@ -14,14 +16,14 @@ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
14
16
  const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
15
17
 
16
18
  function findFileMutation(id) {
17
- const files = descriptor?.mutations?.files;
19
+ const files = packageMetadata?.mutations?.files;
18
20
  return Array.isArray(files)
19
21
  ? files.find((entry) => String(entry?.id || "").trim() === id) || null
20
22
  : null;
21
23
  }
22
24
 
23
25
  function findSourceMutation(id) {
24
- const sourceMutations = descriptor?.mutations?.source;
26
+ const sourceMutations = packageMetadata?.mutations?.source;
25
27
  return Array.isArray(sourceMutations)
26
28
  ? sourceMutations.find((entry) => String(entry?.id || "").trim() === id) || null
27
29
  : null;
@@ -3,16 +3,19 @@ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import test from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
+ import {
7
+ isRequestedOrDescendantLocation
8
+ } from "../src/test/adaptiveShellSmoke.js";
6
9
 
7
10
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
11
 
9
12
  test("shell-web owns the exact Playwright dependency used by its generated smoke", async () => {
10
- const descriptor = (await import(`${path.join(PACKAGE_ROOT, "package.descriptor.mjs")}?test=${Date.now()}`)).default;
13
+ const packageJson = JSON.parse(await readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
11
14
  const rootPackage = JSON.parse(await readFile(path.resolve(PACKAGE_ROOT, "../../package.json"), "utf8"));
12
15
 
13
- assert.equal(descriptor.mutations.dependencies.dev["@playwright/test"], "1.61.1");
16
+ assert.equal(packageJson.jskit.mutations.dependencies.dev["@playwright/test"], "1.61.1");
14
17
  assert.equal(
15
- descriptor.mutations.dependencies.dev["@playwright/test"],
18
+ packageJson.jskit.mutations.dependencies.dev["@playwright/test"],
16
19
  rootPackage.devDependencies["@playwright/test"]
17
20
  );
18
21
  });
@@ -25,6 +28,39 @@ test("adaptive shell smoke navigates through Playwright baseURL", async () => {
25
28
  assert.doesNotMatch(source, /http:\/\/127\.0\.0\.1/u);
26
29
  });
27
30
 
31
+ test("adaptive shell navigation accepts canonical descendants without accepting prefix siblings", () => {
32
+ const requested = new URL("https://example.test/home/settings?tab=profile");
33
+
34
+ assert.equal(
35
+ isRequestedOrDescendantLocation(
36
+ new URL("https://example.test/home/settings?tab=profile"),
37
+ requested
38
+ ),
39
+ true
40
+ );
41
+ assert.equal(
42
+ isRequestedOrDescendantLocation(
43
+ new URL("https://example.test/home/settings/general?tab=profile"),
44
+ requested
45
+ ),
46
+ true
47
+ );
48
+ assert.equal(
49
+ isRequestedOrDescendantLocation(
50
+ new URL("https://example.test/home/settings-other?tab=profile"),
51
+ requested
52
+ ),
53
+ false
54
+ );
55
+ assert.equal(
56
+ isRequestedOrDescendantLocation(
57
+ new URL("https://example.test/home/settings/general?tab=security"),
58
+ requested
59
+ ),
60
+ false
61
+ );
62
+ });
63
+
28
64
  test("adaptive shell smoke follows rendered layout state and waits for drawer transitions", async () => {
29
65
  const source = await readFile(path.join(PACKAGE_ROOT, "src/test/adaptiveShellSmoke.js"), "utf8");
30
66
 
@@ -35,7 +71,7 @@ test("adaptive shell smoke follows rendered layout state and waits for drawer tr
35
71
  assert.match(source, /toBeFocused/u);
36
72
  assert.match(source, /v-navigation-drawer__scrim/u);
37
73
  assert.match(source, /page\.keyboard\.press\("Escape"\)[\s\S]*data-presentation", "drawer"/u);
38
- assert.match(source, /iconBox\.x \+ iconBox\.width \/ 2/u);
74
+ assert.match(source, /centers\.push\(box\.x \+ box\.width \/ 2\)/u);
39
75
  assert.match(source, /fit\.endGap - configuredSpacing/u);
40
76
  assert.match(source, /fit\.iconLabelGaps/u);
41
77
  assert.doesNotMatch(source, /viewport\.name === "compact"/u);
@@ -4,14 +4,16 @@ import test from "node:test";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { assertGeneratedUiSourceContract } from "@jskit-ai/kernel/shared/support/generatedUiContract";
7
- import descriptor from "../package.descriptor.mjs";
7
+ import packageJson from "../package.json" with { type: "json" };
8
+
9
+ const packageMetadata = packageJson.jskit;
8
10
  import { resolveShellRouteTransitionKey } from "../src/client/support/routeTransitionKey.js";
9
11
 
10
12
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
11
13
  const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
12
14
 
13
15
  function readOutlets(target = "") {
14
- const outlets = descriptor?.metadata?.ui?.placements?.outlets;
16
+ const outlets = packageMetadata?.metadata?.ui?.placements?.outlets;
15
17
  const normalizedTarget = String(target || "").trim();
16
18
  return Array.isArray(outlets)
17
19
  ? outlets.filter((entry) => String(entry?.target || "").trim() === normalizedTarget)
@@ -19,7 +21,7 @@ function readOutlets(target = "") {
19
21
  }
20
22
 
21
23
  function readContributions(target = "") {
22
- const contributions = descriptor?.metadata?.ui?.placements?.contributions;
24
+ const contributions = packageMetadata?.metadata?.ui?.placements?.contributions;
23
25
  const normalizedTarget = String(target || "").trim();
24
26
  return Array.isArray(contributions)
25
27
  ? contributions.filter((entry) => String(entry?.target || "").trim() === normalizedTarget)
@@ -27,7 +29,7 @@ function readContributions(target = "") {
27
29
  }
28
30
 
29
31
  function readTopology(id = "", owner = "") {
30
- const placements = descriptor?.metadata?.ui?.placements?.topology?.placements;
32
+ const placements = packageMetadata?.metadata?.ui?.placements?.topology?.placements;
31
33
  const normalizedId = String(id || "").trim();
32
34
  const normalizedOwner = String(owner || "").trim();
33
35
  return Array.isArray(placements)
@@ -46,12 +48,12 @@ function readPackageImportSpecifiers(source = "") {
46
48
  }
47
49
 
48
50
  function readClientContainerTokens() {
49
- const tokens = descriptor?.metadata?.apiSummary?.containerTokens?.client;
51
+ const tokens = packageMetadata?.metadata?.apiSummary?.containerTokens?.client;
50
52
  return Array.isArray(tokens) ? tokens : [];
51
53
  }
52
54
 
53
55
  function findFileMutation(id) {
54
- const files = descriptor?.mutations?.files;
56
+ const files = packageMetadata?.mutations?.files;
55
57
  return Array.isArray(files)
56
58
  ? files.find((entry) => String(entry?.id || "").trim() === id) || null
57
59
  : null;
@@ -110,6 +112,10 @@ test("shell-web shell layout registers navigation at the app layout level", asyn
110
112
  assert.match(source, /measureDrawerContentWidth/);
111
113
  assert.match(source, /DEFAULT_SHELL_NAVIGATION_ITEM_SPACING/);
112
114
  assert.match(source, /:prepend-gap="resolvedNavigationItemSpacing"/);
115
+ assert.match(source, /--shell-navigation-drawer-inset:\s*12px/);
116
+ assert.match(source, /--shell-navigation-rail-width/);
117
+ assert.match(source, /padding-inline-start:\s*var\(--shell-navigation-drawer-inset\)/);
118
+ assert.doesNotMatch(source, /<v-list-subheader/);
113
119
  assert.doesNotMatch(source, /:width="248"/);
114
120
  assert.doesNotMatch(source, /:rail-width="80"/);
115
121
  assert.match(source, /:model-value="drawerPresentation\.visible"/);
@@ -347,7 +353,7 @@ test("shell-web placement topology seeds global actions as a semantic shell plac
347
353
  assert.match(source, /outlet: "shell-layout:supporting-side-panel"/);
348
354
  });
349
355
 
350
- test("shell-web descriptor pre-optimizes package subpaths reached only through dynamic base-shell modules", async () => {
356
+ test("shell-web packageMetadata pre-optimizes package subpaths reached only through dynamic base-shell modules", async () => {
351
357
  const [providerSource, placementSource, placementTopologySource, errorSource] = await Promise.all([
352
358
  readFile(path.join(PACKAGE_DIR, "src", "client", "providers", "ShellWebClientProvider.js"), "utf8"),
353
359
  readFile(path.join(PACKAGE_DIR, "templates", "src", "placement.js"), "utf8"),
@@ -374,16 +380,16 @@ test("shell-web descriptor pre-optimizes package subpaths reached only through d
374
380
  "@jskit-ai/shell-web/client/error"
375
381
  ]);
376
382
 
377
- assert.deepEqual(descriptor?.metadata?.client?.optimizeDeps?.include, [
383
+ assert.deepEqual(packageMetadata?.metadata?.client?.optimizeDeps?.include, [
378
384
  "@jskit-ai/shell-web/client/placement",
379
385
  "@jskit-ai/shell-web/client/error"
380
386
  ]);
381
- assert.deepEqual(descriptor?.metadata?.client?.optimizeDeps?.exclude, [
387
+ assert.deepEqual(packageMetadata?.metadata?.client?.optimizeDeps?.exclude, [
382
388
  "@jskit-ai/shell-web/client"
383
389
  ]);
384
390
  });
385
391
 
386
- test("shell-web descriptor metadata advertises adaptive shell outlets, default links, and installs the scaffold page", () => {
392
+ test("shell-web packageMetadata metadata advertises adaptive shell outlets, default links, and installs the scaffold page", () => {
387
393
  const homePageMutationIds = [
388
394
  "shell-web-page-home-wrapper",
389
395
  "shell-web-page-home",