@jskit-ai/shell-web 0.1.149 → 0.1.150

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.
@@ -0,0 +1,290 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { chromium, expect } from "@playwright/test";
9
+ import {
10
+ DEFAULT_VIEWPORTS,
11
+ runAdaptiveShellSmokeCase
12
+ } from "../src/test/adaptiveShellSmoke.js";
13
+
14
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
15
+ const REPOSITORY_ROOT = path.resolve(TEST_DIRECTORY, "../../..");
16
+ 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
+ const RUN_BROWSER_TEST = process.env.JSKIT_SHELL_WEB_BROWSER_INTEGRATION === "1";
19
+
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
+ function parseRgb(color) {
109
+ const channels = String(color || "").match(/[\d.]+/gu)?.map(Number) || [];
110
+ return {
111
+ red: channels[0] || 0,
112
+ green: channels[1] || 0,
113
+ blue: channels[2] || 0,
114
+ alpha: channels.length > 3 ? channels[3] : 1
115
+ };
116
+ }
117
+
118
+ function relativeLuminance(color) {
119
+ const channels = [color.red, color.green, color.blue].map(normalizeChannel);
120
+ return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
121
+ }
122
+
123
+ function normalizeChannel(channel) {
124
+ const normalized = channel / 255;
125
+ return normalized <= 0.03928
126
+ ? normalized / 12.92
127
+ : ((normalized + 0.055) / 1.055) ** 2.4;
128
+ }
129
+
130
+ function contrastRatio(foreground, background) {
131
+ const foregroundLuminance = relativeLuminance(foreground);
132
+ const backgroundLuminance = relativeLuminance(background);
133
+ const lighter = Math.max(foregroundLuminance, backgroundLuminance);
134
+ const darker = Math.min(foregroundLuminance, backgroundLuminance);
135
+ return (lighter + 0.05) / (darker + 0.05);
136
+ }
137
+
138
+ async function assertTooltipContrast(page, linkName, interaction) {
139
+ const link = page.getByRole("link", { name: linkName, exact: true });
140
+ if (interaction === "focus") {
141
+ await link.focus();
142
+ } else {
143
+ await link.hover();
144
+ }
145
+ const tooltip = page.locator(".shell-navigation-tooltip").filter({ hasText: linkName }).last();
146
+ await expect(tooltip).toBeVisible();
147
+ const colors = await tooltip.evaluate((element) => {
148
+ const style = window.getComputedStyle(element);
149
+ return {
150
+ background: style.backgroundColor,
151
+ color: style.color,
152
+ opacity: Number(style.opacity)
153
+ };
154
+ });
155
+ const background = parseRgb(colors.background);
156
+ const foreground = parseRgb(colors.color);
157
+ assert.equal(colors.opacity, 1);
158
+ assert.equal(background.alpha, 1);
159
+ assert.ok(
160
+ contrastRatio(foreground, background) >= 4.5,
161
+ `${linkName} tooltip contrast was ${contrastRatio(foreground, background).toFixed(2)}:1.`
162
+ );
163
+ await page.keyboard.press("Escape");
164
+ }
165
+
166
+ async function assertDrawerFit(page) {
167
+ const drawer = page.getByTestId("jskit-shell-drawer");
168
+ await expect(drawer).toHaveAttribute("data-presentation", "drawer");
169
+ await expect.poll(async () => {
170
+ const configured = Number(await drawer.getAttribute("data-drawer-width"));
171
+ const box = await drawer.boundingBox();
172
+ return box ? Math.abs(box.width - configured) : Number.POSITIVE_INFINITY;
173
+ }).toBeLessThanOrEqual(1);
174
+ const result = await drawer.evaluate((element) => {
175
+ const drawerRect = element.getBoundingClientRect();
176
+ const border = Number.parseFloat(window.getComputedStyle(element).borderRightWidth) || 0;
177
+ const innerEnd = drawerRect.right - border;
178
+ const labels = Array.from(element.querySelectorAll(".v-list-item-title")).map((label) => {
179
+ const range = document.createRange();
180
+ range.selectNodeContents(label);
181
+ const rect = range.getBoundingClientRect();
182
+ range.detach?.();
183
+ return {
184
+ clientHeight: label.clientHeight,
185
+ clientWidth: label.clientWidth,
186
+ rect,
187
+ scrollHeight: label.scrollHeight,
188
+ scrollWidth: label.scrollWidth
189
+ };
190
+ });
191
+ const furthestEnd = Math.max(...labels.map((label) => label.rect.right));
192
+ return {
193
+ clipped: labels.some((label) => (
194
+ label.scrollWidth > label.clientWidth ||
195
+ label.scrollHeight > label.clientHeight
196
+ )),
197
+ endGap: innerEnd - furthestEnd,
198
+ overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth
199
+ };
200
+ });
201
+ assert.equal(result.clipped, false, "A drawer label was clipped or ellipsized.");
202
+ assert.ok(result.endGap >= 9 && result.endGap <= 11, `Drawer label end gap was ${result.endGap}px.`);
203
+ assert.ok(result.overflow <= 1, `Page overflowed horizontally by ${result.overflow}px.`);
204
+ }
205
+
206
+ async function assertRailKeyboardAndSelection(page) {
207
+ const drawer = page.getByTestId("jskit-shell-drawer");
208
+ await page.getByTestId("jskit-shell-nav-toggle").click();
209
+ await expect(drawer).toHaveAttribute("data-presentation", "rail");
210
+ const configuredRailWidth = Number(await drawer.getAttribute("data-rail-width"));
211
+ await expect.poll(async () => {
212
+ const box = await drawer.boundingBox();
213
+ return box ? Math.abs(box.width - configuredRailWidth) : Number.POSITIVE_INFINITY;
214
+ }).toBeLessThanOrEqual(1);
215
+
216
+ const activeLink = page.getByRole("link", { name: "Bookings", exact: true });
217
+ const activeBackground = await activeLink.locator(".v-list-item__overlay").evaluate(
218
+ (element) => window.getComputedStyle(element).backgroundColor
219
+ );
220
+ assert.notEqual(activeBackground, "rgba(0, 0, 0, 0)");
221
+
222
+ const contacts = page.getByRole("link", { name: "Contacts", exact: true });
223
+ await contacts.focus();
224
+ await contacts.press("Enter");
225
+ await expect(page).toHaveURL(/\/contacts(?:\?|$)/u);
226
+
227
+ const assistant = page.getByRole("link", { name: "Assistant", exact: true });
228
+ await assistant.focus();
229
+ const scrollBeforeSpace = await page.evaluate(() => window.scrollY);
230
+ await assistant.press("Space");
231
+ await expect(page).toHaveURL(/\/assistant(?:\?|$)/u);
232
+ assert.equal(await page.evaluate(() => window.scrollY), scrollBeforeSpace);
233
+ }
234
+
235
+ test("shell-web adaptive navigation passes package-owned browser contracts", {
236
+ skip: RUN_BROWSER_TEST
237
+ ? false
238
+ : "set JSKIT_SHELL_WEB_BROWSER_INTEGRATION=1 to run shell-web browser integration",
239
+ timeout: 180_000
240
+ }, async () => {
241
+ const port = await reservePort();
242
+ const vite = startVite(port);
243
+ let browser = null;
244
+
245
+ try {
246
+ await vite.waitUntilReady();
247
+ const executablePath = String(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "").trim();
248
+ browser = await chromium.launch({
249
+ headless: true,
250
+ ...(executablePath ? { executablePath } : {})
251
+ });
252
+ const context = await browser.newContext({
253
+ baseURL: `http://127.0.0.1:${port}`,
254
+ locale: "en-US"
255
+ });
256
+
257
+ for (const viewport of DEFAULT_VIEWPORTS) {
258
+ const page = await context.newPage();
259
+ await runAdaptiveShellSmokeCase({ page, expect, viewport });
260
+ await page.close();
261
+ }
262
+
263
+ for (const pathAndTheme of [
264
+ "/home?theme=light",
265
+ "/home?theme=dark",
266
+ "/w/acme/admin/bookings?theme=light",
267
+ "/w/acme/admin/bookings?theme=dark"
268
+ ]) {
269
+ const page = await context.newPage();
270
+ await page.setViewportSize({ width: 1280, height: 900 });
271
+ await page.goto(pathAndTheme);
272
+ await expect(page.getByTestId("jskit-shell-drawer")).toBeVisible();
273
+ await assertDrawerFit(page);
274
+ await assertTooltipContrast(page, "Bookings", "focus");
275
+ await assertTooltipContrast(page, "Help and support", "hover");
276
+ await page.close();
277
+ }
278
+
279
+ const railPage = await context.newPage();
280
+ await railPage.setViewportSize({ width: 1280, height: 900 });
281
+ await railPage.goto("/w/acme/admin/bookings?theme=light");
282
+ await assertDrawerFit(railPage);
283
+ await assertRailKeyboardAndSelection(railPage);
284
+ await railPage.close();
285
+ await context.close();
286
+ } finally {
287
+ await browser?.close();
288
+ await stopVite(vite);
289
+ }
290
+ });
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ DEFAULT_SHELL_DRAWER_WIDTH,
5
+ DEFAULT_SHELL_RAIL_WIDTH,
6
+ normalizeShellDrawerWidth,
7
+ normalizeShellRailWidth,
8
+ resolveContentAwareDrawerWidth
9
+ } from "../src/client/support/drawerWidth.js";
10
+
11
+ test("content-aware drawer width includes the rendered label start, width, and 10px end gap", () => {
12
+ assert.equal(
13
+ resolveContentAwareDrawerWidth([
14
+ { logicalStart: 72, textWidth: 40 },
15
+ { logicalStart: 72, textWidth: 78 }
16
+ ]),
17
+ 160
18
+ );
19
+ assert.equal(
20
+ resolveContentAwareDrawerWidth([
21
+ { logicalStart: 72, textWidth: 99.015625 }
22
+ ]),
23
+ 182
24
+ );
25
+ });
26
+
27
+ test("drawer and rail widths have stable defaults and bounded overrides", () => {
28
+ assert.equal(normalizeShellDrawerWidth(null), DEFAULT_SHELL_DRAWER_WIDTH);
29
+ assert.equal(normalizeShellDrawerWidth(180), 180);
30
+ assert.equal(normalizeShellDrawerWidth(1000), 360);
31
+ assert.equal(normalizeShellRailWidth(null), DEFAULT_SHELL_RAIL_WIDTH);
32
+ assert.equal(normalizeShellRailWidth(96), 96);
33
+ assert.equal(normalizeShellRailWidth(20), 48);
34
+ });
35
+
36
+ test("content-aware width ignores invalid measurements and uses a bounded fallback", () => {
37
+ assert.equal(
38
+ resolveContentAwareDrawerWidth([
39
+ { logicalStart: -1, textWidth: 20 },
40
+ { logicalStart: 40, textWidth: Number.NaN }
41
+ ]),
42
+ DEFAULT_SHELL_DRAWER_WIDTH
43
+ );
44
+ assert.equal(
45
+ resolveContentAwareDrawerWidth([{ logicalStart: 72, textWidth: 900 }]),
46
+ 360
47
+ );
48
+ });
@@ -152,13 +152,17 @@ test("shell-web generic link items support the expected shared route and icon be
152
152
  assert.match(shellMenuSource, /exact:\s*\{/);
153
153
  assert.match(shellMenuSource, /:exact="props\.exact"/);
154
154
  assert.match(shellMenuSource, /:aria-label="props\.label"/);
155
- assert.match(shellMenuSource, /v-tooltip="props\.label"/);
155
+ assert.match(shellMenuSource, /ShellNavigationTooltip/);
156
+ assert.match(shellMenuSource, /@keydown\.space="activateShellNavigationLinkOnSpace"/);
157
+ assert.doesNotMatch(shellMenuSource, /v-tooltip="props\.label"/);
156
158
  assert.match(shellMenuSource, /class="shell-menu-link-item"/);
157
159
  assert.match(shellMenuSource, /min-height:\s*48px/);
158
160
  assert.match(shellSurfaceAwareSource, /exact:\s*\{/);
159
161
  assert.match(shellSurfaceAwareSource, /:exact="props\.exact"/);
160
162
  assert.match(shellSurfaceAwareSource, /:aria-label="props\.label"/);
161
- assert.match(shellSurfaceAwareSource, /v-tooltip="props\.label"/);
163
+ assert.match(shellSurfaceAwareSource, /ShellNavigationTooltip/);
164
+ assert.match(shellSurfaceAwareSource, /@keydown\.space="activateShellNavigationLinkOnSpace"/);
165
+ assert.doesNotMatch(shellSurfaceAwareSource, /v-tooltip="props\.label"/);
162
166
  assert.match(shellSurfaceAwareSource, /class="shell-menu-link-item"/);
163
167
  assert.match(shellSurfaceAwareSource, /min-height:\s*48px/);
164
168
  assert.match(shellTabSource, /icon:\s*\{/);
@@ -167,6 +171,18 @@ test("shell-web generic link items support the expected shared route and icon be
167
171
  assert.match(shellTabSource, /stacked/);
168
172
  assert.match(shellTabSource, /min-height:\s*48px/);
169
173
  assert.match(shellTabSource, /<v-icon v-if="resolvedIcon" :icon="resolvedIcon" \/>/);
174
+
175
+ const tooltipSource = await readFile(
176
+ path.join(PACKAGE_DIR, "src", "client", "components", "ShellNavigationTooltip.vue"),
177
+ "utf8"
178
+ );
179
+ assert.match(tooltipSource, /:theme="theme\.name\.value"/);
180
+ assert.match(tooltipSource, /location="end"/);
181
+ assert.match(tooltipSource, /:open-on-focus="true"/);
182
+ assert.match(tooltipSource, /:open-on-hover="true"/);
183
+ assert.match(tooltipSource, /--v-theme-inverse-surface, var\(--v-theme-on-surface\)/);
184
+ assert.match(tooltipSource, /--v-theme-inverse-on-surface, var\(--v-theme-surface\)/);
185
+ assert.match(tooltipSource, /opacity:\s*1\s*!important/);
170
186
  });
171
187
 
172
188
  test("shell-web binds the local link-item wrapper tokens into MainClientProvider", () => {
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { activateShellNavigationLinkOnSpace } from "../src/client/support/navigationLinkKeyboard.js";
4
+
5
+ test("Space activates a shell navigation link once and suppresses page scrolling", () => {
6
+ const calls = [];
7
+ activateShellNavigationLinkOnSpace({
8
+ key: " ",
9
+ currentTarget: {
10
+ click() {
11
+ calls.push("click");
12
+ }
13
+ },
14
+ preventDefault() {
15
+ calls.push("preventDefault");
16
+ },
17
+ stopPropagation() {
18
+ calls.push("stopPropagation");
19
+ }
20
+ });
21
+
22
+ assert.deepEqual(calls, ["preventDefault", "stopPropagation", "click"]);
23
+ });
24
+
25
+ test("other keys retain native link behavior", () => {
26
+ let clicked = false;
27
+ activateShellNavigationLinkOnSpace({
28
+ key: "Enter",
29
+ currentTarget: {
30
+ click() {
31
+ clicked = true;
32
+ }
33
+ }
34
+ });
35
+ assert.equal(clicked, false);
36
+ });
@@ -24,3 +24,18 @@ test("adaptive shell smoke navigates through Playwright baseURL", async () => {
24
24
  assert.doesNotMatch(source, /PLAYWRIGHT_BASE_URL/u);
25
25
  assert.doesNotMatch(source, /http:\/\/127\.0\.0\.1/u);
26
26
  });
27
+
28
+ test("adaptive shell smoke follows rendered layout state and waits for drawer transitions", async () => {
29
+ const source = await readFile(path.join(PACKAGE_ROOT, "src/test/adaptiveShellSmoke.js"), "utf8");
30
+
31
+ assert.match(source, /data-layout/u);
32
+ assert.match(source, /data-rail-width/u);
33
+ assert.match(source, /expect\.poll/u);
34
+ assert.match(source, /toBeFocused/u);
35
+ assert.match(source, /v-navigation-drawer__scrim/u);
36
+ assert.match(source, /page\.keyboard\.press\("Escape"\)[\s\S]*data-presentation", "drawer"/u);
37
+ assert.match(source, /iconBox\.x \+ iconBox\.width \/ 2/u);
38
+ assert.match(source, /endGap\)\.toBeGreaterThanOrEqual\(9\)/u);
39
+ assert.doesNotMatch(source, /viewport\.name === "compact"/u);
40
+ assert.doesNotMatch(source, /await new Promise.*setTimeout|waitForTimeout/u);
41
+ });
@@ -98,7 +98,17 @@ test("shell-web shell layout registers navigation at the app layout level", asyn
98
98
  assert.match(source, /desktopDrawerClosedMode/);
99
99
  assert.match(source, /default: "rail"/);
100
100
  assert.match(source, /:rail="drawerPresentation\.rail"/);
101
- assert.match(source, /:rail-width="80"/);
101
+ assert.match(source, /drawerWidth/);
102
+ assert.match(source, /railWidth/);
103
+ assert.match(source, /:width="resolvedDrawerWidth"/);
104
+ assert.match(source, /:rail-width="resolvedRailWidth"/);
105
+ assert.match(source, /data-drawer-width="resolvedDrawerWidth"/);
106
+ assert.match(source, /data-rail-width="resolvedRailWidth"/);
107
+ assert.match(source, /data-layout="layoutClass"/);
108
+ assert.match(source, /measureDrawerContentWidth/);
109
+ assert.match(source, /SHELL_DRAWER_LABEL_END_GAP/);
110
+ assert.doesNotMatch(source, /:width="248"/);
111
+ assert.doesNotMatch(source, /:rail-width="80"/);
102
112
  assert.match(source, /:model-value="drawerPresentation\.visible"/);
103
113
  assert.match(source, /data-testid="jskit-shell-nav-toggle"/);
104
114
  assert.match(source, /data-testid="jskit-shell-bottom-nav"/);
@@ -107,6 +117,7 @@ test("shell-web shell layout registers navigation at the app layout level", asyn
107
117
  const template = await readFile(path.join(PACKAGE_DIR, "templates", "src", "components", "ShellLayout.vue"), "utf8");
108
118
 
109
119
  assert.match(template, /PackageShellLayout from "@jskit-ai\/shell-web\/client\/components\/ShellLayout"/);
120
+ assert.match(template, /drawerWidth, railWidth, and future shell props package-owned/);
110
121
  assert.match(template, /h\(PackageShellLayout, attrs, slots\)/);
111
122
  assert.doesNotMatch(template, /ShellOutlet|ShellRouteTransition|useShellLayoutState|pointerdown|v-navigation-drawer|v-bottom-navigation/);
112
123
  });
@@ -154,7 +165,7 @@ test("shell-web installs generated adaptive shell Playwright smoke coverage", as
154
165
  assert.match(source, /@jskit-ai\/shell-web\/test\/adaptiveShellSmoke/);
155
166
  assert.match(helperSource, /generated adaptive shell smoke/);
156
167
  assert.match(helperSource, /390/);
157
- assert.match(helperSource, /768/);
168
+ assert.match(helperSource, /1024/);
158
169
  assert.match(helperSource, /1280/);
159
170
  assert.match(helperSource, /jskit-shell-bottom-nav/);
160
171
  assert.match(helperSource, /jskit-shell-drawer/);