@kabmo-labs/kabmo 0.0.1 → 0.2.0
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.
- package/README.md +16 -11
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
- package/package.json +2 -1
- package/public/kabmo-wordmark.svg +31 -0
- package/src/cli/lib/commands/update.js +17 -2
- package/src/cli/lib/commands/update.test.js +36 -0
- package/src/client/bootstrap.d.ts +8 -0
- package/src/client/components/auth/login-screen/index.test.tsx +93 -0
- package/src/client/components/auth/login-screen/index.tsx +43 -22
- package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
- package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
- package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
- package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
- package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
- package/src/client/components/common/batch-selection/folder-state.ts +101 -0
- package/src/client/components/common/batch-selection/types.ts +42 -0
- package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
- package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
- package/src/client/components/common/brand-wordmark/index.tsx +33 -11
- package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
- package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
- package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
- package/src/client/components/layout/session-sidebar/index.tsx +92 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +83 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/android-download.ts +106 -0
- package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
- package/src/client/hooks/workspace/batch-delete.ts +63 -0
- package/src/client/hooks/workspace/batch-selection.ts +98 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- package/src/server/lib/lifecycle/restart.ts +52 -14
- package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
- package/src/server/lib/updates/android-release.test.ts +92 -0
- package/src/server/lib/updates/android-release.ts +148 -0
- package/src/server/lib/updates/apply.ts +8 -0
- package/src/server/lib/updates/install-steps.ts +81 -0
- package/src/server/lib/updates/install.ts +19 -67
- package/src/server/lib/updates/installed-cli.test.ts +222 -0
- package/src/server/lib/updates/installed-cli.ts +139 -0
- package/src/server/lib/updates/omp.ts +1 -1
- package/src/server/plugins/ssr.ts +9 -2
- package/src/server/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +142 -0
- package/src/server/routes/sessions/batch-delete.ts +60 -0
- package/src/server/routes/sessions/delete.ts +13 -51
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/apply.ts +1 -1
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +36 -0
- package/src/shared/lib/workspace/batch-select.test.ts +92 -0
- package/src/shared/lib/workspace/batch-select.ts +86 -0
- package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
- package/dist/client/static/js/index-x9cehh1a.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kabmo-labs/kabmo",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Web console and diagnostic workbench for AI coding agents — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"lint": "tsc --noEmit",
|
|
54
54
|
"test": "bun test",
|
|
55
55
|
"test:e2e": "playwright test --config tests/e2e/playwright.config.ts",
|
|
56
|
+
"readme:media": "bun run scripts/readme-media.ts",
|
|
56
57
|
"typecheck:e2e": "tsc --noEmit -p tests/e2e",
|
|
57
58
|
"publish:plugins": "bash .github/scripts/publish-plugin-packages.sh packages/kabmo-sdk packages/kabmo-ui"
|
|
58
59
|
},
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!-- Kabmo wordmark — the app icon's K lockup with the word beside it.
|
|
3
|
+
The word is Inter ExtraBold (the face index.html loads), outlines at a
|
|
4
|
+
100-unit cap height; the plate is the icon's own 64-unit tile scaled to
|
|
5
|
+
1.5625 x cap so the K keeps its proportions. Tracking -0.025em, the a
|
|
6
|
+
tucked 0.28em into the plate, the b->m gap widened 0.02em — the same
|
|
7
|
+
values the app's BrandWordmark uses, measured there. -->
|
|
8
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -128.12 486.38 156.25" width="486.38" height="156.25" role="img" aria-label="Kabmo">
|
|
9
|
+
<title>Kabmo</title>
|
|
10
|
+
<defs>
|
|
11
|
+
<linearGradient id="kabmo-ab" gradientUnits="userSpaceOnUse" x1="117.76" y1="0" x2="279.42" y2="0">
|
|
12
|
+
<stop offset="0" stop-color="#f59e0b"/>
|
|
13
|
+
<stop offset="1" stop-color="#ea580c"/>
|
|
14
|
+
</linearGradient>
|
|
15
|
+
<style>
|
|
16
|
+
/* One ink for every background. A README embeds this as an image, and a
|
|
17
|
+
prefers-color-scheme query inside such an image follows the CONTAINER
|
|
18
|
+
only in Chromium — WebKit resolves it from the OS, so a GitHub-dark
|
|
19
|
+
page on a light OS rendered near-black on near-black. */
|
|
20
|
+
.kabmo-mo { fill: #7e7a72; }
|
|
21
|
+
</style>
|
|
22
|
+
</defs>
|
|
23
|
+
<rect width="156.25" height="156.25" y="-128.12" rx="34.18" fill="#0b5d3b"/>
|
|
24
|
+
<g fill="none" stroke-width="17.09" stroke-linecap="round" stroke-linejoin="round">
|
|
25
|
+
<path d="M48.83 -91.5V-8.5" stroke="#f7f4ea"/>
|
|
26
|
+
<path d="M48.83 -50 102.54 -91.5" stroke="#f26522"/>
|
|
27
|
+
<path d="M48.83 -50 102.54 -8.5" stroke="#f7f4ea"/>
|
|
28
|
+
</g>
|
|
29
|
+
<path fill="url(#kabmo-ab)" d="M146.49 1.34Q139.31 1.34 133.7 -1.11Q128.1 -3.56 124.95 -8.49Q121.79 -13.42 121.79 -20.81Q121.79 -27.05 123.97 -31.34Q126.15 -35.64 130.05 -38.32Q133.94 -41.01 138.97 -42.42Q144.01 -43.83 149.71 -44.3Q156.09 -44.83 159.98 -45.47Q163.87 -46.11 165.68 -47.38Q167.5 -48.66 167.5 -50.94L167.5 -51.21Q167.5 -53.83 166.29 -55.64Q165.08 -57.45 162.8 -58.39Q160.52 -59.33 157.29 -59.33Q154.07 -59.33 151.56 -58.39Q149.04 -57.45 147.43 -55.7Q145.82 -53.96 145.15 -51.61L123.94 -54.36Q125.42 -60.81 129.74 -65.67Q134.07 -70.54 141.09 -73.26Q148.1 -75.97 157.5 -75.97Q164.48 -75.97 170.55 -74.33Q176.62 -72.68 181.19 -69.5Q185.75 -66.31 188.3 -61.64Q190.85 -56.98 190.85 -50.94L190.85 0L168.84 0L168.84 -10.54L168.23 -10.54Q166.22 -6.64 163.1 -4.03Q159.98 -1.41 155.85 -0.03Q151.72 1.34 146.49 1.34ZM153.6 -14.16Q157.56 -14.16 160.75 -15.77Q163.94 -17.38 165.82 -20.2Q167.7 -23.02 167.7 -26.71L167.7 -33.89Q166.69 -33.36 165.18 -32.89Q163.67 -32.42 161.86 -32.05Q160.05 -31.68 158.23 -31.38Q156.42 -31.07 154.74 -30.81Q151.32 -30.34 148.91 -29.19Q146.49 -28.05 145.21 -26.24Q143.94 -24.43 143.94 -21.88Q143.94 -19.4 145.21 -17.68Q146.49 -15.97 148.64 -15.07Q150.78 -14.16 153.6 -14.16ZM247.95 1.07Q242.25 1.07 238.05 -0.84Q233.86 -2.75 231.11 -5.77Q228.35 -8.79 226.88 -12.15L226.07 -12.15L226.07 0L202.92 0L202.92 -100L226.34 -100L226.34 -62.15L226.88 -62.15Q228.29 -65.57 231.01 -68.72Q233.72 -71.88 237.92 -73.93Q242.11 -75.97 248.09 -75.97Q256.01 -75.97 262.82 -71.85Q269.63 -67.72 273.82 -59.16Q278.02 -50.6 278.02 -37.45Q278.02 -24.7 273.99 -16.14Q269.97 -7.58 263.12 -3.26Q256.27 1.07 247.95 1.07ZM239.97 -17.18Q244.46 -17.18 247.58 -19.7Q250.7 -22.21 252.31 -26.81Q253.93 -31.41 253.93 -37.45Q253.93 -43.56 252.31 -48.09Q250.7 -52.62 247.62 -55.13Q244.53 -57.65 239.97 -57.65Q235.47 -57.65 232.31 -55.2Q229.16 -52.75 227.48 -48.22Q225.8 -43.69 225.8 -37.45Q225.8 -31.34 227.48 -26.78Q229.16 -22.21 232.35 -19.7Q235.54 -17.18 239.97 -17.18Z"/>
|
|
30
|
+
<path class="kabmo-mo" fill="#7e7a72" d="M289.88 0L289.88 -75.03L311.43 -75.03L312.77 -55.57L311.16 -55.57Q312.9 -62.89 316.36 -67.38Q319.82 -71.88 324.41 -73.96Q329.01 -76.04 334.11 -76.04Q342.16 -76.04 347.1 -70.87Q352.03 -65.7 354.71 -53.96L352.1 -53.96Q353.91 -61.81 357.8 -66.71Q361.69 -71.61 366.93 -73.83Q372.16 -76.04 377.8 -76.04Q384.85 -76.04 390.25 -72.99Q395.65 -69.93 398.74 -64.26Q401.83 -58.59 401.83 -50.6L401.83 0L378.47 0L378.47 -45.3Q378.47 -51.01 375.49 -53.96Q372.5 -56.91 367.87 -56.91Q364.58 -56.91 362.13 -55.44Q359.68 -53.96 358.41 -51.31Q357.13 -48.66 357.13 -45.03L357.13 0L334.65 0L334.65 -45.64Q334.65 -50.81 331.76 -53.86Q328.88 -56.91 324.18 -56.91Q320.96 -56.91 318.51 -55.44Q316.06 -53.96 314.68 -51.24Q313.31 -48.52 313.31 -44.56L313.31 0ZM448.73 1.41Q436.98 1.41 428.53 -3.46Q420.07 -8.32 415.54 -17.01Q411.01 -25.7 411.01 -37.25Q411.01 -48.86 415.54 -57.55Q420.07 -66.24 428.53 -71.11Q436.98 -75.97 448.73 -75.97Q460.47 -75.97 468.93 -71.11Q477.39 -66.24 481.88 -57.55Q486.38 -48.86 486.38 -37.25Q486.38 -25.7 481.88 -17.01Q477.39 -8.32 468.93 -3.46Q460.47 1.41 448.73 1.41ZM448.73 -16.51Q453.22 -16.51 456.31 -19.13Q459.4 -21.74 460.98 -26.44Q462.55 -31.14 462.55 -37.38Q462.55 -43.62 460.98 -48.29Q459.4 -52.95 456.31 -55.5Q453.22 -58.05 448.73 -58.05Q444.23 -58.05 441.14 -55.5Q438.06 -52.95 436.48 -48.29Q434.9 -43.62 434.9 -37.38Q434.9 -31.14 436.48 -26.44Q438.06 -21.74 441.14 -19.13Q444.23 -16.51 448.73 -16.51Z"/>
|
|
31
|
+
</svg>
|
|
@@ -26,6 +26,21 @@ async function liveInstances(options) {
|
|
|
26
26
|
return selectRestartTargets(await listLiveInstances(), Boolean(options?.all));
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The context a replacement is spawned from.
|
|
31
|
+
*
|
|
32
|
+
* After a RENAME the installed package is not the one this command runs out of
|
|
33
|
+
* (`bun add -g @kabmo-labs/kabmo` leaves the pre-rename package untouched on
|
|
34
|
+
* disk), so restarting from `ctx.pkgRoot` relaunches the build the update just
|
|
35
|
+
* replaced — measured: the console came back as the OLD version after a
|
|
36
|
+
* successful move to the new one. The installed copy's own root wins whenever
|
|
37
|
+
* the update reported one.
|
|
38
|
+
*/
|
|
39
|
+
export function restartCtx(ctx, installed) {
|
|
40
|
+
if (!installed || typeof installed.root !== 'string' || installed.root.length === 0) return ctx;
|
|
41
|
+
return { ...ctx, pkgRoot: installed.root, bin: installed.bin ?? ctx.bin };
|
|
42
|
+
}
|
|
43
|
+
|
|
29
44
|
async function restartInstances(options, ctx) {
|
|
30
45
|
const restarted = [];
|
|
31
46
|
const skipped = [];
|
|
@@ -103,7 +118,7 @@ export async function run(options, ctx) {
|
|
|
103
118
|
// one JSON document.
|
|
104
119
|
if (result.success && result.updated && shouldRestart) configure({ json: false, quiet: true });
|
|
105
120
|
const { restarted, skipped, failed } = result.success && result.updated && shouldRestart
|
|
106
|
-
? await restartInstances(options, ctx)
|
|
121
|
+
? await restartInstances(options, restartCtx(ctx, result.installed))
|
|
107
122
|
: { restarted: [], skipped: [], failed: [] };
|
|
108
123
|
printJson({ ...result, restarted, skipped, failed });
|
|
109
124
|
if (!result.success || failed.length > 0) process.exitCode = 1;
|
|
@@ -124,7 +139,7 @@ export async function run(options, ctx) {
|
|
|
124
139
|
return;
|
|
125
140
|
}
|
|
126
141
|
|
|
127
|
-
const { restarted, skipped, failed } = await restartInstances(options, ctx);
|
|
142
|
+
const { restarted, skipped, failed } = await restartInstances(options, restartCtx(ctx, result.installed));
|
|
128
143
|
if (restarted.length === 0 && skipped.length === 0) {
|
|
129
144
|
log('No running instance to restart.');
|
|
130
145
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The context `kabmo update` restarts from.
|
|
2
|
+
//
|
|
3
|
+
// Pinned because getting it wrong is silent and expensive: after a RENAME the
|
|
4
|
+
// installed package is a different directory from the one this command runs
|
|
5
|
+
// out of, and restarting from `ctx.pkgRoot` relaunches the build the update
|
|
6
|
+
// just replaced. Measured on the 4.23.1 → 0.0.1 move: the console came back as
|
|
7
|
+
// the old version while the update reported success.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
|
|
11
|
+
import { restartCtx } from '@/cli/lib/commands/update.js';
|
|
12
|
+
|
|
13
|
+
const ctx = { cwd: '/work', pkgRoot: '/global/node_modules/ompchamber', version: '4.23.1', bin: 'ompchamber' };
|
|
14
|
+
|
|
15
|
+
describe('restartCtx', () => {
|
|
16
|
+
test('spawns the replacement from the copy the update installed', () => {
|
|
17
|
+
const installed = {
|
|
18
|
+
root: '/global/node_modules/@kabmo-labs/kabmo',
|
|
19
|
+
packageName: '@kabmo-labs/kabmo',
|
|
20
|
+
bin: 'kabmo',
|
|
21
|
+
cli: '/global/node_modules/@kabmo-labs/kabmo/src/cli/kabmo.js',
|
|
22
|
+
};
|
|
23
|
+
expect(restartCtx(ctx, installed)).toEqual({
|
|
24
|
+
...ctx,
|
|
25
|
+
pkgRoot: '/global/node_modules/@kabmo-labs/kabmo',
|
|
26
|
+
bin: 'kabmo',
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('keeps this command\u2019s own root when the update reported none', () => {
|
|
31
|
+
// A checkout, or a run whose install could not be read: the copy that was
|
|
32
|
+
// updated IS this one, so nothing moves.
|
|
33
|
+
expect(restartCtx(ctx, null)).toBe(ctx);
|
|
34
|
+
expect(restartCtx(ctx, { root: '' })).toBe(ctx);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -22,6 +22,14 @@ export interface KabmoBootstrap {
|
|
|
22
22
|
* while this fact was missing, and the only way to ask was a second request.
|
|
23
23
|
*/
|
|
24
24
|
authEnabled?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The Android shell's version, when this page was requested by the APK
|
|
27
|
+
* (`KabmoAndroid/<version>` in the User-Agent). Null in every browser.
|
|
28
|
+
*
|
|
29
|
+
* The sidebar uses it to decide whether a download button applies at all, and
|
|
30
|
+
* whether this install is behind the published APK.
|
|
31
|
+
*/
|
|
32
|
+
shellVersion?: string | null;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
declare global {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The login screen's header.
|
|
3
|
+
*
|
|
4
|
+
* It is the product's own lockup — the mark's green plate and the word — and
|
|
5
|
+
* nothing else: the key glyph that used to sit above it was a second icon for a
|
|
6
|
+
* screen whose only control is already labelled "Password". The assertions are
|
|
7
|
+
* about the RENDERED tree, because the failure this guards is a screen that
|
|
8
|
+
* still looks right in a class list while drawing two marks.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
12
|
+
import { Window } from 'happy-dom';
|
|
13
|
+
import { h, render } from 'preact';
|
|
14
|
+
import { act } from 'preact/test-utils';
|
|
15
|
+
|
|
16
|
+
import { LoginScreen } from '@/client/components/auth/login-screen';
|
|
17
|
+
|
|
18
|
+
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
|
|
19
|
+
/** The runner's own globals, restored on teardown — deleting them would strip natives later files need. */
|
|
20
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
21
|
+
const nativeFetch = globalThis.fetch;
|
|
22
|
+
|
|
23
|
+
let container: HTMLElement;
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
const win = new Window({ url: 'http://localhost' });
|
|
27
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
28
|
+
for (const key of DOM_GLOBALS) {
|
|
29
|
+
if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
|
|
30
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
31
|
+
}
|
|
32
|
+
// The screen re-checks the session on mount; an unanswerable probe leaves the
|
|
33
|
+
// form alone, which is the state under test.
|
|
34
|
+
target.fetch = async () => {
|
|
35
|
+
throw new Error('offline');
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
41
|
+
for (const key of DOM_GLOBALS) {
|
|
42
|
+
if (nativeGlobals[key] === undefined) delete target[key];
|
|
43
|
+
else target[key] = nativeGlobals[key];
|
|
44
|
+
}
|
|
45
|
+
target.fetch = nativeFetch;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
if (container) render(null, container);
|
|
50
|
+
container?.remove();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function mount(): Promise<HTMLElement> {
|
|
54
|
+
container = document.createElement('div');
|
|
55
|
+
document.body.appendChild(container);
|
|
56
|
+
await act(async () => {
|
|
57
|
+
render(h(LoginScreen, {}), container);
|
|
58
|
+
});
|
|
59
|
+
return container;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('LoginScreen header', () => {
|
|
63
|
+
test('draws the wordmark as the header, with the mark in it', async () => {
|
|
64
|
+
const el = await mount();
|
|
65
|
+
const heading = el.querySelector('h1');
|
|
66
|
+
expect(heading).toBeTruthy();
|
|
67
|
+
// The mark's own geometry, not a font letter: the plate plus three strokes.
|
|
68
|
+
const mark = heading?.querySelector('svg[viewBox="0 0 64 64"]');
|
|
69
|
+
expect(mark).toBeTruthy();
|
|
70
|
+
expect(mark?.querySelector('rect')).toBeTruthy();
|
|
71
|
+
expect(mark?.querySelectorAll('path').length).toBe(3);
|
|
72
|
+
// Hero size, so the lockup is the screen's identity rather than a caption.
|
|
73
|
+
expect(heading?.querySelector('span')?.className).toContain('text-3xl');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('carries no second icon', async () => {
|
|
77
|
+
const el = await mount();
|
|
78
|
+
// The header holds exactly one SVG — the mark. A key glyph above it would be
|
|
79
|
+
// a second. The field's eye toggle is a CONTROL, not a mark, so it is
|
|
80
|
+
// deliberately outside this count.
|
|
81
|
+
const heading = el.querySelector('h1');
|
|
82
|
+
expect(heading?.querySelectorAll('svg').length).toBe(1);
|
|
83
|
+
expect(heading?.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 64 64');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('still names the product for assistive tech and keeps the form', async () => {
|
|
87
|
+
const el = await mount();
|
|
88
|
+
expect(el.textContent).toContain('Kabmo');
|
|
89
|
+
const password = el.querySelector('input[type="password"]');
|
|
90
|
+
expect(password?.getAttribute('aria-label')).toBe('Password');
|
|
91
|
+
expect(el.querySelector('button[type="submit"]')?.textContent).toContain('Sign in');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -20,12 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
import { useEffect, useState } from 'preact/hooks';
|
|
22
22
|
import type { FunctionComponent } from 'preact/compat';
|
|
23
|
-
import {
|
|
23
|
+
import { Eye, EyeOff, Loader2 } from 'lucide-preact';
|
|
24
24
|
import { login, probeSession } from '@/client/hooks/ui/auth';
|
|
25
25
|
import { BrandWordmark } from '@/client/components/common/brand-wordmark';
|
|
26
26
|
|
|
27
27
|
export const LoginScreen: FunctionComponent = () => {
|
|
28
28
|
const [password, setPassword] = useState('');
|
|
29
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
29
30
|
const [trustDevice, setTrustDevice] = useState(false);
|
|
30
31
|
const [error, setError] = useState('');
|
|
31
32
|
const [busy, setBusy] = useState(false);
|
|
@@ -76,31 +77,51 @@ export const LoginScreen: FunctionComponent = () => {
|
|
|
76
77
|
return (
|
|
77
78
|
<div className="min-h-dvh flex items-center justify-center bg-canvas text-ink px-4">
|
|
78
79
|
<form onSubmit={submit} className="w-full max-w-xs space-y-4">
|
|
79
|
-
<div className="space-y-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
<div className="space-y-2 text-center">
|
|
81
|
+
{/* The product's own lockup is the header here: one wordmark, drawn
|
|
82
|
+
at hero size, so the login screen and the app cannot disagree
|
|
83
|
+
about what the product is called. No second icon — the K *is* the
|
|
84
|
+
icon, and a key glyph above it said nothing the field's own label
|
|
85
|
+
does not. The eye in the field is a control, not a second mark. */}
|
|
86
|
+
<h1 className="flex items-center justify-center">
|
|
87
|
+
<BrandWordmark className="text-3xl" />
|
|
85
88
|
</h1>
|
|
86
89
|
<p className="text-xs text-ink/60">This instance is password protected.</p>
|
|
87
90
|
</div>
|
|
88
91
|
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
92
|
+
<div className="relative">
|
|
93
|
+
<input
|
|
94
|
+
type={showPassword ? 'text' : 'password'}
|
|
95
|
+
autoComplete="current-password"
|
|
96
|
+
autoFocus
|
|
97
|
+
value={password}
|
|
98
|
+
onInput={(event) => {
|
|
99
|
+
setPassword(event.currentTarget.value);
|
|
100
|
+
if (error) setError('');
|
|
101
|
+
}}
|
|
102
|
+
placeholder="Password"
|
|
103
|
+
aria-label="Password"
|
|
104
|
+
aria-invalid={error ? true : undefined}
|
|
105
|
+
disabled={busy}
|
|
106
|
+
className="w-full bg-paper border border-ink/20 rounded-lg pl-3 pr-10 py-2 text-sm text-ink placeholder-ink/40 focus:outline-none focus:border-ink/50 transition-colors"
|
|
107
|
+
/>
|
|
108
|
+
{/* The eye is a `type` toggle, not a second field: the value lives in
|
|
109
|
+
one state slot either way, so revealing it cannot change what is
|
|
110
|
+
submitted. It is a real button with its own label because the
|
|
111
|
+
alternative — a bare `<svg onClick>` — is unreachable by keyboard
|
|
112
|
+
and unnamed for assistive tech. */}
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={() => setShowPassword((shown) => !shown)}
|
|
116
|
+
disabled={busy}
|
|
117
|
+
title={showPassword ? 'Hide password' : 'Show password'}
|
|
118
|
+
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
119
|
+
aria-pressed={showPassword}
|
|
120
|
+
className="absolute inset-y-0 right-0 flex items-center px-3 text-ink/50 hover:text-ink disabled:opacity-40 transition-colors cursor-pointer disabled:cursor-default"
|
|
121
|
+
>
|
|
122
|
+
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
104
125
|
|
|
105
126
|
<label className="flex items-center gap-2 text-xs text-ink/70">
|
|
106
127
|
<input
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The password field's visibility toggle.
|
|
3
|
+
*
|
|
4
|
+
* The eye is a `type` switch on the SAME input, so what it must not do is move
|
|
5
|
+
* the value: a reveal that rebuilt the field would clear the credential the
|
|
6
|
+
* user just typed. The assertions are about the rendered tree and the input's
|
|
7
|
+
* own `type`/`value`, because a class list proves nothing about either.
|
|
8
|
+
*
|
|
9
|
+
* The header's own assertions live in `index.test.tsx`; this file covers the
|
|
10
|
+
* control beside the field and nothing else.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
14
|
+
import { Window } from 'happy-dom';
|
|
15
|
+
import { h, render } from 'preact';
|
|
16
|
+
import { act } from 'preact/test-utils';
|
|
17
|
+
|
|
18
|
+
import { LoginScreen } from '@/client/components/auth/login-screen';
|
|
19
|
+
|
|
20
|
+
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
|
|
21
|
+
/** The runner's own globals, restored on teardown — deleting them would strip natives later files need. */
|
|
22
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
23
|
+
const nativeFetch = globalThis.fetch;
|
|
24
|
+
|
|
25
|
+
let container: HTMLElement;
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
const win = new Window({ url: 'http://localhost' });
|
|
29
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
30
|
+
for (const key of DOM_GLOBALS) {
|
|
31
|
+
if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
|
|
32
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
33
|
+
}
|
|
34
|
+
// The screen re-checks the session on mount; an unanswerable probe leaves the
|
|
35
|
+
// form alone, which is the state under test.
|
|
36
|
+
target.fetch = async () => {
|
|
37
|
+
throw new Error('offline');
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
43
|
+
for (const key of DOM_GLOBALS) {
|
|
44
|
+
if (nativeGlobals[key] === undefined) delete target[key];
|
|
45
|
+
else target[key] = nativeGlobals[key];
|
|
46
|
+
}
|
|
47
|
+
target.fetch = nativeFetch;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
if (container) render(null, container);
|
|
52
|
+
container?.remove();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function mount(): Promise<HTMLElement> {
|
|
56
|
+
container = document.createElement('div');
|
|
57
|
+
document.body.appendChild(container);
|
|
58
|
+
await act(async () => {
|
|
59
|
+
render(h(LoginScreen, {}), container);
|
|
60
|
+
});
|
|
61
|
+
return container;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The one field the form submits — matched by its label, never by `type`, which is what changes. */
|
|
65
|
+
function passwordField(el: HTMLElement): HTMLInputElement | null {
|
|
66
|
+
return el.querySelector<HTMLInputElement>('input[aria-label="Password"]');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('LoginScreen password visibility', () => {
|
|
70
|
+
test('starts masked, with a labelled toggle beside the field', async () => {
|
|
71
|
+
const el = await mount();
|
|
72
|
+
expect(passwordField(el)?.type).toBe('password');
|
|
73
|
+
const toggle = el.querySelector<HTMLButtonElement>('button[aria-label="Show password"]');
|
|
74
|
+
expect(toggle).toBeTruthy();
|
|
75
|
+
// A real button, not an svg with a click handler: keyboard reachable.
|
|
76
|
+
expect(toggle?.getAttribute('type')).toBe('button');
|
|
77
|
+
expect(toggle?.getAttribute('title')).toBe('Show password');
|
|
78
|
+
expect(toggle?.getAttribute('aria-pressed')).toBe('false');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('reveals the same field and puts it back, keeping the value', async () => {
|
|
82
|
+
const el = await mount();
|
|
83
|
+
await act(async () => {
|
|
84
|
+
const input = passwordField(el);
|
|
85
|
+
if (input) {
|
|
86
|
+
input.value = 's3cret';
|
|
87
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
expect(passwordField(el)?.value).toBe('s3cret');
|
|
91
|
+
|
|
92
|
+
await act(async () => el.querySelector<HTMLButtonElement>('button[aria-label="Show password"]')?.click());
|
|
93
|
+
expect(passwordField(el)?.type).toBe('text');
|
|
94
|
+
expect(passwordField(el)?.value).toBe('s3cret');
|
|
95
|
+
const hide = el.querySelector<HTMLButtonElement>('button[aria-label="Hide password"]');
|
|
96
|
+
expect(hide?.getAttribute('aria-pressed')).toBe('true');
|
|
97
|
+
expect(hide?.getAttribute('title')).toBe('Hide password');
|
|
98
|
+
|
|
99
|
+
await act(async () => hide?.click());
|
|
100
|
+
expect(passwordField(el)?.type).toBe('password');
|
|
101
|
+
expect(passwordField(el)?.value).toBe('s3cret');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the toggle is disabled while a sign-in is in flight', async () => {
|
|
105
|
+
const el = await mount();
|
|
106
|
+
await act(async () => {
|
|
107
|
+
const input = passwordField(el);
|
|
108
|
+
if (input) {
|
|
109
|
+
input.value = 's3cret';
|
|
110
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
// The submit handler awaits `login`, so the in-flight state is observable
|
|
114
|
+
// in the same tick the form is submitted.
|
|
115
|
+
await act(async () => {
|
|
116
|
+
el.querySelector<HTMLFormElement>('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
117
|
+
});
|
|
118
|
+
expect(el.querySelector<HTMLButtonElement>('button[aria-label="Show password"]')?.disabled).toBe(true);
|
|
119
|
+
expect(passwordField(el)?.disabled).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The batch bar's contract, rendered.
|
|
8
|
+
*
|
|
9
|
+
* Three properties are load-bearing and each is a way a batch delete can go
|
|
10
|
+
* wrong: the bar names the mode's noun (a session batch that says "workspace"
|
|
11
|
+
* deletes the wrong thing), Delete is refused with nothing selected, and
|
|
12
|
+
* Select-all is refused once everything already is — a control that looks
|
|
13
|
+
* available but does nothing is how a user concludes the feature is broken.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
17
|
+
import { Window } from 'happy-dom';
|
|
18
|
+
import { h, render } from 'preact';
|
|
19
|
+
import { act } from 'preact/test-utils';
|
|
20
|
+
|
|
21
|
+
import { BatchActionBar } from '@/client/components/common/batch-selection/BatchActionBar';
|
|
22
|
+
import type { BatchActionBarProps } from '@/client/components/common/batch-selection/BatchActionBar';
|
|
23
|
+
|
|
24
|
+
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
|
|
25
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
26
|
+
|
|
27
|
+
let container: HTMLElement;
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
const win = new Window({ url: 'http://localhost' });
|
|
31
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
32
|
+
for (const key of DOM_GLOBALS) {
|
|
33
|
+
nativeGlobals[key] = target[key];
|
|
34
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
40
|
+
for (const key of DOM_GLOBALS) {
|
|
41
|
+
if (nativeGlobals[key] === undefined) delete target[key];
|
|
42
|
+
else target[key] = nativeGlobals[key];
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
container?.remove();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function mount(overrides: Partial<BatchActionBarProps> = {}) {
|
|
51
|
+
const calls = { selectAll: 0, unselectAll: 0, del: 0, cancel: 0 };
|
|
52
|
+
container = document.createElement('div');
|
|
53
|
+
document.body.appendChild(container);
|
|
54
|
+
await act(async () => {
|
|
55
|
+
render(h(BatchActionBar, {
|
|
56
|
+
mode: 'session',
|
|
57
|
+
count: 0,
|
|
58
|
+
total: 3,
|
|
59
|
+
busy: false,
|
|
60
|
+
onSelectAll: () => { calls.selectAll += 1; },
|
|
61
|
+
onUnselectAll: () => { calls.unselectAll += 1; },
|
|
62
|
+
onDelete: () => { calls.del += 1; },
|
|
63
|
+
onCancel: () => { calls.cancel += 1; },
|
|
64
|
+
...overrides,
|
|
65
|
+
}), container);
|
|
66
|
+
});
|
|
67
|
+
return { el: container, calls };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buttonContaining(el: HTMLElement, text: string): HTMLButtonElement | null {
|
|
71
|
+
return Array.from(el.querySelectorAll('button'))
|
|
72
|
+
.find((button) => (button.textContent ?? '').includes(text)) as HTMLButtonElement ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('BatchActionBar', () => {
|
|
76
|
+
test('counts the selection against the scope, in the mode\'s noun', async () => {
|
|
77
|
+
const { el } = await mount({ count: 2, total: 5, scopeLabel: 'kabmo' });
|
|
78
|
+
expect(el.textContent).toContain('2');
|
|
79
|
+
expect(el.textContent).toContain('of 5 sessions selected');
|
|
80
|
+
expect(el.textContent).toContain('in kabmo');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('workspace mode names workspaces and drops the scope clause', async () => {
|
|
84
|
+
const { el } = await mount({ mode: 'workspace', count: 1, total: 4, scopeLabel: 'kabmo' });
|
|
85
|
+
expect(el.textContent).toContain('of 4 workspaces selected');
|
|
86
|
+
expect(el.textContent).not.toContain('in kabmo');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('Delete is refused with nothing selected', async () => {
|
|
90
|
+
const { el, calls } = await mount({ count: 0 });
|
|
91
|
+
const del = buttonContaining(el, 'Delete');
|
|
92
|
+
expect(del?.disabled).toBe(true);
|
|
93
|
+
await act(async () => { del?.click(); });
|
|
94
|
+
expect(calls.del).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('Delete is enabled with a selection and reports its handler', async () => {
|
|
98
|
+
const { el, calls } = await mount({ count: 2 });
|
|
99
|
+
const del = buttonContaining(el, 'Delete');
|
|
100
|
+
expect(del?.disabled).toBe(false);
|
|
101
|
+
await act(async () => { del?.click(); });
|
|
102
|
+
expect(calls.del).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('Select all is refused once everything is selected', async () => {
|
|
106
|
+
const { el } = await mount({ count: 3, total: 3 });
|
|
107
|
+
expect(buttonContaining(el, 'Select all')?.disabled).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('Unselect all is refused with nothing selected', async () => {
|
|
111
|
+
const { el } = await mount({ count: 0 });
|
|
112
|
+
expect(buttonContaining(el, 'Unselect all')?.disabled).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('both directions are separate controls and both report', async () => {
|
|
116
|
+
const { el, calls } = await mount({ count: 1, total: 3 });
|
|
117
|
+
await act(async () => {
|
|
118
|
+
buttonContaining(el, 'Select all')?.click();
|
|
119
|
+
buttonContaining(el, 'Unselect all')?.click();
|
|
120
|
+
});
|
|
121
|
+
expect(calls.selectAll).toBe(1);
|
|
122
|
+
expect(calls.unselectAll).toBe(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a running delete disables every control and says so', async () => {
|
|
126
|
+
const { el } = await mount({ count: 2, busy: true });
|
|
127
|
+
expect(el.textContent).toContain('Deleting…');
|
|
128
|
+
for (const button of Array.from(el.querySelectorAll('button'))) {
|
|
129
|
+
expect((button as HTMLButtonElement).disabled).toBe(true);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|