mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
v26.10.0 (#1573)
* feat: optional auto reconnect for dropped ssh sessions (#1321) * feat: tighter compact host rows with inline tags (#1325) * feat: bring back the alert feed homepage widget * feat: pick target terminals for snippets again * feat: share button on connection tabs again * feat: recent servers list when linking the desktop app * feat: list the fixed app shortcuts in keyboard shortcuts * fix: translate hardcoded toasts and drop em dashes from ui text * fix: translate leftover hardcoded ui text * fix: toolbar files button opens at the shell directory again * fix: show the remote desktop note under connection origin again * chore: openapi docs for host metrics manager routes * chore: openapi docs for undocumented routes and fix two broken doc blocks * fix: declare terminal.listSessions use for snippet targets * revert: drop the alert feed widget, the notification center replaces it * feat: restore the alert feed homepage widget * fix: quick connect fills the host address from plugin auth editors like tailscale * fix: docker container logs use the base background color * chore: fix dependabot alerts and apply safe npm upgrades * fix: proxmox stats tab reads its switch from plugin host settings * fix: count plugin ssh logins toward host status and stop showing reachable as a warning * fix: hide the built-in source pill in the host editor * fix: host status shows online when the port answers and only warns on a failed login * fix: drop the top border on the split layout menu * fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default * fix: remote desktop auth method labels and notes for vnc and telnet * fix: add description to the web endpoint enable switch * fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release * feat: move telemetry out of core into a usage statistics plugin * fix: recent activity card no longer cuts off host names early * fix: remove duplicate top border on split screen menu * fix: redesign session log player controls, fix tooltip overlap and drop dot separators * fix: file manager and sftp text showing raw i18n keys * fix: onboarding rerunning for existing users after upgrading * fix: fleet detail buttons overflowing the sidebar * fix: session log player scrolling off screen horizontally * fix: network graph lag when adding hosts and on every render * fix: maintenance index name, shared host read only view and badge polling * fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag * chore: update release notes * fix: remote desktop tabs reconnecting on every tab switch * fix: build plugin sdk before linting in release workflow * chore: lint, format, and bump version to 2.9.0 * chore: skip crowdin pre-translate in release for now * chore: switch to calver, bump version to 26.10.0 * fix(sso): send the PKCE verifier for GitHub login (#1534) * fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) * test(remote-desktop): preserve sessions beyond one hour (#1536) * fix(hosts): expose controls for overflowing editor tabs (#1537) * fix(hosts): expose controls for overflowing editor tabs * test(hosts): mock resize observation in admin panel tests * fix(status): skip TCP probes while host sessions are active (#1538) * fix(database): batch session activity persistence (#1539) * docs(api): describe cookie and API key authentication (#1540) * fix(snippets): repair missing note column on SQLite upgrades (#1541) * fix(docker): retain stored SSH credential association (#1543) * fix(file-manager): render transfer toasts without plugin hooks (#1546) * feat(hosts): keep compact row actions inline (#1547) * fix(auth): allow retrying unavailable second-factor interfaces (#1549) * fix(auth): reject unresolved legacy identity provisioning (#1550) * fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) * fix(tmux): pass full session info to the terminal's session picker (#1552) The tmux.sessions service reduced detected sessions to bare names, but the picker reads session.name, so every entry rendered blank and selecting one sent an empty name, which created a new session instead of attaching. * fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553) The column holds the text "true"/"false", and the resolver passed it through as-is. The string "false" is truthy, so the password provider treated every saved host as forced keyboard-interactive and left the password out. Jump hops are built straight from the resolved host, so a password hop whose server doesn't offer keyboard-interactive failed with "All configured authentication methods failed". * fix(database): batch database saves for bulk host writes (#1554) * fix(database): yield to the event loop between database saves Each SQLite save serializes and encrypts the whole database into new buffers, which V8 only releases once the event loop turns. Boot migrations and bulk host import write plugin settings per host and per key in an awaited loop, so hundreds of full copies piled up and the container ran out of memory. * fix(database): save once per bulk host write instead of once per row On SQLite every repository write force-saves the whole database. Boot plugin data migrations, host defaults materialization and the bulk host routes write one row per host per setting, so they serialized and encrypted the full database hundreds of times in a row. DatabaseSaveTrigger.batched wraps a function so force saves made while it runs collapse into a single save when it finishes. Nested scopes fold into the outer one, and work that outlives its scope saves normally. * feat(hosts): add instance-wide predefined tag suggestions (#1548) * feat(hosts): add shared predefined tag suggestions * style(hosts): format tag catalog routes * test(database): advance fake timers past the save yield (#1555) * fix(i18n): complete Simplified Chinese core and plugin translations (#1542) * fix(i18n): complete Chinese onboarding and navigation labels * fix(i18n): translate remaining Chinese core and plugin interfaces * fix(i18n): translate host editor tab overflow controls * fix(i18n): use consistent Chinese fleet terminology * fix(i18n): localize hardcoded controls and plugin views * fix(i18n): translate built-in homepage widget catalog * test(homepage): follow translated timezone placeholder * fix(i18n): translate plugin-provided homepage widgets * fix(i18n): localize feature settings section titles * fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) * fix: plugins losing the saved ssh login when copying a host (#1391) * fix: fleets, proxmox and automations not getting the host sudo password * fix: host imports running a defaults pass and metrics restart per host (#1384) * fix: high cpu from status probes and full database saves on every sample (#1300) * fix: user data export freezing the server (#1393) * fix: op:// secret references rejected as ssh keys on credentials (#1394) * fix: slow file deletes from the trash lookups on every delete (#1390) * fix: add openapi docs and error handling to host tag routes * test: compare download stream buffers directly so it stops timing out * chore: drop em dash from host row comment * chore: update release notes for 2.9.1 * feat(ai): add remote coding agent sessions for SSH hosts (#1545) * feat(ai): add host coding agent sessions with configured providers * Wait for agent initialization and shutdown before resuming sessions * Keep Pi transcripts outside its configuration migration directory * Cover Pi session directory separation * Update core permission catalog expectations for remote agents * Install remote agent runtimes from verified official sources * Wait for the identity panel credentials request in its test * Add scoped SSH forwarding setup for remote agents * Verify forwarding setup through a fresh SSH connection * Bound the SSH forwarding verification timeout * Return the forwarding verification callback result * fix(ai): support agent send and interrupt keyboard shortcuts * feat(ai): add agent queues attachments worktrees and session recovery * fix(ai): handle deleted workspace paths and duplicate status events * fix(ai): accept file references in root workspaces * fix(ai): refresh paused queues after agent errors * fix(ai): store agent sessions in plugin tables and lock down OpenCode Agent sessions lived in a single ctx.kv value holding up to 2000 events, which passed the 256 KB kv limit within minutes of a normal run. Every save after that failed, so history, nativeId, queue and archive changes were lost and every event wrote a kv audit row. Sessions, events and queued prompts now have their own tables. A running agent appends events in batches at most once a second with a lazy save; session and queue changes save immediately. Prompts, drafts and stored event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no longer requested. The OpenCode server now requires a per-session password, so other local accounts on the host cannot drive the agent through its loopback port. * test(upgrade): include agent session tables in the reviewed upgrade SQL * test: run the posix-only ai agent tests on linux only * fix: restore space to add host tags and redesign predefined tags editor * fix: host key silently accepted when the host is missing from the database (#1397) * fix: clearer host login failed status label and fix its translations (#1396) * chore: add host key and status label fixes to release notes * fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles * chore: increment ver * fix(audit): store plugin entries with no acting user as a null user_id (#1556) Plugin audit entries written outside a request used the literal "system" as user_id. That column references users.id, so the insert was refused (Postgres logs it as an FK violation) and the entry was silently dropped. Write null instead and keep "system" / plugin:<id> in username. * chore: add sso/ldap dialog and audit log fixes to release notes * fix: tunnels and host settings missing from shared hosts on desktop * fix: remote desktop logins missing from shared hosts on desktop * fix: simplify host status to online/offline and keep it live without a refresh * chore: sync Crowdin translations for 2.9.1 * fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi * chore: rework plugin readmes, json changelogs and standalone repo setup * chore: move plugins to their own repos and drop the plugins folder * chore(deps-dev): bump globals (#1560) Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals). Updates `globals` from 17.12.0 to 17.13.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.13.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: drop the 2.8 columns, moves and aliases kept for a downgrade * chore: remove the 2.9 finish list * feat: port the demo redesign with Manage and Settings tabs * chore: pin the plugin signing key and use TERMIX_PAT in workflows * chore: rename the plugin sdk to @termix-ssh/plugin-sdk * feat: add a publish workflow for the plugin sdk * chore: cleanup * fix: harden plugin sockets, public routes, prebuilt binaries and login fallback * chore: add a 2.9.1 upgrade check for postgres and mysql * fix: stop full database rewrites on every plugin db call * fix: stop full database rewrites on every plugin db call * fix: restore release video link in 2.9.2 notes * fix: use TERMIX_PAT in workflows * feat: pin the official plugins in the docker image * feat: implement plugin management UI * feat: shared list kit, settings search, rail order and ui consistency fixes * chore: run all workflows on blacksmith runners * chore: split dashboard quick actions into quarters and drop host status row lines * fix: open plain settings from the profile button, navigation only from the rail menu * feat: consent check, developer mode file installs, install counts and issue links for plugins * feat: row detail options for hosts and credentials, open manage from sidebar header * fix: unify connection failure screens and rebuild stale local plugins * feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev * chore: clean up npm scripts, make npm run dev the full runner and drop concurrently * fix: settle quick connect panel loads in tests so they stop flaking * fix: stop the dev runner rebuilding plugins when windows reports file reads as changes * chore: update the plugin sdk ui export snapshot * feat: full screen onboarding with a plugin picker and per step versions * feat: changelog.md release notes, v tags, plugin release notes and videos * chore: add temp changelog * chore: release the plugin sdk as 1.0.1 * feat: show only the latest plugin release notes and cap long ones * chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub * chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN * chore: renew mac app store provisioning profile * chore: drop docs job from release workflow * chore: run sdk publish on github hosted runner * chore: remove dependabot * chore: general file cleanup * chore: rewrite readme and taglines for plugin-based termix * chore: drop optional from ai plugin row in readme * feat: per-repo issue forms and prefilled bug reports instead of the support repo * feat: add report a bug link to the your account settings section * fix: open settings goes to the plugin's page and hides when it has none * feat: show plugin features from the manifest on the plugin page * fix: retry failed or blocked plugins on restart instead of leaving them disabled * fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling * feat: request a feature link for plugins and use the bug form for unverified plugins * feat: beta channels for core and plugins with one stable/beta release workflow * fix(sync): resolve shared hosts for connect-only recipients (#1568) * fix(ssh): accept full passwords in keyboard-interactive prompts (#1569) * fix(ssh): normalize PKCS8 private keys before connecting (#1570) * fix(sidebar): reclaim space from hidden folder actions (#1571) * fix(hosts): display protocol credential usernames for non-SSH hosts (#1572) * fix: ask for a push/TOTP menu choice as text instead of a push confirm * fix: keep client tunnels open when one of their connections fails (Support#1417) * fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420) * fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419) * fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418) * fix: refresh cached plugin translations when only a translation changes (Support#1411) * fix: import sshpk as a default so the backend starts under ESM * feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2 * fix: drop the ./ from the plugin sdk bin path so npm stops warning * chore: name core and sdk releases Termix vX and Plugin SDK vX * fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g) * fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh) * fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw) * fix: refuse sessionless tokens and keep the secrets file owner only * feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm) * chore: standardize repo setup files * chore: remove unused knip config * chore: add LICENSE and use Node 24 from .nvmrc * chore: update translation guidance, only en.json needs editing * feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3 * fix: header separators render as borders and center the status divider * fix: lighter confirm prompt overlay to stop lag when it opens * fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4 * fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK * fix: keep plugin tailwind variants in core's layer so overrides keep their order * fix: manage tab refetches before giving up on a host or credential it cannot find * fix: tab buttons like share show as soon as the terminal is ready * fix: folder rows no longer nest action buttons inside a button * fix: repin remote-desktop after its 1.0.0 re-release * chore: repin bundled plugins after the 1.0.0 re-release * fix: recent activity rows use the same separator as host status * feat: upload banner * fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24 * fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks * fix: bundled plugins path for source runs and backend cleanup * fix: load plugin strings on guest pages, plugin wording and drop unused keys * fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms * docs: 26.10.0 changelog * fix: current version in the version route example * chore: stop pushing the release- docker tag * feat: remove the donation reminder and its column * docs: note the removed donation reminder * fix: link server wizard always opens inline, onboarding card grows for it * feat: plugin text columns are longtext on mysql so they are not capped at 64KB * fix: linked desktops keep a plugin when its update from the server fails * chore: default mobile download links to v tags * fix: sso only accounts cannot sign in with a passkey or other local method * feat: note in the plugins tab that community plugins are coming * fix: plugin validate accepts beta versions * chore: pin bundled plugins rebuilt on sdk 1.0.5 * fix: skip locale fetches for disabled plugins * fix: show translated names for the built-in admin and user roles * fix: show host keys as SHA256 fingerprints like ssh-keygen * fix: host editor scrolls to a newly enabled protocol section * fix: pass the host to dashboard host row plugins * fix: plugin cards show dividers between author, version and category * fix: refuse plugin archives with paths outside the plugin folder * fix: theme picker shows one dropdown arrow * fix: read activity and audit times as UTC so they show in local time * fix: show the real error for a wrong password or code on plugin sign in * fix: explain that external sign in needs Auto-create external accounts * fix: clear leftover plugin unpack folders at startup * fix: shared session links open for guests again * fix: share panels no longer list yourself as a share target * fix: a host update without an auth type keeps the saved password * fix: guest pages get the tab ids a guest plugin registers * fix: refuse a 2.8 database, which already had a plugins table * fix: dashboard cards fit on a phone * fix: desktop setup no longer asks to secure the local account * fix: the desktop app keeps the desktop layout in a narrow window * fix: the desktop app shows the rail and no phone bar in a narrow window * fix: name every sync type in the Sync panel and pluralize the link prompt * chore: update the sdk ui snapshot for the host key dialog * fix: turning a plugin back on restarts the plugins it turned off * fix: quick connect asks to trust the host key * fix: revoking a manager also removes the shares they handed out * fix: database export works for users with TOTP on (#1426) * fix: rdp and vnc logins use the folder credential when none is picked (#1429) * fix: imported hosts get status checks without a restart * fix: re-pin bundled plugins after 1.0.0 re-release * fix: re-pin bundled plugins after search bar fix * chore: update app icon to smaller version * chore: round the readme logo, keep square source for app icons * feat: grid columns and table view in the plugins tab * fix: match the host key copy button to the fingerprint height * fix: use brand color for toggles in sync settings * fix: re-pin bundled plugins after 1.0.0 re-release * fix: web terminals failing auth behind a proxy that rewrites Host (Support#651) * fix: re-pin snippets and termix-identity after 1.0.0 overwrite * fix: re-pin file-manager after 1.0.0 overwrite * fix: re-pin file-manager after 1.0.0 overwrite * chore: remove Crowdin from the release workflow * chore: remove crowdin config --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
3656 files changed
+116210
-460999
No files matched your search
@@ -114,9 +114,9 @@ describe("Auth local desktop flow", () => {
|
||||
renderAuth();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Local desktop signed out")).toBeTruthy();
|
||||
expect(screen.getByText("auth.localDesktopSignedOut")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("Continue with local desktop")).toBeTruthy();
|
||||
expect(screen.getByText("auth.continueLocalDesktop")).toBeTruthy();
|
||||
expect(screen.queryByText("common.register")).toBeNull();
|
||||
expect(screen.queryByLabelText("common.username")).toBeNull();
|
||||
expect(screen.queryByText("auth.forgotPassword")).toBeNull();
|
||||
@@ -134,11 +134,9 @@ describe("Auth local desktop flow", () => {
|
||||
renderAuth();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Local desktop session unavailable"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("auth.localDesktopUnavailable")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("Retry local desktop session")).toBeTruthy();
|
||||
expect(screen.getByText("auth.retryLocalDesktop")).toBeTruthy();
|
||||
expect(screen.queryByText("common.register")).toBeNull();
|
||||
expect(screen.queryByLabelText("common.username")).toBeNull();
|
||||
expect(mainAxios.requestDesktopAutoSession).toHaveBeenCalled();
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import type {
|
||||
LoginMethodUIProps,
|
||||
SecondFactorUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
} from "@termix-ssh/plugin-sdk/frontend";
|
||||
|
||||
const mainAxios = vi.hoisted(() => ({
|
||||
loginUser: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { masonryColumns } from "@/components/card-masonry";
|
||||
|
||||
describe("masonryColumns", () => {
|
||||
it("stacks into one column when narrow", () => {
|
||||
expect(masonryColumns(400, 4)).toBe(1);
|
||||
});
|
||||
|
||||
it("grows with the container up to the preference", () => {
|
||||
expect(masonryColumns(700, 3)).toBe(2);
|
||||
expect(masonryColumns(1000, 3)).toBe(3);
|
||||
expect(masonryColumns(1300, 3)).toBe(3);
|
||||
expect(masonryColumns(1300, 4)).toBe(4);
|
||||
});
|
||||
|
||||
it("falls back to three for an unknown preference", () => {
|
||||
expect(masonryColumns(1300, 9)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import {
|
||||
ConnectionGate,
|
||||
type ConnectionGateController,
|
||||
} from "@/components/connection/ConnectionGate";
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ConnectionGate", () => {
|
||||
it("shows the children once connected", async () => {
|
||||
render(
|
||||
<ConnectionGate
|
||||
message="docker.connecting"
|
||||
connect={(gate) => gate.markConnected()}
|
||||
>
|
||||
<span>containers</span>
|
||||
</ConnectionGate>,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(screen.getByText("containers")).toBeTruthy();
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the screen up with the reason and a retry when connect throws", async () => {
|
||||
const connect = vi
|
||||
.fn<(gate: ConnectionGateController) => Promise<void> | void>()
|
||||
.mockRejectedValueOnce(new Error("No route to host"))
|
||||
.mockImplementation((gate) => gate.markConnected());
|
||||
render(
|
||||
<ConnectionGate message="files.connecting" connect={connect}>
|
||||
<span>tree</span>
|
||||
</ConnectionGate>,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByText("tree")).toBeNull();
|
||||
expect(screen.getAllByText("No route to host").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("connection.failedRetrying")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("connection.retryNow"));
|
||||
});
|
||||
expect(screen.getByText("tree")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not retry on its own when unavailable", async () => {
|
||||
const connect = vi.fn((gate: ConnectionGateController) =>
|
||||
gate.markUnavailable({ title: "docker.notEnabled" }),
|
||||
);
|
||||
render(
|
||||
<ConnectionGate message="docker.connecting" connect={connect}>
|
||||
<span>containers</span>
|
||||
</ConnectionGate>,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(screen.getByText("docker.notEnabled")).toBeTruthy();
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(connect).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("containers")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps children mounted under the screen when asked", async () => {
|
||||
render(
|
||||
<ConnectionGate
|
||||
message="serial.connecting"
|
||||
keepMounted
|
||||
connect={() => new Promise(() => {})}
|
||||
>
|
||||
<span>xterm</span>
|
||||
</ConnectionGate>,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(screen.getByText("xterm")).toBeTruthy();
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,17 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { ConnectionScreen } from "../../../components/connection/ConnectionScreen";
|
||||
import { ConnectionLogProvider } from "../../../ssh/connection-log/ConnectionLogContext";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "../../../ssh/connection-log/ConnectionLogContext";
|
||||
import { SurfaceScope } from "../../../components/surface/surface-scope";
|
||||
import { useEffect } from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -25,16 +30,16 @@ describe("ConnectionScreen", () => {
|
||||
});
|
||||
|
||||
it("renders the disconnected state without a provider", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message="remoteDesktop.hostNotFound"
|
||||
/>,
|
||||
),
|
||||
).not.toThrow();
|
||||
render(
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message="common.loading"
|
||||
disconnectedMessage="terminal.connectionLost"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("remoteDesktop.hostNotFound")).toBeTruthy();
|
||||
expect(screen.getByText("terminal.connectionLost")).toBeTruthy();
|
||||
expect(screen.queryByText("common.loading")).toBeNull();
|
||||
expect(screen.getByRole("status").getAttribute("data-status")).toBe(
|
||||
"disconnected",
|
||||
);
|
||||
@@ -47,6 +52,137 @@ describe("ConnectionScreen", () => {
|
||||
</ConnectionLogProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/terminal\.connectionLogTitle/)).toBeTruthy();
|
||||
expect(screen.getByText(/sshAuth\.connectionLogTitle/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers retry and the reason once a connect fails", () => {
|
||||
const retry = vi.fn();
|
||||
render(
|
||||
<ConnectionScreen
|
||||
status="error"
|
||||
message="common.loading"
|
||||
errorDetail="Authentication failed"
|
||||
onManualRetry={retry}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("connection.failed")).toBeTruthy();
|
||||
expect(screen.getByText("Authentication failed")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("connection.reconnect"));
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lets the user skip the wait while it retries on its own", () => {
|
||||
const retry = vi.fn();
|
||||
render(
|
||||
<ConnectionScreen
|
||||
status="error"
|
||||
attempt={2}
|
||||
maxAttempts={8}
|
||||
nextRetryInMs={4000}
|
||||
onManualRetry={retry}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("connection.failedRetrying")).toBeTruthy();
|
||||
expect(screen.getByText("connection.retryingIn")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("connection.retryNow"));
|
||||
expect(retry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an unavailable state with its hint, action and retry", () => {
|
||||
render(
|
||||
<ConnectionScreen
|
||||
status="connected"
|
||||
unavailable={{
|
||||
title: "docker.notEnabled",
|
||||
hint: "docker.notEnabledHint",
|
||||
action: <button>open editor</button>,
|
||||
}}
|
||||
onManualRetry={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status").getAttribute("data-status")).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
expect(screen.getByText("docker.notEnabled")).toBeTruthy();
|
||||
expect(screen.getByText("docker.notEnabledHint")).toBeTruthy();
|
||||
expect(screen.getByText("open editor")).toBeTruthy();
|
||||
expect(screen.getByText("connection.reconnect")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the same headline when retries run out", () => {
|
||||
render(<ConnectionScreen status="disconnected" message="common.loading" />);
|
||||
expect(screen.getByText("connection.failed")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes the tab it sits in once it stops", () => {
|
||||
const close = vi.fn();
|
||||
render(
|
||||
<SurfaceScope kind="tab" onClose={close}>
|
||||
<ConnectionScreen status="error" onManualRetry={() => {}} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("connection.close"));
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prefers its own close and can hide it", () => {
|
||||
const own = vi.fn();
|
||||
const { rerender } = render(
|
||||
<SurfaceScope kind="tab" onClose={() => {}}>
|
||||
<ConnectionScreen status="error" onClose={own} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("connection.close"));
|
||||
expect(own).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<SurfaceScope kind="tab" onClose={() => {}}>
|
||||
<ConnectionScreen status="error" onClose={false} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
expect(screen.queryByText("connection.close")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no close while connecting or outside a tab", () => {
|
||||
render(
|
||||
<SurfaceScope kind="tab" onClose={() => {}}>
|
||||
<ConnectionScreen status="connecting" />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
expect(screen.queryByText("connection.close")).toBeNull();
|
||||
cleanup();
|
||||
render(<ConnectionScreen status="error" />);
|
||||
expect(screen.queryByText("connection.close")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the last logged error for the reason", () => {
|
||||
function Seed() {
|
||||
const { addLog } = useConnectionLog();
|
||||
useEffect(() => {
|
||||
addLog({ type: "error", stage: "connection", message: "old" });
|
||||
addLog({ type: "info", stage: "connection", message: "trying" });
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: "Docker not found",
|
||||
});
|
||||
}, [addLog]);
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<ConnectionLogProvider>
|
||||
<Seed />
|
||||
<ConnectionScreen status="error" />
|
||||
</ConnectionLogProvider>,
|
||||
);
|
||||
expect(screen.getAllByText("Docker not found").length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders nothing once connected", () => {
|
||||
const { container } = render(<ConnectionScreen status="connected" />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { DataView } from "@/components/data-view";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const items = [
|
||||
{ id: "a", name: "alpha" },
|
||||
{ id: "b", name: "beta" },
|
||||
];
|
||||
|
||||
function renderView(view: "grid" | "list", onRowClick = vi.fn()) {
|
||||
return render(
|
||||
<DataView
|
||||
items={items}
|
||||
view={view}
|
||||
density="compact"
|
||||
getKey={(item) => item.id}
|
||||
renderCard={(item) => <div>card {item.name}</div>}
|
||||
columns={{ base: 1, md: 2 }}
|
||||
listColumns={[
|
||||
{ key: "name", header: "Name", width: "1fr", cell: (i) => i.name },
|
||||
]}
|
||||
onRowClick={onRowClick}
|
||||
empty={<span>nothing</span>}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("DataView", () => {
|
||||
it("renders cards in grid mode", () => {
|
||||
renderView("grid");
|
||||
expect(screen.getByText("card alpha")).toBeTruthy();
|
||||
expect(screen.queryByText("Name")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a header and clickable rows in list mode", () => {
|
||||
const onRowClick = vi.fn();
|
||||
renderView("list", onRowClick);
|
||||
expect(screen.getByText("Name")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("beta"));
|
||||
expect(onRowClick).toHaveBeenCalledWith(items[1]);
|
||||
});
|
||||
|
||||
it("shows the empty state with no items", () => {
|
||||
render(
|
||||
<DataView
|
||||
items={[]}
|
||||
view="grid"
|
||||
getKey={() => ""}
|
||||
renderCard={() => null}
|
||||
columns={{}}
|
||||
listColumns={[]}
|
||||
empty={<span>nothing</span>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("nothing")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import {
|
||||
AddButton,
|
||||
FormFooter,
|
||||
ListRow,
|
||||
ListRowAction,
|
||||
ListRowFolder,
|
||||
PanelList,
|
||||
} from "@/components/list-kit";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("ListRow", () => {
|
||||
it("tints odd rows and marks the selected one", () => {
|
||||
const { container, rerender } = render(<ListRow title="a" stripe={1} />);
|
||||
const row = container.firstElementChild as HTMLElement;
|
||||
expect(row.className).toContain("bg-muted/15");
|
||||
|
||||
rerender(<ListRow title="a" stripe={1} selected />);
|
||||
expect(row.className).toContain("bg-accent-brand/10");
|
||||
expect(row.getAttribute("data-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("clicks and opens with Enter, but tray clicks do not reach the row", () => {
|
||||
const onClick = vi.fn();
|
||||
const onAction = vi.fn();
|
||||
render(
|
||||
<ListRow
|
||||
title="web"
|
||||
onClick={onClick}
|
||||
actions={
|
||||
<ListRowAction label="Edit" onClick={onAction}>
|
||||
<span />
|
||||
</ListRowAction>
|
||||
}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole("button", { name: /web/ });
|
||||
fireEvent.keyDown(row, { key: "Enter" });
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Edit"));
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the tray visible while active", () => {
|
||||
render(
|
||||
<ListRow
|
||||
title="a"
|
||||
active
|
||||
actions={
|
||||
<ListRowAction label="More">
|
||||
<span />
|
||||
</ListRowAction>
|
||||
}
|
||||
/>,
|
||||
);
|
||||
const tray = screen.getByLabelText("More").parentElement as HTMLElement;
|
||||
expect(tray.className).toMatch(/(^| )flex( |$)/);
|
||||
expect(tray.className).not.toContain("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ListRowFolder", () => {
|
||||
it("toggles and shows the empty text when open with no items", () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = render(
|
||||
<ListRowFolder
|
||||
name="Prod"
|
||||
count={0}
|
||||
open={false}
|
||||
onToggle={onToggle}
|
||||
emptyText="Nothing here"
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Prod/ }));
|
||||
expect(onToggle).toHaveBeenCalled();
|
||||
expect(screen.queryByText("Nothing here")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<ListRowFolder
|
||||
name="Prod"
|
||||
count={0}
|
||||
open
|
||||
onToggle={onToggle}
|
||||
emptyText="Nothing here"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Nothing here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PanelList", () => {
|
||||
it("shows the empty slot only without rows", () => {
|
||||
const { rerender } = render(
|
||||
<PanelList empty={<span>empty</span>}>{[false, null]}</PanelList>,
|
||||
);
|
||||
expect(screen.getByText("empty")).toBeTruthy();
|
||||
rerender(
|
||||
<PanelList empty={<span>empty</span>}>
|
||||
<ListRow title="row" />
|
||||
</PanelList>,
|
||||
);
|
||||
expect(screen.queryByText("empty")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddButton", () => {
|
||||
it("defaults its label and hides it when compact", () => {
|
||||
const { rerender } = render(<AddButton />);
|
||||
expect(screen.getByText("common.new")).toBeTruthy();
|
||||
rerender(<AddButton compact label="Add host" />);
|
||||
expect(screen.queryByText("Add host")).toBeNull();
|
||||
expect(screen.getByLabelText("Add host")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormFooter", () => {
|
||||
it("shows the unsaved note, delete and a saving state", () => {
|
||||
const onSave = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const { rerender } = render(
|
||||
<FormFooter
|
||||
dirty
|
||||
onSave={onSave}
|
||||
onCancel={() => {}}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("common.unsavedChanges")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("common.delete"));
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText("common.save"));
|
||||
expect(onSave).toHaveBeenCalled();
|
||||
|
||||
rerender(<FormFooter saving onSave={onSave} />);
|
||||
const saving = screen.getByText("common.saving").closest("button");
|
||||
expect(saving?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a status replace the unsaved note", () => {
|
||||
render(<FormFooter dirty status="2 errors" onSave={() => {}} />);
|
||||
expect(screen.getByText("2 errors")).toBeTruthy();
|
||||
expect(screen.queryByText("common.unsavedChanges")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import {
|
||||
Facts,
|
||||
PanelSearch,
|
||||
PanelShell,
|
||||
Segmented,
|
||||
ViewToggle,
|
||||
} from "@/components/panel-layout";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PanelSearch", () => {
|
||||
it("clears with the button and with Esc", () => {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PanelSearch value="web" onChange={onChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("panel.clearSearch"));
|
||||
expect(onChange).toHaveBeenLastCalledWith("");
|
||||
|
||||
rerender(<PanelSearch value="db" onChange={onChange} />);
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("hides the clear button while empty", () => {
|
||||
render(<PanelSearch value="" onChange={() => {}} />);
|
||||
expect(screen.queryByLabelText("panel.clearSearch")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Segmented", () => {
|
||||
it("marks the active option and reports a pick", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Segmented
|
||||
value="hosts"
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: "hosts", label: "Hosts", count: 3 },
|
||||
{ value: "credentials", label: "Credentials" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const radios = screen.getAllByRole("radio");
|
||||
expect(radios[0].getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(radios[1]);
|
||||
expect(onChange).toHaveBeenCalledWith("credentials");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ViewToggle", () => {
|
||||
it("switches view and density", () => {
|
||||
const onView = vi.fn();
|
||||
const onDensity = vi.fn();
|
||||
render(
|
||||
<ViewToggle
|
||||
view="grid"
|
||||
onView={onView}
|
||||
density="comfortable"
|
||||
onDensity={onDensity}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("panel.listView"));
|
||||
expect(onView).toHaveBeenCalledWith("list");
|
||||
fireEvent.click(screen.getByLabelText("panel.compactRows"));
|
||||
expect(onDensity).toHaveBeenCalledWith("compact");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Facts", () => {
|
||||
it("puts a rule only between facts", () => {
|
||||
const { container } = render(
|
||||
<Facts>
|
||||
<span>one</span>
|
||||
{null}
|
||||
<span>two</span>
|
||||
</Facts>,
|
||||
);
|
||||
expect(container.querySelectorAll("[aria-hidden]")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("separates facts passed inside a fragment", () => {
|
||||
const { container } = render(
|
||||
<Facts>
|
||||
<>
|
||||
<span>Termix</span>
|
||||
{false}
|
||||
<span>v1.0.0</span>
|
||||
<span>Monitoring</span>
|
||||
</>
|
||||
</Facts>,
|
||||
);
|
||||
expect(container.querySelectorAll("[aria-hidden]")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PanelShell", () => {
|
||||
it("draws the header, toolbar and footer", () => {
|
||||
render(
|
||||
<PanelShell
|
||||
title="Docker"
|
||||
status="2 running"
|
||||
toolbar={<span>toolbar</span>}
|
||||
footer={<span>footer</span>}
|
||||
>
|
||||
body
|
||||
</PanelShell>,
|
||||
);
|
||||
expect(screen.getByText("Docker")).toBeTruthy();
|
||||
expect(screen.getByText("2 running")).toBeTruthy();
|
||||
expect(screen.getByText("toolbar")).toBeTruthy();
|
||||
expect(screen.getByText("footer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import {
|
||||
InlineView,
|
||||
PanePrompt,
|
||||
SurfaceScope,
|
||||
useConfirm,
|
||||
type ConfirmOptions,
|
||||
} from "@/components/surface/surface-scope";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Asker({
|
||||
options,
|
||||
onResult,
|
||||
}: {
|
||||
options: ConfirmOptions;
|
||||
onResult: (ok: boolean) => void;
|
||||
}) {
|
||||
const confirm = useConfirm();
|
||||
return (
|
||||
<button onClick={() => void confirm(options).then(onResult)}>ask</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useConfirm", () => {
|
||||
it("resolves true when confirmed with Enter", async () => {
|
||||
const onResult = vi.fn();
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Asker options={{ title: "Delete web-01?" }} onResult={onResult} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("ask"));
|
||||
expect(screen.getByRole("alertdialog")).toBeTruthy();
|
||||
expect(screen.getByText("Delete web-01?")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
});
|
||||
expect(onResult).toHaveBeenCalledWith(true);
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves false on Esc and on Cancel", async () => {
|
||||
const onResult = vi.fn();
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Asker options={{ title: "Remove?" }} onResult={onResult} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("ask"));
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(onResult).toHaveBeenLastCalledWith(false);
|
||||
|
||||
fireEvent.click(screen.getByText("ask"));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("common.cancel"));
|
||||
});
|
||||
expect(onResult).toHaveBeenCalledTimes(2);
|
||||
expect(onResult).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("stops Enter from reaching anything listening underneath", async () => {
|
||||
const underneath = vi.fn();
|
||||
window.addEventListener("keydown", underneath);
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Asker options={{ title: "Close?" }} onResult={() => {}} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("ask"));
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
});
|
||||
expect(underneath).not.toHaveBeenCalled();
|
||||
window.removeEventListener("keydown", underneath);
|
||||
});
|
||||
|
||||
it("uses a neutral confirm button when not destructive", () => {
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Asker
|
||||
options={{ title: "Apply?", destructive: false }}
|
||||
onResult={() => {}}
|
||||
/>
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("ask"));
|
||||
expect(screen.getByText("common.confirm")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
function Editor({ onBeforeClose }: { onBeforeClose?: () => boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [nested, setNested] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>open</button>
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Edit folder"
|
||||
onBeforeClose={onBeforeClose}
|
||||
>
|
||||
<span>folder form</span>
|
||||
<button onClick={() => setNested(true)}>nested</button>
|
||||
<InlineView
|
||||
open={nested}
|
||||
onOpenChange={setNested}
|
||||
title="Pick icon"
|
||||
width="wide"
|
||||
>
|
||||
<span>icon grid</span>
|
||||
</InlineView>
|
||||
</InlineView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("InlineView", () => {
|
||||
it("takes over the surface and reports the widening", () => {
|
||||
const onEditingChange = vi.fn();
|
||||
render(
|
||||
<SurfaceScope onEditingChange={onEditingChange}>
|
||||
<Editor />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
expect(onEditingChange).toHaveBeenLastCalledWith(false);
|
||||
fireEvent.click(screen.getByText("open"));
|
||||
expect(screen.getByText("folder form")).toBeTruthy();
|
||||
expect(onEditingChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
fireEvent.click(screen.getByText("nested"));
|
||||
expect(screen.getByText("icon grid")).toBeTruthy();
|
||||
expect(onEditingChange).toHaveBeenLastCalledWith("wide");
|
||||
});
|
||||
|
||||
it("goes back one view at a time", async () => {
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Editor />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("open"));
|
||||
fireEvent.click(screen.getByText("nested"));
|
||||
const backs = screen.getAllByLabelText("common.back");
|
||||
await act(async () => {
|
||||
fireEvent.click(backs[backs.length - 1]);
|
||||
});
|
||||
expect(screen.queryByText("icon grid")).toBeNull();
|
||||
expect(screen.getByText("folder form")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stays open when the close guard says no", async () => {
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Editor onBeforeClose={() => false} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("open"));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText("common.back"));
|
||||
});
|
||||
expect(screen.getByText("folder form")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PanePrompt", () => {
|
||||
it("dims the pane by default and closes on a backdrop click", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<PanePrompt open title="Title" onCancel={onCancel}>
|
||||
<p>body</p>
|
||||
</PanePrompt>,
|
||||
);
|
||||
const card = screen.getByRole("dialog", { name: "Title" });
|
||||
const layer = card.parentElement!;
|
||||
expect(layer.className).toContain("z-50");
|
||||
fireEvent.mouseDown(card);
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
fireEvent.mouseDown(layer);
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("covers a connecting surface with an opaque layer", () => {
|
||||
render(
|
||||
<PanePrompt
|
||||
open
|
||||
title="Login"
|
||||
layer="connection"
|
||||
backgroundColor="rgb(1, 2, 3)"
|
||||
/>,
|
||||
);
|
||||
const layer = screen.getByRole("dialog", { name: "Login" }).parentElement!;
|
||||
expect(layer.className).toContain("z-500");
|
||||
expect(layer.style.backgroundColor).toBe("rgb(1, 2, 3)");
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
render(<PanePrompt open={false} title="Hidden" />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { TabStrip } from "@/sidebar/HostManagerTabs";
|
||||
import { TabStrip } from "@/components/tab-strip";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
@@ -29,12 +29,12 @@ describe("VersionBadge", () => {
|
||||
expect(link.textContent).toBe(UPDATE_TEXT);
|
||||
});
|
||||
|
||||
it("keeps the yellow update treatment on the link", () => {
|
||||
it("keeps the warning update treatment on the link", () => {
|
||||
render(<VersionBadge status="requires_update" releaseUrl={RELEASE_URL} />);
|
||||
|
||||
const className = screen.getByRole("link").className;
|
||||
expect(className).toContain("bg-yellow-500/20");
|
||||
expect(className).toContain("text-yellow-400");
|
||||
expect(className).toContain("bg-warning/20");
|
||||
expect(className).toContain("text-warning");
|
||||
expect(className).toContain("hover:underline");
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("VersionBadge", () => {
|
||||
|
||||
const className = screen.getByRole("link").className;
|
||||
expect(className).toContain("w-fit");
|
||||
expect(className).toContain("bg-yellow-500/20");
|
||||
expect(className).toContain("bg-warning/20");
|
||||
});
|
||||
|
||||
it("applies the className on the inert span too", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getUiPreferences: vi.fn(),
|
||||
saveUiPreferences: vi.fn(async () => {}),
|
||||
saveUiPreferences: vi.fn(async (_body: unknown) => {}),
|
||||
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
|
||||
}));
|
||||
|
||||
@@ -199,3 +199,64 @@ describe("UiPreferencesProvider", () => {
|
||||
expect(result.current?.preferences.overrides).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboarding state", () => {
|
||||
it("uses preferences handed in by the boot gate without fetching", () => {
|
||||
const initial = { ...defaultUiPreferences(), preset: "advanced" as const };
|
||||
const { result } = renderHook(() => useUiPreferencesContext(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<UiPreferencesProvider initial={initial}>
|
||||
{children}
|
||||
</UiPreferencesProvider>
|
||||
),
|
||||
});
|
||||
expect(result.current?.loaded).toBe(true);
|
||||
expect(result.current?.preferences.preset).toBe("advanced");
|
||||
expect(api.getUiPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("merges seen steps and sends them right away on flushNow, even in local mode", async () => {
|
||||
const { result } = renderHook(() => useUiPreferencesContext(), { wrapper });
|
||||
await waitFor(() => expect(result.current?.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current?.markOnboardingSeen({ welcome: 1, "rd:guacd": 2 });
|
||||
result.current?.markOnboardingSeen(
|
||||
{ "rd:guacd": 1, done: 1 },
|
||||
{ completed: true, skipped: false },
|
||||
);
|
||||
});
|
||||
expect(result.current?.preferences.onboarding.seen).toEqual({
|
||||
welcome: 1,
|
||||
"rd:guacd": 2,
|
||||
done: 1,
|
||||
});
|
||||
expect(result.current?.preferences.onboarding.completedAt).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
await result.current?.flushNow();
|
||||
});
|
||||
expect(api.saveUiPreferences).toHaveBeenCalledTimes(1);
|
||||
const body = api.saveUiPreferences.mock.calls[0][0] as {
|
||||
onboarding: { seen: Record<string, number>; skipped: boolean };
|
||||
};
|
||||
expect(body.onboarding.seen).toEqual({
|
||||
welcome: 1,
|
||||
"rd:guacd": 2,
|
||||
done: 1,
|
||||
});
|
||||
expect(body.onboarding.skipped).toBe(false);
|
||||
});
|
||||
|
||||
it("sends a queued change when the provider unmounts", async () => {
|
||||
const { result, unmount } = renderHook(() => useUiPreferencesContext(), {
|
||||
wrapper,
|
||||
});
|
||||
await waitFor(() => expect(result.current?.loaded).toBe(true));
|
||||
act(() => {
|
||||
result.current?.markOnboardingSeen({ appearance: 1 });
|
||||
});
|
||||
unmount();
|
||||
await waitFor(() => expect(api.saveUiPreferences).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -27,16 +27,17 @@ describe("HostStatusCard", () => {
|
||||
|
||||
const name = screen.getByText(host.name);
|
||||
const ip = screen.getByText(host.ip);
|
||||
const identity = name.parentElement?.parentElement;
|
||||
const row = identity?.parentElement?.parentElement;
|
||||
const metrics = row?.lastElementChild;
|
||||
const row = name.closest("[role=button]");
|
||||
const badge = screen.getByText("hosts.status.offline");
|
||||
|
||||
expect(name.className).toContain("truncate");
|
||||
expect(name.getAttribute("title")).toBe(host.name);
|
||||
expect(ip.className).toContain("truncate");
|
||||
expect(ip.getAttribute("title")).toBe(host.ip);
|
||||
expect(identity?.className).toContain("min-w-0");
|
||||
expect(metrics?.className).toContain("shrink-0");
|
||||
expect(row?.firstElementChild?.nextElementSibling?.className).toContain(
|
||||
"min-w-0",
|
||||
);
|
||||
expect(badge.className).toContain("shrink-0");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mobile = vi.hoisted(() => ({ value: true }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => mobile.value }));
|
||||
vi.mock("@/shell/ActionSlot", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/shell/ActionSlot")>()),
|
||||
ComponentSlot: ({ slotId }: { slotId: string }) => <span>{slotId}</span>,
|
||||
}));
|
||||
|
||||
import { HostStatusCard } from "@/dashboard/DashboardTab";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const host = { id: "1", name: "web", ip: "10.0.0.1", online: true } as Host;
|
||||
|
||||
describe("HostStatusCard on a phone", () => {
|
||||
it("leaves out plugin metrics so the host name has room", () => {
|
||||
mobile.value = true;
|
||||
render(<HostStatusCard hosts={[host]} onOpenTab={() => {}} />);
|
||||
expect(screen.queryByText("dashboard.hostRow")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps them on a wide screen", () => {
|
||||
mobile.value = false;
|
||||
render(<HostStatusCard hosts={[host]} onOpenTab={() => {}} />);
|
||||
expect(screen.getByText("dashboard.hostRow")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { quarterBorders } from "@/dashboard/quarter-borders";
|
||||
|
||||
describe("quarterBorders", () => {
|
||||
it("draws a cross between four cells", () => {
|
||||
expect([0, 1, 2, 3].map((i) => quarterBorders(i, 4))).toEqual([
|
||||
"border-border border-b sm:border-r",
|
||||
"border-border border-b",
|
||||
"border-border border-b sm:border-b-0 sm:border-r",
|
||||
"border-border sm:border-b-0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stretches an odd last cell across the row", () => {
|
||||
expect(quarterBorders(2, 3)).toBe(
|
||||
"border-border sm:border-b-0 sm:col-span-2",
|
||||
);
|
||||
expect(quarterBorders(1, 3)).toBe("border-border border-b");
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ function setViewport(width: number) {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (window as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
|
||||
describe("useIsMobile", () => {
|
||||
@@ -42,4 +43,11 @@ describe("useIsMobile", () => {
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the desktop layout in the desktop app, however narrow", () => {
|
||||
setViewport(500);
|
||||
(window as { electronAPI?: unknown }).electronAPI = { isElectron: true };
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DOCS_PAGES, DOCS_URL, docsUrl } from "@/lib/docs";
|
||||
|
||||
describe("docsUrl", () => {
|
||||
it("builds full links to core docs pages", () => {
|
||||
expect(docsUrl()).toBe(`${DOCS_URL}/`);
|
||||
expect(docsUrl("hosts")).toBe(`${DOCS_URL}/guide/hosts`);
|
||||
expect(docsUrl("portKnocking")).toBe(
|
||||
`${DOCS_URL}/guide/hosts#port-knocking`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps every page a relative path", () => {
|
||||
for (const path of Object.values(DOCS_PAGES)) {
|
||||
expect(path).not.toMatch(/^\/|^https?:/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isEmbeddedAuthFrame,
|
||||
isInElectronWebView,
|
||||
isInMobileWebView,
|
||||
} from "@/lib/embedded-frame";
|
||||
|
||||
type TestWindow = Window & {
|
||||
ReactNativeWebView?: unknown;
|
||||
IS_ELECTRON_WEBVIEW?: boolean;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (window as TestWindow).ReactNativeWebView;
|
||||
delete (window as TestWindow).IS_ELECTRON_WEBVIEW;
|
||||
});
|
||||
|
||||
describe("embedded frame detection", () => {
|
||||
it("is false for a normal browser tab", () => {
|
||||
expect(isInMobileWebView()).toBe(false);
|
||||
expect(isInElectronWebView()).toBe(false);
|
||||
expect(isEmbeddedAuthFrame()).toBe(false);
|
||||
});
|
||||
|
||||
it("spots the mobile app by user agent or bridge", () => {
|
||||
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
||||
"Mozilla/5.0 Termix-Mobile/Android",
|
||||
);
|
||||
expect(isInMobileWebView()).toBe(true);
|
||||
vi.restoreAllMocks();
|
||||
(window as TestWindow).ReactNativeWebView = { postMessage() {} };
|
||||
expect(isInMobileWebView()).toBe(true);
|
||||
expect(isInElectronWebView()).toBe(false);
|
||||
expect(isEmbeddedAuthFrame()).toBe(true);
|
||||
});
|
||||
|
||||
it("spots the desktop link window by flag", () => {
|
||||
(window as TestWindow).IS_ELECTRON_WEBVIEW = true;
|
||||
expect(isInElectronWebView()).toBe(true);
|
||||
expect(isEmbeddedAuthFrame()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hostKeyFingerprint } from "@/lib/host-key-fingerprint";
|
||||
|
||||
describe("hostKeyFingerprint", () => {
|
||||
it("matches ssh-keygen -lf for an ed25519 key", () => {
|
||||
const hex =
|
||||
"0000000b7373682d656432353531390000002068baf48c33774769fc29e545d108119da4c6186dffb0b00fe887316935630379";
|
||||
expect(hostKeyFingerprint(hex)).toBe(
|
||||
"SHA256:4AQQyjAyA8CDb9y/vb1q02wyz6f8tagQ4qK41tmJ/d8",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves values that are not hex alone", () => {
|
||||
expect(hostKeyFingerprint("")).toBe("");
|
||||
expect(hostKeyFingerprint("SHA256:abc")).toBe("SHA256:abc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildIssueUrl,
|
||||
describeEnvironment,
|
||||
normalizeGitHubRepo,
|
||||
reportBetaFeedbackUrl,
|
||||
reportCoreIssueUrl,
|
||||
} from "@/lib/issue-url";
|
||||
|
||||
const CHROME_WIN =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
|
||||
const SAFARI_MAC =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
|
||||
const FIREFOX_LINUX =
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0";
|
||||
|
||||
describe("normalizeGitHubRepo", () => {
|
||||
it("strips .git and trailing slashes", () => {
|
||||
expect(normalizeGitHubRepo(" https://github.com/a/b.git ")).toBe(
|
||||
"https://github.com/a/b",
|
||||
);
|
||||
expect(normalizeGitHubRepo("https://github.com/a/b/")).toBe(
|
||||
"https://github.com/a/b",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects anything that is not a GitHub repo", () => {
|
||||
expect(normalizeGitHubRepo("https://gitlab.com/a/b")).toBeNull();
|
||||
expect(normalizeGitHubRepo("https://github.com/a")).toBeNull();
|
||||
expect(normalizeGitHubRepo(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildIssueUrl", () => {
|
||||
it("sets the template and skips empty fields", () => {
|
||||
const url = new URL(
|
||||
buildIssueUrl("https://github.com/a/b", {
|
||||
template: "bug_report.yml",
|
||||
fields: { "termix-version": "26.10.0", logs: "", other: undefined },
|
||||
}),
|
||||
);
|
||||
expect(url.pathname).toBe("/a/b/issues/new");
|
||||
expect(url.searchParams.get("template")).toBe("bug_report.yml");
|
||||
expect(url.searchParams.get("termix-version")).toBe("26.10.0");
|
||||
expect(url.searchParams.has("logs")).toBe(false);
|
||||
expect(url.searchParams.has("other")).toBe(false);
|
||||
});
|
||||
|
||||
it("encodes values safely", () => {
|
||||
const url = new URL(
|
||||
buildIssueUrl("https://github.com/a/b", {
|
||||
template: "bug_report.yml",
|
||||
fields: { environment: "a & b\nc=d" },
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("environment")).toBe("a & b\nc=d");
|
||||
});
|
||||
|
||||
it("cuts long logs to keep the URL short", () => {
|
||||
const url = buildIssueUrl("https://github.com/a/b", {
|
||||
template: "bug_report.yml",
|
||||
fields: { logs: "x".repeat(20000) },
|
||||
});
|
||||
expect(url.length).toBeLessThanOrEqual(7500);
|
||||
expect(new URL(url).searchParams.get("logs")).toMatch(/\(cut\)$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeEnvironment", () => {
|
||||
it("names the browser and OS on the web", () => {
|
||||
expect(describeEnvironment(CHROME_WIN, false)).toBe(
|
||||
"Web, Chrome 141 on Windows",
|
||||
);
|
||||
expect(describeEnvironment(SAFARI_MAC, false)).toBe(
|
||||
"Web, Safari 18 on macOS",
|
||||
);
|
||||
expect(describeEnvironment(FIREFOX_LINUX, false)).toBe(
|
||||
"Web, Firefox 130 on Linux",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the desktop app", () => {
|
||||
expect(describeEnvironment(CHROME_WIN, true)).toBe(
|
||||
"Desktop app on Windows",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportCoreIssueUrl", () => {
|
||||
it("points at the core repo with the version filled in", () => {
|
||||
const url = new URL(reportCoreIssueUrl("26.10.0"));
|
||||
expect(url.pathname).toBe("/Termix-SSH/Termix/issues/new");
|
||||
expect(url.searchParams.get("template")).toBe("bug_report.yml");
|
||||
expect(url.searchParams.get("termix-version")).toBe("26.10.0");
|
||||
expect(url.searchParams.get("environment")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportBetaFeedbackUrl", () => {
|
||||
it("uses the beta form and lists plugins on a beta", () => {
|
||||
const url = new URL(
|
||||
reportBetaFeedbackUrl("26.11.0-beta.2", [
|
||||
{ id: "docker", version: "1.2.0-beta.1" },
|
||||
{ id: "tunnels", version: "1.1.0-beta.3" },
|
||||
]),
|
||||
);
|
||||
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
|
||||
expect(url.searchParams.get("termix-version")).toBe("26.11.0-beta.2");
|
||||
expect(url.searchParams.get("beta-plugins")).toBe(
|
||||
"docker 1.2.0-beta.1\ntunnels 1.1.0-beta.3",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the plugin list out when none are on a beta", () => {
|
||||
const url = new URL(reportBetaFeedbackUrl("26.11.0-beta.2"));
|
||||
expect(url.searchParams.has("beta-plugins")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,27 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ electron: false }));
|
||||
const axiosMethods = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
postForm: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: { defaults: { baseURL: "http://server:8080/" } },
|
||||
authApi: {
|
||||
defaults: { baseURL: "http://server:8080/" },
|
||||
...axiosMethods,
|
||||
},
|
||||
createRemoteOriginApiInstance: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron: () => state.electron }));
|
||||
vi.mock("@/lib/device-id", () => ({ getDeviceId: () => "device-1" }));
|
||||
vi.mock("@/lib/base-path", () => ({ getBasePath: () => "" }));
|
||||
|
||||
import { pluginFetch } from "@/lib/plugin-transport";
|
||||
import {
|
||||
createPluginApi,
|
||||
pluginFetch,
|
||||
pluginWsUrl,
|
||||
} from "@/lib/plugin-transport";
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response("ok"));
|
||||
|
||||
@@ -61,3 +73,57 @@ describe("pluginFetch", () => {
|
||||
expect(headers.get("authorization")).toBe("Bearer token-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPluginApi", () => {
|
||||
it("puts the plugin's mount point in front of request and form paths", () => {
|
||||
const api = createPluginApi("file-manager");
|
||||
const form = new FormData();
|
||||
api.get("/listFiles");
|
||||
api.postForm("uploadFileStream", form);
|
||||
expect(axiosMethods.get).toHaveBeenCalledWith(
|
||||
"/plugin-api/file-manager/listFiles",
|
||||
);
|
||||
expect(axiosMethods.postForm).toHaveBeenCalledWith(
|
||||
"/plugin-api/file-manager/uploadFileStream",
|
||||
form,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginWsUrl in the browser", () => {
|
||||
it("sends a socket ticket, since the cookie can fail behind a proxy", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ ticket: "ticket-1" })),
|
||||
);
|
||||
|
||||
const target = await pluginWsUrl("ssh-terminal", "/terminal");
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url).toBe("http://server:8080/users/ws-ticket");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.credentials).toBe("include");
|
||||
expect(target?.url).toBe(
|
||||
`ws://${window.location.host}/plugin-ws/ssh-terminal/terminal`,
|
||||
);
|
||||
expect(target?.protocols).toEqual(["termix.jwt.ticket-1"]);
|
||||
});
|
||||
|
||||
it("falls back to the cookie alone for a guest", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 401 }));
|
||||
|
||||
const target = await pluginWsUrl("ssh-terminal", "/terminal");
|
||||
|
||||
expect(target?.protocols).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back when the server is older and has no ticket route", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||
|
||||
const target = await pluginWsUrl("ssh-terminal", "/terminal");
|
||||
|
||||
expect(target?.protocols).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TFunction } from "i18next";
|
||||
import { roleLabel } from "@/lib/role-label";
|
||||
|
||||
const t = ((key: string) => `T(${key})`) as unknown as TFunction;
|
||||
|
||||
describe("roleLabel", () => {
|
||||
it("translates system role keys", () => {
|
||||
expect(roleLabel(t, "rbac.roles.admin")).toBe("T(rbac.roles.admin)");
|
||||
});
|
||||
|
||||
it("keeps custom names and empty values", () => {
|
||||
expect(roleLabel(t, "Ops team")).toBe("Ops team");
|
||||
expect(roleLabel(t, null)).toBeNull();
|
||||
expect(roleLabel(t, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseServerTime } from "@/lib/server-time";
|
||||
|
||||
describe("parseServerTime", () => {
|
||||
it("reads a zone-less SQLite timestamp as UTC", () => {
|
||||
expect(parseServerTime("2026-10-09 23:11:54").toISOString()).toBe(
|
||||
"2026-10-09T23:11:54.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps timestamps that already carry a zone", () => {
|
||||
expect(parseServerTime("2026-10-09T22:05:30.871Z").toISOString()).toBe(
|
||||
"2026-10-09T22:05:30.871Z",
|
||||
);
|
||||
expect(parseServerTime("2026-10-09T23:00:00+02:00").toISOString()).toBe(
|
||||
"2026-10-09T21:00:00.000Z",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { usageBarColor, usageColor } from "@/lib/usage-color";
|
||||
|
||||
describe("usage color ramp", () => {
|
||||
it("uses the brand color below 75", () => {
|
||||
expect(usageColor(10)).toBe("text-accent-brand");
|
||||
expect(usageBarColor(74.9)).toBe("bg-accent-brand");
|
||||
});
|
||||
|
||||
it("warns from 75 and turns destructive from 90", () => {
|
||||
expect(usageColor(75)).toBe("text-warning");
|
||||
expect(usageBarColor(89)).toBe("bg-warning");
|
||||
expect(usageColor(90)).toBe("text-destructive");
|
||||
expect(usageBarColor(100)).toBe("bg-destructive");
|
||||
});
|
||||
|
||||
it("mutes an unknown value", () => {
|
||||
expect(usageColor(null)).toBe("text-muted-foreground");
|
||||
expect(usageBarColor(null)).toBe("bg-muted-foreground/30");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isLoginRequestUrl } from "@/main-axios";
|
||||
|
||||
describe("isLoginRequestUrl", () => {
|
||||
it("knows every sign in request", () => {
|
||||
expect(isLoginRequestUrl("/users/login")).toBe(true);
|
||||
expect(isLoginRequestUrl("/users/totp/verify-login")).toBe(true);
|
||||
expect(isLoginRequestUrl("/users/auth/ldap/verify")).toBe(true);
|
||||
expect(isLoginRequestUrl("/users/auth/second-factor/totp/verify")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves other requests alone", () => {
|
||||
expect(isLoginRequestUrl("/host/db/host")).toBe(false);
|
||||
expect(isLoginRequestUrl("/users/auth/ldap/start")).toBe(false);
|
||||
expect(isLoginRequestUrl(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import {
|
||||
EditorPane,
|
||||
EditorSection,
|
||||
LazySection,
|
||||
useScrollSpy,
|
||||
} from "@/manage/EditorPane";
|
||||
import { Field } from "@/components/form-fields";
|
||||
import { HIDDEN_ATTR } from "@/settings/page-filter";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderEditor() {
|
||||
return render(
|
||||
<EditorPane
|
||||
bands={[
|
||||
{
|
||||
id: "main",
|
||||
label: "Main",
|
||||
items: [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "terminal", label: "Terminal" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<EditorSection id="general" label="General">
|
||||
<Field label="Address">
|
||||
<input />
|
||||
</Field>
|
||||
<Field label="Port">
|
||||
<input />
|
||||
</Field>
|
||||
</EditorSection>
|
||||
<EditorSection id="terminal" label="Terminal">
|
||||
<LazySection>
|
||||
<Field label="Font size">
|
||||
<input />
|
||||
</Field>
|
||||
</LazySection>
|
||||
</EditorSection>
|
||||
</EditorPane>,
|
||||
);
|
||||
}
|
||||
|
||||
const hidden = (el: Element | null) => !!el?.closest(`[${HIDDEN_ATTR}]`);
|
||||
|
||||
describe("EditorPane search", () => {
|
||||
it("filters sections and fields, and hides their nav entries", async () => {
|
||||
renderEditor();
|
||||
fireEvent.change(screen.getByLabelText("manage.searchSettings"), {
|
||||
target: { value: "port" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hidden(screen.getByText("Port"))).toBe(false);
|
||||
expect(hidden(screen.getByText("Address"))).toBe(true);
|
||||
expect(hidden(document.querySelector('[data-section="terminal"]'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Terminal" })).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("mounts lazy sections so their fields can be found", async () => {
|
||||
renderEditor();
|
||||
fireEvent.change(screen.getByLabelText("manage.searchSettings"), {
|
||||
target: { value: "font" },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hidden(screen.getByText("Font size"))).toBe(false);
|
||||
expect(hidden(document.querySelector('[data-section="general"]'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useScrollSpy scrollTo", () => {
|
||||
it("follows a section that moves while the form is still mounting", async () => {
|
||||
const container = document.createElement("div");
|
||||
const section = document.createElement("div");
|
||||
section.dataset.section = "rdp";
|
||||
container.appendChild(section);
|
||||
document.body.appendChild(container);
|
||||
let sectionTop = 300;
|
||||
section.getBoundingClientRect = () => ({ top: sectionTop }) as DOMRect;
|
||||
container.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
|
||||
const calls: number[] = [];
|
||||
container.scrollTo = ((options: ScrollToOptions) => {
|
||||
calls.push(options.top ?? 0);
|
||||
}) as typeof container.scrollTo;
|
||||
|
||||
const ref = { current: container };
|
||||
const { result } = renderHook(() => useScrollSpy(ref, ["general", "rdp"]));
|
||||
act(() => result.current.scrollTo("rdp"));
|
||||
expect(calls).toEqual([292]);
|
||||
|
||||
sectionTop = 900;
|
||||
await waitFor(() => expect(calls).toContain(892));
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { ManageList } from "@/manage/ManageList";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const hosts = [
|
||||
{ id: "1", name: "web-01", ip: "10.0.0.1", folder: "Prod", tags: [] },
|
||||
{ id: "2", name: "db-01", ip: "10.0.0.2", folder: "Prod", tags: ["sql"] },
|
||||
] as unknown as Host[];
|
||||
const credentials = [
|
||||
{ id: "c1", name: "deploy", username: "ci", folder: "" },
|
||||
] as Credential[];
|
||||
|
||||
function renderList(overrides: Partial<Parameters<typeof ManageList>[0]> = {}) {
|
||||
const props = {
|
||||
editing: false,
|
||||
mode: "hosts" as const,
|
||||
onMode: vi.fn(),
|
||||
hosts,
|
||||
credentials,
|
||||
canEditInstanceDefaults: false,
|
||||
isOnline: () => false,
|
||||
selectedKey: null,
|
||||
onPickHost: vi.fn(),
|
||||
onPickCredential: vi.fn(),
|
||||
onPickDefaults: vi.fn(),
|
||||
onAdd: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<ManageList {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("ManageList", () => {
|
||||
it("lists hosts under their folder and picks one", () => {
|
||||
const props = renderList();
|
||||
expect(screen.getByText("Prod")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("db-01"));
|
||||
expect(props.onPickHost).toHaveBeenCalledWith(hosts[1]);
|
||||
});
|
||||
|
||||
it("filters by tags as well as names", () => {
|
||||
renderList();
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "sql" } });
|
||||
expect(screen.queryByText("web-01")).toBeNull();
|
||||
expect(screen.getByText("db-01")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches mode and adds", () => {
|
||||
const props = renderList();
|
||||
fireEvent.click(screen.getByText("nav.credentials"));
|
||||
expect(props.onMode).toHaveBeenCalledWith("credentials");
|
||||
fireEvent.click(screen.getByLabelText("hosts.addHost"));
|
||||
expect(props.onAdd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists levels of defaults", () => {
|
||||
const props = renderList({ mode: "defaults" });
|
||||
fireEvent.click(screen.getByText("hostDefaults.titleUser"));
|
||||
expect(props.onPickDefaults).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "user" }),
|
||||
);
|
||||
expect(screen.getByText("Prod")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultsRows } from "@/manage/defaults-rows";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("defaultsRows", () => {
|
||||
it("lists instance defaults only for those who can edit them", () => {
|
||||
const hosts = [{ folder: "A / B" }, { folder: "" }] as Host[];
|
||||
expect(defaultsRows(hosts, false, t).map((r) => r.key)).toEqual([
|
||||
"user",
|
||||
"folder:A",
|
||||
"folder:A / B",
|
||||
]);
|
||||
expect(defaultsRows(hosts, true, t)[0]).toMatchObject({
|
||||
key: "admin",
|
||||
level: "admin",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateHostForm } from "@/manage/host-validation";
|
||||
|
||||
describe("validateHostForm", () => {
|
||||
it("needs an address", () => {
|
||||
expect(
|
||||
validateHostForm({ ip: " ", sshPort: 22 }, { enableSsh: true }),
|
||||
).toEqual({
|
||||
ip: "manage.errorAddressRequired",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks the SSH port only when SSH is on", () => {
|
||||
expect(
|
||||
validateHostForm({ ip: "h", sshPort: 70000 }, { enableSsh: true }),
|
||||
).toEqual({ sshPort: "manage.errorPortRange" });
|
||||
expect(
|
||||
validateHostForm({ ip: "h", sshPort: 70000 }, { enableSsh: false }),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MANAGE_REQUEST_EVENT,
|
||||
requestFromLegacyEvent,
|
||||
requestManage,
|
||||
resetManageRequests,
|
||||
takePendingManageRequest,
|
||||
} from "@/manage/manage-requests";
|
||||
|
||||
afterEach(resetManageRequests);
|
||||
|
||||
describe("manage requests", () => {
|
||||
it("parks a request until the tab claims it, once", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener(MANAGE_REQUEST_EVENT, listener);
|
||||
requestManage({ kind: "host", hostId: "7" });
|
||||
window.removeEventListener(MANAGE_REQUEST_EVENT, listener);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(takePendingManageRequest()).toEqual({ kind: "host", hostId: "7" });
|
||||
expect(takePendingManageRequest()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps only the latest request", () => {
|
||||
requestManage({ kind: "browse", mode: "credentials" });
|
||||
requestManage({ kind: "credential", credentialId: null });
|
||||
expect(takePendingManageRequest()).toEqual({
|
||||
kind: "credential",
|
||||
credentialId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("translates the old host manager events", () => {
|
||||
expect(requestFromLegacyEvent("host-manager:add-host")).toEqual({
|
||||
kind: "host",
|
||||
hostId: null,
|
||||
draft: undefined,
|
||||
});
|
||||
expect(
|
||||
requestFromLegacyEvent("host-manager:add-host", { name: "web" }),
|
||||
).toEqual({ kind: "host", hostId: null, draft: { name: "web" } });
|
||||
expect(requestFromLegacyEvent("host-manager:edit-host", 12)).toEqual({
|
||||
kind: "host",
|
||||
hostId: "12",
|
||||
});
|
||||
expect(requestFromLegacyEvent("host-manager:edit-host")).toBeNull();
|
||||
expect(requestFromLegacyEvent("host-manager:add-credential")).toEqual({
|
||||
kind: "credential",
|
||||
credentialId: null,
|
||||
});
|
||||
expect(
|
||||
requestFromLegacyEvent("termix:open-host-defaults", { level: "admin" }),
|
||||
).toEqual({ kind: "defaults", level: "admin", folderName: undefined });
|
||||
expect(
|
||||
requestFromLegacyEvent("host-manager:edit-defaults", {
|
||||
level: "folder",
|
||||
folderName: "Prod",
|
||||
}),
|
||||
).toEqual({ kind: "defaults", level: "folder", folderName: "Prod" });
|
||||
expect(requestFromLegacyEvent("something-else")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allFolderPaths,
|
||||
buildCredentialManageTree,
|
||||
buildHostManageTree,
|
||||
flattenTree,
|
||||
} from "@/manage/manage-tree";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
|
||||
function host(id: string, folder: string, extra: Partial<Host> = {}): Host {
|
||||
return {
|
||||
id,
|
||||
name: `host-${id}`,
|
||||
username: "root",
|
||||
ip: `10.0.0.${id}`,
|
||||
port: 22,
|
||||
folder,
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: "",
|
||||
authType: "password",
|
||||
...extra,
|
||||
} as Host;
|
||||
}
|
||||
|
||||
describe("manage tree", () => {
|
||||
it("nests folder paths and sub-hosts", () => {
|
||||
const tree = buildHostManageTree([
|
||||
host("1", "Prod / EU"),
|
||||
host("2", "Prod"),
|
||||
host("3", "", { parentHostId: "2" }),
|
||||
]);
|
||||
expect(allFolderPaths(tree)).toEqual(["Prod", "Prod / EU"]);
|
||||
const prod = tree.folders[0];
|
||||
expect(prod.folders[0].items[0].id).toBe("1");
|
||||
expect(prod.items[0].children[0].id).toBe("3");
|
||||
});
|
||||
|
||||
it("drops a parent loop back to folder placement", () => {
|
||||
const tree = buildHostManageTree([
|
||||
host("1", "", { parentHostId: "2" }),
|
||||
host("2", "", { parentHostId: "1" }),
|
||||
]);
|
||||
const total =
|
||||
tree.items.length + tree.items.flatMap((i) => i.children).length;
|
||||
expect(total).toBe(2);
|
||||
});
|
||||
|
||||
it("uses the online check it is given", () => {
|
||||
const tree = buildHostManageTree([host("1", "")], () => true);
|
||||
expect(tree.items[0].online).toBe(true);
|
||||
});
|
||||
|
||||
it("opens folders holding a search match and hides the rest", () => {
|
||||
const tree = buildHostManageTree([
|
||||
host("1", "Prod / EU", { tags: ["db"] }),
|
||||
host("2", "Staging"),
|
||||
]);
|
||||
const rows = flattenTree(tree, new Set(), "db");
|
||||
expect(
|
||||
rows.map((r) => (r.kind === "folder" ? r.folder.path : r.item.id)),
|
||||
).toEqual(["Prod", "Prod / EU", "1"]);
|
||||
});
|
||||
|
||||
it("respects collapsed folders without a search", () => {
|
||||
const tree = buildHostManageTree([host("1", "Prod")]);
|
||||
expect(flattenTree(tree, new Set(), "")).toHaveLength(1);
|
||||
expect(flattenTree(tree, new Set(["Prod"]), "")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("groups credentials by folder", () => {
|
||||
const tree = buildCredentialManageTree([
|
||||
{ id: "c1", name: "deploy", username: "ci", folder: "Keys" },
|
||||
{ id: "c2", name: "root", username: "root", folder: "" },
|
||||
] as Credential[]);
|
||||
expect(tree.folders[0].items[0].note).toBe("ci");
|
||||
expect(tree.items[0].id).toBe("c2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getUiPreferences: vi.fn(),
|
||||
saveUiPreferences: vi.fn(async () => {}),
|
||||
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
|
||||
}));
|
||||
vi.mock("@/main-axios", () => api);
|
||||
|
||||
const frame = vi.hoisted(() => ({ embedded: false }));
|
||||
vi.mock("@/lib/embedded-frame", () => ({
|
||||
isEmbeddedAuthFrame: () => frame.embedded,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugin-host/plugin-store", () => ({
|
||||
settledPromise: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("@/onboarding/OnboardingStage", () => ({
|
||||
OnboardingStage: ({ mode }: { mode: string }) => <p>stage {mode}</p>,
|
||||
}));
|
||||
|
||||
vi.mock("@/onboarding/onboarding-steps", () => ({
|
||||
CORE_STEPS: [
|
||||
{
|
||||
id: "appearance",
|
||||
version: 1,
|
||||
titleKey: "a",
|
||||
Component: () => null,
|
||||
audience: "all",
|
||||
position: 40,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import {
|
||||
OnboardingHost,
|
||||
OPEN_ONBOARDING_EVENT,
|
||||
} from "@/onboarding/OnboardingHost";
|
||||
import { onboardingSteps } from "@/onboarding/onboarding-registry";
|
||||
import {
|
||||
UiPreferencesProvider,
|
||||
useUiPreferencesContext,
|
||||
} from "@/contexts/UiPreferencesContext";
|
||||
import { setPermissionsForTesting } from "@/hooks/use-permissions";
|
||||
import { defaultUiPreferences } from "@/types/ui-preferences";
|
||||
|
||||
function prefs(extra = {}) {
|
||||
return {
|
||||
...defaultUiPreferences(),
|
||||
onboarding: {
|
||||
seen: { appearance: 1 },
|
||||
completedAt: "2026-01-01T00:00:00.000Z",
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let seen: Record<string, number> = {};
|
||||
function SeenProbe() {
|
||||
seen = useUiPreferencesContext()?.preferences.onboarding.seen ?? {};
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderHost(preferences = prefs()) {
|
||||
render(
|
||||
<UiPreferencesProvider initial={preferences}>
|
||||
<OnboardingHost />
|
||||
<SeenProbe />
|
||||
</UiPreferencesProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const guacd = {
|
||||
id: "rd:guacd",
|
||||
pluginId: "rd",
|
||||
version: 1,
|
||||
titleKey: "g",
|
||||
Component: () => null,
|
||||
audience: "all" as const,
|
||||
position: 50,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
frame.embedded = false;
|
||||
setPermissionsForTesting([]);
|
||||
onboardingSteps.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("OnboardingHost", () => {
|
||||
it("opens a rerun when settings asks for one", async () => {
|
||||
renderHost();
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event(OPEN_ONBOARDING_EVENT));
|
||||
});
|
||||
expect(await screen.findByText("stage rerun")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a partial run when a plugin adds a step mid-session", async () => {
|
||||
renderHost();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(screen.queryByText(/stage/)).toBeNull();
|
||||
act(() => {
|
||||
onboardingSteps.register(guacd);
|
||||
});
|
||||
expect(
|
||||
await screen.findByText("stage partial", {}, { timeout: 2000 }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("marks existing plugin steps seen for a carried over user without showing them", async () => {
|
||||
onboardingSteps.register(guacd);
|
||||
renderHost(prefs({ baselinePending: true }));
|
||||
await waitFor(() => expect(seen["rd:guacd"]).toBe(1));
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
expect(screen.queryByText(/stage/)).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing inside an embedded sign in frame", async () => {
|
||||
frame.embedded = true;
|
||||
onboardingSteps.register(guacd);
|
||||
renderHost();
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event(OPEN_ONBOARDING_EVENT));
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
expect(screen.queryByText(/stage/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import type { OnboardingStepProps } from "@termix-ssh/plugin-sdk/frontend";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getUiPreferences: vi.fn(),
|
||||
saveUiPreferences: vi.fn(async (_body: unknown) => {}),
|
||||
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
|
||||
}));
|
||||
vi.mock("@/main-axios", () => api);
|
||||
|
||||
vi.mock("@/api/plugins-api", () => ({
|
||||
getOnboardingPlugins: vi.fn(async () => ({
|
||||
pending: false,
|
||||
managedByLinkedServer: false,
|
||||
plugins: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const gate = vi.hoisted(() => ({ allow: true }));
|
||||
|
||||
function Plain({ label }: { label: string }) {
|
||||
return <p>{label} body</p>;
|
||||
}
|
||||
|
||||
function Gated({ setBeforeNext }: OnboardingStepProps) {
|
||||
useEffect(() => {
|
||||
setBeforeNext(() => gate.allow);
|
||||
return () => setBeforeNext(null);
|
||||
}, [setBeforeNext]);
|
||||
return <p>picker body</p>;
|
||||
}
|
||||
|
||||
vi.mock("@/onboarding/onboarding-steps", () => ({
|
||||
CORE_STEPS: [
|
||||
{
|
||||
id: "preset",
|
||||
version: 1,
|
||||
titleKey: "preset",
|
||||
Component: () => <Plain label="preset" />,
|
||||
audience: "all",
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
version: 1,
|
||||
titleKey: "picker",
|
||||
Component: Gated,
|
||||
audience: "all",
|
||||
position: 10,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
version: 2,
|
||||
titleKey: "appearance",
|
||||
Component: () => <Plain label="appearance" />,
|
||||
audience: "all",
|
||||
position: 40,
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
version: 1,
|
||||
titleKey: "security",
|
||||
Component: () => <Plain label="security" />,
|
||||
audience: "all",
|
||||
position: 100,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import { OnboardingStage } from "@/onboarding/OnboardingStage";
|
||||
import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext";
|
||||
import { setPermissionsForTesting } from "@/hooks/use-permissions";
|
||||
import { onboardingSteps } from "@/onboarding/onboarding-registry";
|
||||
import {
|
||||
defaultUiPreferences,
|
||||
type UiPreferences,
|
||||
} from "@/types/ui-preferences";
|
||||
|
||||
function renderStage(
|
||||
mode: "full" | "partial" | "rerun",
|
||||
preferences: UiPreferences = defaultUiPreferences(),
|
||||
) {
|
||||
const onDone = vi.fn();
|
||||
render(
|
||||
<UiPreferencesProvider initial={preferences}>
|
||||
<OnboardingStage mode={mode} shellReady={false} onDone={onDone} />
|
||||
</UiPreferencesProvider>,
|
||||
);
|
||||
return onDone;
|
||||
}
|
||||
|
||||
const next = () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: /onboarding.next|Next/ }));
|
||||
|
||||
beforeEach(() => {
|
||||
gate.allow = true;
|
||||
api.saveUiPreferences.mockClear();
|
||||
setPermissionsForTesting([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("OnboardingStage", () => {
|
||||
it("hides Skip until the required step is confirmed, then Skip finishes", async () => {
|
||||
const onDone = renderStage("full");
|
||||
await screen.findByText("preset body");
|
||||
expect(screen.queryByRole("button", { name: /skip/i })).toBeNull();
|
||||
|
||||
next();
|
||||
await screen.findByText("picker body");
|
||||
expect(screen.queryByRole("button", { name: /skip/i })).toBeNull();
|
||||
|
||||
await act(async () => next());
|
||||
await screen.findByText("appearance body");
|
||||
await act(async () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: /skip/i })),
|
||||
);
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
const body = api.saveUiPreferences.mock.calls.at(-1)?.[0] as {
|
||||
onboarding: { skipped: boolean };
|
||||
};
|
||||
expect(body.onboarding.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("stays on a step whose beforeNext says no", async () => {
|
||||
gate.allow = false;
|
||||
renderStage("full");
|
||||
await screen.findByText("preset body");
|
||||
next();
|
||||
await screen.findByText("picker body");
|
||||
await act(async () => next());
|
||||
expect(screen.getByText("picker body")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("marks every step in the run as seen when it finishes", async () => {
|
||||
const onDone = renderStage("full");
|
||||
await screen.findByText("preset body");
|
||||
next();
|
||||
await screen.findByText("picker body");
|
||||
await act(async () => next());
|
||||
await screen.findByText("appearance body");
|
||||
next();
|
||||
await screen.findByText("security body");
|
||||
await act(async () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: /finish|Done/ })),
|
||||
);
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
const body = api.saveUiPreferences.mock.calls.at(-1)?.[0] as {
|
||||
onboarding: { seen: Record<string, number> };
|
||||
};
|
||||
expect(body.onboarding.seen).toEqual({
|
||||
preset: 1,
|
||||
plugins: 1,
|
||||
appearance: 2,
|
||||
security: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("has no sidebar, just the step and the buttons", async () => {
|
||||
renderStage("full");
|
||||
await screen.findByText("preset body");
|
||||
expect(screen.queryByRole("navigation")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves plugin steps out for someone carried over from an older version", async () => {
|
||||
const off = onboardingSteps.register({
|
||||
id: "rd:guacd",
|
||||
pluginId: "rd",
|
||||
version: 1,
|
||||
titleKey: "guacd",
|
||||
Component: () => <Plain label="guacd" />,
|
||||
audience: "all",
|
||||
position: 50,
|
||||
});
|
||||
const preferences = {
|
||||
...defaultUiPreferences(),
|
||||
onboarding: {
|
||||
seen: { preset: 1, plugins: 1, appearance: 1, security: 1 },
|
||||
completedAt: null,
|
||||
skipped: false,
|
||||
baselinePending: true,
|
||||
},
|
||||
};
|
||||
renderStage("partial", preferences);
|
||||
await screen.findByText("appearance body");
|
||||
expect(screen.queryByText("guacd body")).toBeNull();
|
||||
off();
|
||||
});
|
||||
|
||||
it("shows only what someone has not seen on a partial run", async () => {
|
||||
const preferences = {
|
||||
...defaultUiPreferences(),
|
||||
onboarding: {
|
||||
seen: { preset: 1, plugins: 1, appearance: 1, security: 1 },
|
||||
completedAt: "2026-01-01T00:00:00.000Z",
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
},
|
||||
};
|
||||
const onDone = renderStage("partial", preferences);
|
||||
await screen.findByText("appearance body");
|
||||
expect(screen.queryByText("preset body")).toBeNull();
|
||||
await act(async () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: /finish|Done/ })),
|
||||
);
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
baselineSeen,
|
||||
computeFlow,
|
||||
needsFullFlow,
|
||||
seenFor,
|
||||
} from "@/onboarding/onboarding-flow";
|
||||
import {
|
||||
mergeStepDefs,
|
||||
sectionPosition,
|
||||
type OnboardingCtx,
|
||||
type OnboardingStepDef,
|
||||
} from "@/onboarding/onboarding-registry";
|
||||
import { defaultOnboardingState } from "@/types/ui-preferences";
|
||||
|
||||
const Noop = () => null;
|
||||
|
||||
function step(
|
||||
id: string,
|
||||
position: number,
|
||||
extra: Partial<OnboardingStepDef> = {},
|
||||
): OnboardingStepDef {
|
||||
return {
|
||||
id,
|
||||
version: 1,
|
||||
titleKey: id,
|
||||
Component: Noop,
|
||||
audience: "all",
|
||||
position,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function ctx(overrides: Partial<OnboardingCtx> = {}): OnboardingCtx {
|
||||
return {
|
||||
mode: "partial",
|
||||
isAdmin: false,
|
||||
isDesktop: false,
|
||||
has: () => false,
|
||||
pluginSetupPending: false,
|
||||
pluginsManagedElsewhere: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const steps = [
|
||||
step("welcome", 0),
|
||||
step("appearance", 40, { version: 2 }),
|
||||
step("rd:guacd", sectionPosition("setup"), {
|
||||
pluginId: "rd",
|
||||
audience: "admin",
|
||||
}),
|
||||
step("ai:assistant", sectionPosition("explore"), {
|
||||
pluginId: "ai",
|
||||
permission: "ai.use",
|
||||
}),
|
||||
step("done", 100),
|
||||
];
|
||||
|
||||
const ids = (list: OnboardingStepDef[]) => list.map((s) => s.id);
|
||||
|
||||
describe("computeFlow", () => {
|
||||
it("shows every relevant step on a full run", () => {
|
||||
expect(ids(computeFlow(steps, {}, ctx({ mode: "full" })))).toEqual([
|
||||
"welcome",
|
||||
"appearance",
|
||||
"done",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters admin and permission steps", () => {
|
||||
const flow = computeFlow(
|
||||
steps,
|
||||
{},
|
||||
ctx({ mode: "rerun", isAdmin: true, has: (p) => p === "ai.use" }),
|
||||
);
|
||||
expect(ids(flow)).toEqual([
|
||||
"welcome",
|
||||
"appearance",
|
||||
"rd:guacd",
|
||||
"ai:assistant",
|
||||
"done",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows only unseen or bumped steps on a partial run", () => {
|
||||
const flow = computeFlow(
|
||||
steps,
|
||||
{ welcome: 1, appearance: 1, "rd:guacd": 1, done: 1 },
|
||||
ctx({ isAdmin: true, has: () => true }),
|
||||
);
|
||||
expect(ids(flow)).toEqual(["appearance", "ai:assistant"]);
|
||||
});
|
||||
|
||||
it("is empty when everything is seen", () => {
|
||||
expect(
|
||||
computeFlow(
|
||||
steps,
|
||||
{ welcome: 1, appearance: 2, "rd:guacd": 1, done: 1 },
|
||||
ctx({ isAdmin: true }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes a seen step that forces itself pending", () => {
|
||||
const picker = step("plugins", 10, {
|
||||
forcePending: (c) => c.pluginSetupPending,
|
||||
});
|
||||
expect(
|
||||
ids(
|
||||
computeFlow(
|
||||
[picker],
|
||||
{ plugins: 1 },
|
||||
ctx({ pluginSetupPending: true }),
|
||||
),
|
||||
),
|
||||
).toEqual(["plugins"]);
|
||||
expect(computeFlow([picker], { plugins: 1 }, ctx())).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a step whose relevance check throws", () => {
|
||||
const broken = step("x:y", 50, {
|
||||
isRelevant: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
expect(computeFlow([broken], {}, ctx())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seen bookkeeping", () => {
|
||||
it("records every step in a run at its version", () => {
|
||||
expect(seenFor(steps.slice(0, 2))).toEqual({ welcome: 1, appearance: 2 });
|
||||
});
|
||||
|
||||
it("baselines only plugin steps", () => {
|
||||
expect(baselineSeen(steps)).toEqual({ "rd:guacd": 1, "ai:assistant": 1 });
|
||||
});
|
||||
|
||||
it("needs the full run only for someone who never finished one", () => {
|
||||
expect(needsFullFlow(defaultOnboardingState())).toBe(true);
|
||||
expect(
|
||||
needsFullFlow({ ...defaultOnboardingState(), seen: { welcome: 1 } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
needsFullFlow({ ...defaultOnboardingState(), completedAt: "x" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeStepDefs", () => {
|
||||
it("orders by position and lets a registered step win over a legacy one", () => {
|
||||
const legacy = step("ai:assistant", 80, { titleKey: "old" });
|
||||
const registered = step("ai:assistant", 50, { titleKey: "new" });
|
||||
const merged = mergeStepDefs(
|
||||
[step("welcome", 0), step("done", 100)],
|
||||
[registered],
|
||||
[legacy, step("x:legacy", 80)],
|
||||
);
|
||||
expect(merged.map((s) => [s.id, s.titleKey])).toEqual([
|
||||
["welcome", "welcome"],
|
||||
["ai:assistant", "new"],
|
||||
["x:legacy", "x:legacy"],
|
||||
["done", "done"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("places plugin sections between the core steps", () => {
|
||||
expect(sectionPosition("setup")).toBeGreaterThan(40);
|
||||
expect(sectionPosition("setup")).toBeLessThan(60);
|
||||
expect(sectionPosition("explore", 1000)).toBeLessThan(90);
|
||||
expect(sectionPosition("security")).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ONBOARDING_STEPS, relevantSteps } from "@/onboarding/onboarding-steps";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CORE_STEPS } from "@/onboarding/onboarding-steps";
|
||||
import { computeFlow } from "@/onboarding/onboarding-flow";
|
||||
import type { OnboardingCtx } from "@/onboarding/onboarding-registry";
|
||||
import { CORE_ONBOARDING_STEPS } from "@/types/onboarding";
|
||||
import { UI_AREA_KEYS, PRESETS } from "@/types/ui-preferences";
|
||||
import en from "@/locales/en.json";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn(() => false));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
|
||||
function lookup(key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
@@ -18,83 +18,131 @@ function lookup(key: string): unknown {
|
||||
);
|
||||
}
|
||||
|
||||
describe("ONBOARDING_STEPS", () => {
|
||||
it("has unique ids and real title translations", () => {
|
||||
const ids = ONBOARDING_STEPS.map((s) => s.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const step of ONBOARDING_STEPS) {
|
||||
function ctx(overrides: Partial<OnboardingCtx> = {}): OnboardingCtx {
|
||||
return {
|
||||
mode: "full",
|
||||
isAdmin: false,
|
||||
isDesktop: false,
|
||||
has: () => false,
|
||||
pluginSetupPending: false,
|
||||
pluginsManagedElsewhere: false,
|
||||
canEnrollSecondFactor: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ids = (c: OnboardingCtx) =>
|
||||
computeFlow(CORE_STEPS, {}, c).map((s) => s.id);
|
||||
|
||||
describe("CORE_STEPS", () => {
|
||||
it("has unique ids, real titles and the versions in CORE_ONBOARDING_STEPS", () => {
|
||||
const stepIds = CORE_STEPS.map((s) => s.id);
|
||||
expect(new Set(stepIds).size).toBe(stepIds.length);
|
||||
expect(stepIds.sort()).toEqual(Object.keys(CORE_ONBOARDING_STEPS).sort());
|
||||
for (const step of CORE_STEPS) {
|
||||
expect(typeof lookup(step.titleKey), step.titleKey).toBe("string");
|
||||
expect(step.version).toBe(
|
||||
CORE_ONBOARDING_STEPS[step.id as keyof typeof CORE_ONBOARDING_STEPS],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens with the preset picker right after the welcome", () => {
|
||||
expect(ONBOARDING_STEPS[0].id).toBe("welcome");
|
||||
expect(ONBOARDING_STEPS[1].id).toBe("preset");
|
||||
it("is only choices: preset and appearance for everyone", () => {
|
||||
expect(ids(ctx())).toEqual(["preset", "appearance"]);
|
||||
});
|
||||
|
||||
it("has no add-a-host step, which used to close onboarding mid-flow", () => {
|
||||
const ids = relevantSteps({}).map((s) => s.id);
|
||||
expect(ids).not.toContain("first-host");
|
||||
it("opens on the plugin picker for an admin on a fresh install", () => {
|
||||
expect(ids(ctx({ isAdmin: true, pluginSetupPending: true }))).toEqual([
|
||||
"plugins",
|
||||
"preset",
|
||||
"appearance",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never shows the plugin picker to someone who is not an admin", () => {
|
||||
expect(ids(ctx({ pluginSetupPending: true }))).not.toContain("plugins");
|
||||
expect(ids(ctx({ mode: "rerun" }))).not.toContain("plugins");
|
||||
});
|
||||
|
||||
it("leaves the picker out once it was applied, even on a rerun", () => {
|
||||
expect(ids(ctx({ isAdmin: true, mode: "rerun" }))).not.toContain("plugins");
|
||||
expect(ids(ctx({ isAdmin: true }))).not.toContain("plugins");
|
||||
});
|
||||
|
||||
it("never shows the picker on a linked desktop", () => {
|
||||
expect(
|
||||
ids(
|
||||
ctx({
|
||||
isAdmin: true,
|
||||
pluginSetupPending: true,
|
||||
pluginsManagedElsewhere: true,
|
||||
}),
|
||||
),
|
||||
).not.toContain("plugins");
|
||||
});
|
||||
|
||||
it("makes the plugin picker required", () => {
|
||||
expect(CORE_STEPS.find((s) => s.id === "plugins")?.required).toBe(true);
|
||||
});
|
||||
|
||||
it("asks how the desktop app is used only on the desktop", () => {
|
||||
isElectron.mockReturnValue(false);
|
||||
expect(relevantSteps({}).map((s) => s.id)).not.toContain("desktop-sync");
|
||||
isElectron.mockReturnValue(true);
|
||||
expect(relevantSteps({}).map((s) => s.id)).toContain("desktop-sync");
|
||||
isElectron.mockReturnValue(false);
|
||||
expect(ids(ctx({ isDesktop: true }))).toEqual([
|
||||
"desktop-sync",
|
||||
"preset",
|
||||
"appearance",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends on the done step", () => {
|
||||
const ids = relevantSteps({}).map((s) => s.id);
|
||||
expect(ids[ids.length - 1]).toBe("done");
|
||||
it("offers account security only when there is something to set up", () => {
|
||||
expect(ids(ctx({ canEnrollSecondFactor: true }))).toEqual([
|
||||
"preset",
|
||||
"appearance",
|
||||
"security",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tours the feature set after the setup choices", () => {
|
||||
const ids = ONBOARDING_STEPS.map((s) => s.id);
|
||||
for (const id of ["features", "workflow", "security"]) {
|
||||
expect(ids).toContain(id);
|
||||
expect(ids.indexOf(id)).toBeGreaterThan(ids.indexOf("appearance"));
|
||||
}
|
||||
it("skips account security on the desktop, which signs in by itself", () => {
|
||||
expect(
|
||||
ids(ctx({ isDesktop: true, canEnrollSecondFactor: true })),
|
||||
).not.toContain("security");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboarding step body translations", () => {
|
||||
const KEYS: Record<string, string[]> = {
|
||||
welcome: ["hosts", "terminal", "files"],
|
||||
workflow: ["palette", "split", "dock"],
|
||||
security: ["credentials", "twofa", "identity", "sharing"],
|
||||
done: ["settings", "rerun", "docs"],
|
||||
};
|
||||
|
||||
it("has a title and description for every card the steps render", () => {
|
||||
for (const [prefix, keys] of Object.entries(KEYS)) {
|
||||
for (const key of keys) {
|
||||
expect(
|
||||
lookup(`onboarding.${prefix}_${key}`),
|
||||
`${prefix}_${key}`,
|
||||
).toBeTypeOf("string");
|
||||
expect(
|
||||
lookup(`onboarding.${prefix}_${key}_desc`),
|
||||
`${prefix}_${key}_desc`,
|
||||
).toBeTypeOf("string");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has the intro copy each step shows above its cards", () => {
|
||||
describe("onboarding translations", () => {
|
||||
it("has the copy the stage and the steps show", () => {
|
||||
for (const key of [
|
||||
"welcomeIntro",
|
||||
"presetIntro",
|
||||
"appearanceIntro",
|
||||
"featuresIntro",
|
||||
"workflowIntro",
|
||||
"securityIntro",
|
||||
"doneDesc",
|
||||
"pluginsIntro",
|
||||
"pluginsRemoveNote",
|
||||
"pluginsChoice_enabled",
|
||||
"pluginsChoice_disabled",
|
||||
"pluginsChoice_remove",
|
||||
"pluginsConsentTitle",
|
||||
"partialHeading",
|
||||
"setupHeading",
|
||||
"skipPartial",
|
||||
"next",
|
||||
"finish",
|
||||
]) {
|
||||
expect(lookup(`onboarding.${key}`), key).toBeTypeOf("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("no longer carries the feature tour", () => {
|
||||
const keys = Object.keys(
|
||||
(en as Record<string, Record<string, unknown>>).onboarding,
|
||||
);
|
||||
expect(
|
||||
keys.filter((k) => /^(welcome|features|workflow|done)/.test(k)),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses no em dashes", () => {
|
||||
const text = JSON.stringify((en as Record<string, unknown>).onboarding);
|
||||
expect(text).not.toContain("\u2014");
|
||||
});
|
||||
});
|
||||
|
||||
describe("interface settings translations", () => {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultUiPreferences } from "@/types/ui-preferences";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getUiPreferences: vi.fn(),
|
||||
getOnboardingPlugins: vi.fn(),
|
||||
has: vi.fn((_p: string) => false),
|
||||
embedded: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/ui-preferences-api", () => ({
|
||||
getUiPreferences: mocks.getUiPreferences,
|
||||
}));
|
||||
vi.mock("@/api/plugins-api", () => ({
|
||||
getOnboardingPlugins: mocks.getOnboardingPlugins,
|
||||
}));
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
loadPermissionChecker: async () => mocks.has,
|
||||
}));
|
||||
vi.mock("@/lib/embedded-frame", () => ({
|
||||
isEmbeddedAuthFrame: () => mocks.embedded,
|
||||
}));
|
||||
vi.mock("@/onboarding/onboarding-steps", () => ({
|
||||
CORE_STEPS: [
|
||||
{
|
||||
id: "welcome",
|
||||
version: 1,
|
||||
titleKey: "w",
|
||||
Component: () => null,
|
||||
audience: "all",
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
version: 1,
|
||||
titleKey: "p",
|
||||
Component: () => null,
|
||||
audience: "admin",
|
||||
position: 10,
|
||||
isRelevant: (ctx: { pluginSetupPending: boolean }) =>
|
||||
ctx.pluginSetupPending,
|
||||
forcePending: (ctx: { pluginSetupPending: boolean }) =>
|
||||
ctx.pluginSetupPending,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
version: 2,
|
||||
titleKey: "a",
|
||||
Component: () => null,
|
||||
audience: "all",
|
||||
position: 40,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import { resolveStartupOnboarding } from "@/onboarding/startup";
|
||||
import { onboardingSteps } from "@/onboarding/onboarding-registry";
|
||||
|
||||
function prefs(seen: Record<string, number>, extra = {}) {
|
||||
return {
|
||||
...defaultUiPreferences(),
|
||||
onboarding: {
|
||||
seen,
|
||||
completedAt: "2026-01-01T00:00:00.000Z",
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.embedded = false;
|
||||
mocks.has.mockReset();
|
||||
mocks.has.mockReturnValue(false);
|
||||
mocks.getUiPreferences.mockReset();
|
||||
mocks.getOnboardingPlugins.mockReset();
|
||||
mocks.getOnboardingPlugins.mockResolvedValue({
|
||||
pending: false,
|
||||
managedByLinkedServer: false,
|
||||
plugins: [],
|
||||
});
|
||||
onboardingSteps.reset();
|
||||
});
|
||||
|
||||
describe("resolveStartupOnboarding", () => {
|
||||
it("never runs inside the mobile app or a desktop link window", async () => {
|
||||
mocks.embedded = true;
|
||||
expect((await resolveStartupOnboarding()).mode).toBeNull();
|
||||
expect(mocks.getUiPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the full flow for a new user", async () => {
|
||||
mocks.getUiPreferences.mockResolvedValue(defaultUiPreferences());
|
||||
const plan = await resolveStartupOnboarding();
|
||||
expect(plan.mode).toBe("full");
|
||||
expect(plan.preferences).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not ask for the plugin list as a non-admin", async () => {
|
||||
mocks.getUiPreferences.mockResolvedValue(defaultUiPreferences());
|
||||
await resolveStartupOnboarding();
|
||||
expect(mocks.getOnboardingPlugins).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows nothing to someone who has seen everything", async () => {
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, plugins: 1, appearance: 2 }),
|
||||
);
|
||||
expect((await resolveStartupOnboarding()).mode).toBeNull();
|
||||
});
|
||||
|
||||
it("runs a partial flow for a bumped core step", async () => {
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, appearance: 1 }),
|
||||
);
|
||||
expect((await resolveStartupOnboarding()).mode).toBe("partial");
|
||||
});
|
||||
|
||||
it("brings an admin back to the picker while it is still pending", async () => {
|
||||
mocks.has.mockReturnValue(true);
|
||||
mocks.getOnboardingPlugins.mockResolvedValue({
|
||||
pending: true,
|
||||
managedByLinkedServer: false,
|
||||
plugins: [],
|
||||
});
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, plugins: 1, appearance: 2 }),
|
||||
);
|
||||
const plan = await resolveStartupOnboarding();
|
||||
expect(plan.mode).toBe("partial");
|
||||
expect(plan.plugins?.pending).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a failed plugin list as not pending", async () => {
|
||||
mocks.has.mockReturnValue(true);
|
||||
mocks.getOnboardingPlugins.mockRejectedValue(new Error("403"));
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, plugins: 1, appearance: 2 }),
|
||||
);
|
||||
expect((await resolveStartupOnboarding()).mode).toBeNull();
|
||||
});
|
||||
|
||||
it("runs a partial flow for a new plugin step", async () => {
|
||||
onboardingSteps.register({
|
||||
id: "rd:guacd",
|
||||
pluginId: "rd",
|
||||
version: 1,
|
||||
titleKey: "g",
|
||||
Component: () => null,
|
||||
audience: "all",
|
||||
position: 50,
|
||||
});
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, appearance: 2 }),
|
||||
);
|
||||
expect((await resolveStartupOnboarding()).mode).toBe("partial");
|
||||
});
|
||||
|
||||
it("leaves plugin steps to the baseline pass for carried over users", async () => {
|
||||
onboardingSteps.register({
|
||||
id: "rd:guacd",
|
||||
pluginId: "rd",
|
||||
version: 1,
|
||||
titleKey: "g",
|
||||
Component: () => null,
|
||||
audience: "all",
|
||||
position: 50,
|
||||
});
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, appearance: 2 }, { baselinePending: true }),
|
||||
);
|
||||
expect((await resolveStartupOnboarding()).mode).toBeNull();
|
||||
});
|
||||
|
||||
it("gives up quietly when preferences cannot load", async () => {
|
||||
mocks.getUiPreferences.mockRejectedValue(new Error("offline"));
|
||||
expect(await resolveStartupOnboarding()).toEqual({
|
||||
preferences: null,
|
||||
mode: null,
|
||||
plugins: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { OnboardingPluginList } from "@/api/plugins-api";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getOnboardingPlugins: vi.fn(),
|
||||
applyOnboardingPlugins: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/api/plugins-api", () => api);
|
||||
|
||||
const loader = vi.hoisted(() => ({ syncPlugins: vi.fn(async () => {}) }));
|
||||
vi.mock("@/plugin-host/loader", () => loader);
|
||||
|
||||
import { PluginsStep } from "@/onboarding/steps/PluginsStep";
|
||||
import { defaultChoices } from "@/onboarding/plugin-choices";
|
||||
|
||||
function plugin(
|
||||
id: string,
|
||||
extra: Partial<OnboardingPluginList["plugins"][number]> = {},
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
description: `${id} plugin`,
|
||||
icon: null,
|
||||
category: "Terminal",
|
||||
version: "1.0.0",
|
||||
source: "bundled" as const,
|
||||
state: "enabled",
|
||||
dependencies: [],
|
||||
recommended: false,
|
||||
consent: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const LIST: OnboardingPluginList = {
|
||||
pending: true,
|
||||
reason: "fresh",
|
||||
managedByLinkedServer: false,
|
||||
plugins: [
|
||||
plugin("term", { recommended: true }),
|
||||
plugin("base", { category: "Infrastructure" }),
|
||||
plugin("addon", { category: "Infrastructure", dependencies: ["base"] }),
|
||||
plugin("stats", { recommended: true, consent: true, state: "disabled" }),
|
||||
],
|
||||
};
|
||||
|
||||
describe("defaultChoices", () => {
|
||||
it("keeps recommended plugins and removes the rest on a fresh install", () => {
|
||||
expect(defaultChoices(LIST)).toEqual({
|
||||
term: "enabled",
|
||||
base: "remove",
|
||||
addon: "remove",
|
||||
stats: "enabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts from how things are now on an upgraded install", () => {
|
||||
expect(defaultChoices({ ...LIST, reason: "upgrade" })).toEqual({
|
||||
term: "enabled",
|
||||
base: "enabled",
|
||||
addon: "enabled",
|
||||
stats: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts from how things are now on a later run", () => {
|
||||
expect(defaultChoices({ ...LIST, pending: false })).toEqual({
|
||||
term: "enabled",
|
||||
base: "enabled",
|
||||
addon: "enabled",
|
||||
stats: "disabled",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginsStep", () => {
|
||||
let beforeNext: (() => boolean | Promise<boolean>) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeNext = null;
|
||||
api.getOnboardingPlugins.mockResolvedValue(LIST);
|
||||
api.applyOnboardingPlugins.mockReset();
|
||||
loader.syncPlugins.mockClear();
|
||||
});
|
||||
|
||||
async function renderStep() {
|
||||
render(
|
||||
<PluginsStep
|
||||
mode="full"
|
||||
isAdmin
|
||||
isDesktop={false}
|
||||
shellReady={false}
|
||||
setCanContinue={() => {}}
|
||||
setBeforeNext={(fn) => {
|
||||
beforeNext = fn;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("TERM");
|
||||
// findByText resolves on the DOM change, before the effect that hands
|
||||
// over the loaded apply has run. Flush it so beforeNext is never stale.
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
it("groups plugins by category and puts consent plugins in their own section", async () => {
|
||||
await renderStep();
|
||||
expect(screen.getByText("Terminal")).toBeTruthy();
|
||||
expect(screen.getByText("Infrastructure")).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox")).toHaveProperty("checked", true);
|
||||
});
|
||||
|
||||
it("tells an upgrading admin that nothing changes unless they say so", async () => {
|
||||
api.getOnboardingPlugins.mockResolvedValue({ ...LIST, reason: "upgrade" });
|
||||
await renderStep();
|
||||
expect(screen.getByText(/onboarding.pluginsUpgradeNote/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps a dependency the admin kept something for, and says why", async () => {
|
||||
await renderStep();
|
||||
const addon = screen.getByRole("radiogroup", { name: "ADDON" });
|
||||
fireEvent.click(within(addon).getByRole("radio", { name: /enabled|On/ }));
|
||||
expect(
|
||||
await screen.findByText("onboarding.pluginsKeptBecause"),
|
||||
).toBeTruthy();
|
||||
const base = screen.getByRole("radiogroup", { name: "BASE" });
|
||||
expect(
|
||||
within(base)
|
||||
.getByRole("radio", { name: /enabled|On/ })
|
||||
.getAttribute("aria-checked"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("applies the choices on Next and resyncs the plugin runtime", async () => {
|
||||
api.applyOnboardingPlugins.mockResolvedValue({
|
||||
resolved: {},
|
||||
adjustments: [],
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
removed: ["base", "addon"],
|
||||
failed: [],
|
||||
});
|
||||
api.getOnboardingPlugins.mockResolvedValueOnce(LIST).mockResolvedValue({
|
||||
...LIST,
|
||||
pending: false,
|
||||
});
|
||||
await renderStep();
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await beforeNext!();
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(api.applyOnboardingPlugins).toHaveBeenCalledWith({
|
||||
term: "enabled",
|
||||
base: "remove",
|
||||
addon: "remove",
|
||||
stats: "enabled",
|
||||
});
|
||||
expect(loader.syncPlugins).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops once to show failures, then lets the admin move on", async () => {
|
||||
api.applyOnboardingPlugins.mockResolvedValue({
|
||||
resolved: {},
|
||||
adjustments: [],
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
removed: [],
|
||||
failed: [{ id: "base", error: "busy" }],
|
||||
});
|
||||
api.getOnboardingPlugins.mockResolvedValueOnce(LIST).mockResolvedValue({
|
||||
...LIST,
|
||||
pending: false,
|
||||
});
|
||||
await renderStep();
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await beforeNext!();
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(screen.getByText("BASE: busy")).toBeTruthy();
|
||||
await act(async () => {
|
||||
ok = await beforeNext!();
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toHostRecord } from "@/plugin-host/bridge";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
const getPluginUserSettings = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/plugins-api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/api/plugins-api")>()),
|
||||
getPluginUserSettings,
|
||||
}));
|
||||
|
||||
import { pluginHostBridge, toHostRecord } from "@/plugin-host/bridge";
|
||||
import { PLUGIN_SETTINGS_CHANGED_EVENT } from "@/api/plugins-api";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function announce(pluginId: string, scope: string) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(PLUGIN_SETTINGS_CHANGED_EVENT, {
|
||||
detail: { pluginId, scope },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("useSettings", () => {
|
||||
it("reads again when the settings page saves the same plugin and scope", async () => {
|
||||
getPluginUserSettings.mockResolvedValueOnce({ enabled: false });
|
||||
const { result } = renderHook(() =>
|
||||
pluginHostBridge.useSettings("ai", "user"),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.values).toEqual({ enabled: false });
|
||||
|
||||
getPluginUserSettings.mockResolvedValue({ enabled: true });
|
||||
announce("other", "user");
|
||||
announce("ai", "admin");
|
||||
expect(getPluginUserSettings).toHaveBeenCalledTimes(1);
|
||||
|
||||
announce("ai", "user");
|
||||
await waitFor(() =>
|
||||
expect(result.current.values).toEqual({ enabled: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toHostRecord", () => {
|
||||
it("copies only the fields the SDK types", () => {
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Every built plugin frontend in dist/plugins imports through the real loader
|
||||
* and runs activate(app) without an error. Needs `npm run build` first.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { FrontendModule } from "@termix/plugin-sdk/frontend";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
configurePluginLoader,
|
||||
resetPluginLoader,
|
||||
syncPlugins,
|
||||
} from "@/plugin-host/loader";
|
||||
import { getPluginRecord } from "@/plugin-host/plugin-store";
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../../..");
|
||||
const BUILT = path.join(REPO_ROOT, "dist", "plugins");
|
||||
|
||||
function pluginIds(): string[] {
|
||||
const root = path.join(REPO_ROOT, "plugins");
|
||||
return fs
|
||||
.readdirSync(root, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(root, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function manifest(id: string) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(BUILT, id, "manifest.json"), "utf8"),
|
||||
) as {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
icon?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
contributes?: PluginSummary["contributes"];
|
||||
};
|
||||
}
|
||||
|
||||
function frontendFile(id: string): string {
|
||||
return path.join(BUILT, id, "dist", "frontend.js");
|
||||
}
|
||||
|
||||
/** What GET /plugins would answer for a running, enabled plugin. */
|
||||
function summaryFor(id: string): PluginSummary {
|
||||
const m = manifest(id);
|
||||
return {
|
||||
id,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
enabled: true,
|
||||
state: "active",
|
||||
contributes: m.contributes ?? null,
|
||||
icon: m.icon,
|
||||
dependencies: m.dependencies ?? {},
|
||||
optionalDependencies: m.optionalDependencies ?? {},
|
||||
frontend: fs.existsSync(frontendFile(id)),
|
||||
assetVersion: "test",
|
||||
locales: ["en"],
|
||||
};
|
||||
}
|
||||
|
||||
const errors: unknown[][] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const missing = pluginIds().filter(
|
||||
(id) => !fs.existsSync(path.join(BUILT, id, "manifest.json")),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`dist/plugins is missing ${missing.join(", ")}. Run npm run build first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing here talks to a server; a plugin that fetches on activate gets
|
||||
// an empty answer rather than a network error.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("{}", { status: 200 })),
|
||||
);
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => {
|
||||
errors.push(args);
|
||||
});
|
||||
|
||||
configurePluginLoader({
|
||||
fetchPlugins: async () => pluginIds().map(summaryFor),
|
||||
importFrontend: async (summary) =>
|
||||
(await import(
|
||||
/* @vite-ignore */ pathToFileURL(frontendFile(summary.id)).href
|
||||
)) as FrontendModule,
|
||||
loadLocale: async (summary, file) => {
|
||||
if (file !== "en") return null;
|
||||
const locale = path.join(BUILT, summary.id, "locales", "en.json");
|
||||
return fs.existsSync(locale)
|
||||
? JSON.parse(fs.readFileSync(locale, "utf8"))
|
||||
: null;
|
||||
},
|
||||
injectCss: () => null,
|
||||
});
|
||||
|
||||
await syncPlugins();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await resetPluginLoader();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("built plugin frontends", () => {
|
||||
it("activates every plugin that ships a frontend", () => {
|
||||
const notActive = pluginIds()
|
||||
.filter((id) => fs.existsSync(frontendFile(id)))
|
||||
.map((id) => [
|
||||
id,
|
||||
getPluginRecord(id)?.frontend,
|
||||
getPluginRecord(id)?.error,
|
||||
])
|
||||
.filter(([, state]) => state !== "active");
|
||||
expect(notActive).toEqual([]);
|
||||
});
|
||||
|
||||
it("logs no error while importing and activating them", () => {
|
||||
// Axios has no server to reach here; a failed request is the test
|
||||
// environment, not the plugin.
|
||||
const real = errors
|
||||
.map((args) => args.map(String).join(" "))
|
||||
.filter((line) => !/Network Error/.test(line));
|
||||
expect(real).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
renderWithApp,
|
||||
type FrontendPluginModule,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
} from "@termix-ssh/plugin-sdk/testing";
|
||||
import { resetPanels } from "@/shell/panel-registry";
|
||||
import { resetPluginStore } from "@/plugin-host/plugin-store";
|
||||
|
||||
@@ -48,7 +48,7 @@ afterAll(async () => {
|
||||
describe("a CLI-built plugin bundle", () => {
|
||||
it("leaves React and the SDK out of the bundle", () => {
|
||||
expect(bundle).toMatch(/from\s*"react"/);
|
||||
expect(bundle).toMatch(/from\s*"@termix\/plugin-sdk\/frontend"/);
|
||||
expect(bundle).toMatch(/from\s*"@termix-ssh\/plugin-sdk\/frontend"/);
|
||||
expect(bundle).not.toMatch(/react\.production|react\.development/);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation, type TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
useTranslation,
|
||||
type TermixApp,
|
||||
} from "@termix-ssh/plugin-sdk/frontend";
|
||||
|
||||
function Counter() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
useHost,
|
||||
type PluginHostRecord,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
} from "@termix-ssh/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
} from "@termix-ssh/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import {
|
||||
scopeHost,
|
||||
scopeHostArgs,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import type { FrontendModule, TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
FrontendModule,
|
||||
TermixApp,
|
||||
} from "@termix-ssh/plugin-sdk/frontend";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
configurePluginLoader,
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
visibleRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { getTabType, resetTabTypes } from "@/shell/tab-registry";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
function summary(
|
||||
id: string,
|
||||
@@ -260,6 +264,43 @@ describe("plugin loader", () => {
|
||||
expect(isPluginFrontendActive("alpha")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads plugin strings on guest pages", async () => {
|
||||
modules.guest = railPlugin("guest");
|
||||
plugins = [summary("guest", { locales: ["en"] })];
|
||||
configurePluginLoader({
|
||||
fetchPlugins: async () => plugins,
|
||||
importFrontend: async (entry) => modules[entry.id],
|
||||
loadLocale: async () => ({ hello: "Hello guest" }),
|
||||
injectCss: () => null,
|
||||
});
|
||||
|
||||
await startPluginRuntime({ guest: true });
|
||||
await i18n.loadNamespaces("guest");
|
||||
expect(i18n.t("hello", { ns: "guest" })).toBe("Hello guest");
|
||||
});
|
||||
|
||||
it("does not fetch strings for a disabled plugin", async () => {
|
||||
const loadLocale = vi.fn(async () => null);
|
||||
modules.on = railPlugin("on");
|
||||
plugins = [
|
||||
summary("on", { locales: ["en"] }),
|
||||
summary("off", { enabled: false, locales: ["en"] }),
|
||||
];
|
||||
configurePluginLoader({
|
||||
fetchPlugins: async () => plugins,
|
||||
importFrontend: async (entry) => modules[entry.id],
|
||||
loadLocale,
|
||||
injectCss: () => null,
|
||||
});
|
||||
|
||||
await syncPlugins();
|
||||
const loaded = loadLocale.mock.calls.map(
|
||||
(call) => (call as unknown as [PluginSummary])[0].id,
|
||||
);
|
||||
expect(loaded).toContain("on");
|
||||
expect(loaded).not.toContain("off");
|
||||
});
|
||||
|
||||
it("settles even when the plugin list cannot be fetched", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
configurePluginLoader({
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { TermixApp } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix-ssh/plugin-sdk/testing";
|
||||
import { resetActionRegistry } from "@/shell/action-registry";
|
||||
import { resetPluginStore } from "@/plugin-host/plugin-store";
|
||||
import {
|
||||
onboardingSteps,
|
||||
SECTION_POSITION,
|
||||
} from "@/onboarding/onboarding-registry";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
|
||||
const mounted: RenderedPluginApp[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (mounted.length > 0) await mounted.pop()!.deactivate();
|
||||
resetActionRegistry();
|
||||
resetPluginStore();
|
||||
onboardingSteps.reset();
|
||||
});
|
||||
|
||||
const Step = () => <span>step</span>;
|
||||
|
||||
async function mount(activate: (app: TermixApp) => void) {
|
||||
const rendered = await renderWithApp(
|
||||
{ activate: async (app: TermixApp) => activate(app) },
|
||||
{ manifest: { id: "guac" } },
|
||||
);
|
||||
mounted.push(rendered);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
describe("app.registerOnboardingStep", () => {
|
||||
it("keys the step by plugin, namespaces its text and places it by section", async () => {
|
||||
await mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "setup",
|
||||
version: 2,
|
||||
titleKey: "onboarding.title",
|
||||
descriptionKey: "onboarding.desc",
|
||||
component: Step,
|
||||
audience: "admin",
|
||||
section: "setup",
|
||||
permission: "manage",
|
||||
});
|
||||
});
|
||||
const [step] = onboardingSteps.list();
|
||||
expect(step).toMatchObject({
|
||||
id: "guac:setup",
|
||||
pluginId: "guac",
|
||||
version: 2,
|
||||
audience: "admin",
|
||||
titleKey: pluginKey("guac", "onboarding.title"),
|
||||
descriptionKey: pluginKey("guac", "onboarding.desc"),
|
||||
permission: "guac.manage",
|
||||
});
|
||||
expect(Math.floor(step.position)).toBe(SECTION_POSITION.setup);
|
||||
});
|
||||
|
||||
it("defaults to version 1, everyone, and the explore section", async () => {
|
||||
await mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "tour",
|
||||
titleKey: "t",
|
||||
component: Step,
|
||||
});
|
||||
});
|
||||
const [step] = onboardingSteps.list();
|
||||
expect(step.version).toBe(1);
|
||||
expect(step.audience).toBe("all");
|
||||
expect(Math.round(step.position)).toBe(SECTION_POSITION.explore);
|
||||
});
|
||||
|
||||
it("passes only the documented context to when, and survives it throwing", async () => {
|
||||
let seen: unknown;
|
||||
await mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "a",
|
||||
titleKey: "t",
|
||||
component: Step,
|
||||
when: (context) => {
|
||||
seen = context;
|
||||
return context.isDesktop;
|
||||
},
|
||||
});
|
||||
app.registerOnboardingStep({
|
||||
id: "b",
|
||||
titleKey: "t",
|
||||
component: Step,
|
||||
when: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
});
|
||||
const ctx = {
|
||||
mode: "full" as const,
|
||||
isAdmin: true,
|
||||
isDesktop: true,
|
||||
has: () => true,
|
||||
pluginSetupPending: true,
|
||||
pluginsManagedElsewhere: false,
|
||||
};
|
||||
const [a, b] = onboardingSteps.list();
|
||||
expect(a.isRelevant?.(ctx)).toBe(true);
|
||||
expect(seen).toEqual({ isAdmin: true, isDesktop: true, mode: "full" });
|
||||
expect(b.isRelevant?.(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a bad id or version", async () => {
|
||||
await expect(
|
||||
mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "Bad Id",
|
||||
titleKey: "t",
|
||||
component: Step,
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow(/not valid/);
|
||||
await expect(
|
||||
mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "ok",
|
||||
version: 0,
|
||||
titleKey: "t",
|
||||
component: Step,
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow(/version/);
|
||||
});
|
||||
|
||||
it("removes everything when the plugin is turned off", async () => {
|
||||
const rendered = await mount((app) => {
|
||||
app.registerOnboardingStep({ id: "s", titleKey: "t", component: Step });
|
||||
});
|
||||
expect(onboardingSteps.list()).toHaveLength(1);
|
||||
|
||||
mounted.splice(mounted.indexOf(rendered), 1);
|
||||
await rendered.deactivate();
|
||||
expect(onboardingSteps.list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderWithApp onboarding helpers", () => {
|
||||
it("lists and renders a plugin's onboarding step", async () => {
|
||||
const rendered = await mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "check",
|
||||
titleKey: "t",
|
||||
component: ({ setCanContinue, setBeforeNext }) => {
|
||||
setCanContinue(false);
|
||||
setBeforeNext(() => false);
|
||||
return <span>checking</span>;
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(rendered.registered.onboardingSteps()).toEqual(["check"]);
|
||||
const step = rendered.renderOnboardingStep("check");
|
||||
expect(step.element.textContent).toContain("checking");
|
||||
expect(step.canContinue()).toBe(false);
|
||||
expect(await step.next()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
usePermission,
|
||||
useTranslation,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
} from "@termix-ssh/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
} from "@termix-ssh/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { NavigationVisibilityToggles } from "@/sidebar/NavigationVisibilityToggles";
|
||||
import {
|
||||
hostActionsFor,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import type { FrontendModule } from "@termix/plugin-sdk/frontend";
|
||||
import type { FrontendModule } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
configurePluginLoader,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { useTranslation, type TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
useTranslation,
|
||||
type TermixApp,
|
||||
} from "@termix-ssh/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
} from "@termix-ssh/plugin-sdk/testing";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { resetActionRegistry } from "@/shell/action-registry";
|
||||
import { resetPluginStore } from "@/plugin-host/plugin-store";
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
|
||||
import { mergePlugins } from "@/plugins/plugin-model";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const [plugin] = mergePlugins(
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
description: "",
|
||||
author: "Termix",
|
||||
category: "",
|
||||
versions: [],
|
||||
latestVersion: "1.0.0",
|
||||
installed: false,
|
||||
installedVersion: null,
|
||||
updateAvailable: false,
|
||||
addedCapabilities: [],
|
||||
pinnedVersion: null,
|
||||
autoUpdate: false,
|
||||
bundled: false,
|
||||
installCount: null,
|
||||
installCountSource: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
describe("PluginConsentPrompt", () => {
|
||||
it("lists notable capabilities and folds the low risk ones", () => {
|
||||
render(
|
||||
<PluginConsentPrompt
|
||||
request={{
|
||||
plugin,
|
||||
mode: "install",
|
||||
version: "1.0.0",
|
||||
capabilities: ["kv:own", "ssh:connect", "ui:surface"],
|
||||
}}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("plugins.capabilities.ssh:connect.title"),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("plugins.capabilities.kv:own.title")).toBeNull();
|
||||
expect(screen.getByText("plugins.manager.consent.minor")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirms and cancels", () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<PluginConsentPrompt
|
||||
request={{
|
||||
plugin,
|
||||
mode: "update",
|
||||
version: "1.1.0",
|
||||
capabilities: ["notify:send"],
|
||||
}}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("plugins.manager.consent.updateExplain"),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("plugins.manager.consent.agreeUpdate"));
|
||||
fireEvent.click(screen.getByText("common.cancel"));
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders nothing without a request", () => {
|
||||
const { container } = render(
|
||||
<PluginConsentPrompt
|
||||
request={null}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import type { PluginEntry } from "@/plugins/plugin-model";
|
||||
|
||||
const getPluginChangelog = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/plugins-api", () => ({ getPluginChangelog }));
|
||||
|
||||
const { PluginReleaseNotes, PluginVideo } =
|
||||
await import("@/plugins/PluginReleaseNotes");
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
getPluginChangelog.mockReset();
|
||||
});
|
||||
|
||||
async function i18n() {
|
||||
const instance = i18next.createInstance();
|
||||
await instance.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
plugins: {
|
||||
manager: {
|
||||
releaseNotes: "Release notes",
|
||||
installedBadge: "Installed",
|
||||
changeTypes: { added: "Added", fixed: "Fixed" },
|
||||
showOlderReleases: "Older releases ({{count}})",
|
||||
hideOlderReleases: "Hide older releases",
|
||||
showMoreChanges: "Show {{count}} more",
|
||||
videoTitle: "{{name}} video",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
function plugin(overrides: Partial<PluginEntry> = {}): PluginEntry {
|
||||
return {
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
installed: true,
|
||||
version: "1.0.0",
|
||||
versions: [],
|
||||
...overrides,
|
||||
} as PluginEntry;
|
||||
}
|
||||
|
||||
describe("PluginReleaseNotes", () => {
|
||||
it("shows the installed copy's changelog grouped by type", async () => {
|
||||
getPluginChangelog.mockResolvedValue([
|
||||
{
|
||||
version: "1.0.0",
|
||||
changes: [
|
||||
{ type: "added", text: "Runs `docker ps`" },
|
||||
{ type: "fixed", text: "A crash" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
render(
|
||||
<I18nextProvider i18n={await i18n()}>
|
||||
<PluginReleaseNotes plugin={plugin()} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("A crash")).toBeTruthy());
|
||||
expect(getPluginChangelog).toHaveBeenCalledWith("docker");
|
||||
expect(screen.getByText("Added")).toBeTruthy();
|
||||
expect(screen.getByText("docker ps").tagName).toBe("CODE");
|
||||
expect(screen.getByText("Installed")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows only the newest release until older ones are opened", async () => {
|
||||
getPluginChangelog.mockResolvedValue([
|
||||
{ version: "1.1.0", changes: [{ type: "fixed", text: "Newest" }] },
|
||||
{ version: "1.0.0", changes: [{ type: "added", text: "Oldest" }] },
|
||||
]);
|
||||
render(
|
||||
<I18nextProvider i18n={await i18n()}>
|
||||
<PluginReleaseNotes plugin={plugin({ version: "1.1.0" })} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("Newest")).toBeTruthy());
|
||||
expect(screen.queryByText("Oldest")).toBeNull();
|
||||
fireEvent.click(screen.getByText("Older releases (1)"));
|
||||
expect(screen.getByText("Oldest")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("cuts a long release down until it is opened up", async () => {
|
||||
getPluginChangelog.mockResolvedValue([
|
||||
{
|
||||
version: "1.0.0",
|
||||
changes: Array.from({ length: 12 }, (_, i) => ({
|
||||
type: "added",
|
||||
text: `Change ${i}`,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
render(
|
||||
<I18nextProvider i18n={await i18n()}>
|
||||
<PluginReleaseNotes plugin={plugin()} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("Change 7")).toBeTruthy());
|
||||
expect(screen.queryByText("Change 8")).toBeNull();
|
||||
fireEvent.click(screen.getByText("Show 4 more"));
|
||||
expect(screen.getByText("Change 11")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders nothing for a plugin with no versions", async () => {
|
||||
const { container } = render(
|
||||
<I18nextProvider i18n={await i18n()}>
|
||||
<PluginReleaseNotes plugin={plugin({ installed: false })} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
expect(getPluginChangelog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginVideo", () => {
|
||||
it("embeds a YouTube id", async () => {
|
||||
render(
|
||||
<I18nextProvider i18n={await i18n()}>
|
||||
<PluginVideo videoId="dQw4w9WgXcQ" name="Docker" />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
const frame = screen.getByTitle("Docker video") as HTMLIFrameElement;
|
||||
expect(frame.src).toBe(
|
||||
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads nothing without a valid id", async () => {
|
||||
const { container } = render(
|
||||
<I18nextProvider i18n={await i18n()}>
|
||||
<PluginVideo videoId="https://evil.example" name="Docker" />
|
||||
<PluginVideo name="Docker" />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
expect(container.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import en from "@/locales/en.json";
|
||||
import { PluginsTab } from "@/plugins/PluginsTab";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
|
||||
const plugin = {
|
||||
id: "demo",
|
||||
name: "Demo Plugin",
|
||||
description: "Does demo things",
|
||||
version: "1.0.0",
|
||||
latestVersion: "1.0.0",
|
||||
installed: true,
|
||||
inRegistry: true,
|
||||
enabled: true,
|
||||
status: "running",
|
||||
updateAvailable: false,
|
||||
pinnedVersion: null,
|
||||
addedCapabilities: [],
|
||||
capabilities: [],
|
||||
contributes: {},
|
||||
versions: [],
|
||||
installCount: null,
|
||||
installCountSource: null,
|
||||
};
|
||||
|
||||
vi.mock("@/plugins/use-plugins-manager", () => ({
|
||||
usePluginsManager: () => ({
|
||||
plugins: [plugin],
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
refresh: vi.fn(),
|
||||
registryError: null,
|
||||
managedByServer: false,
|
||||
busy: new Set(),
|
||||
consent: null,
|
||||
setConsent: vi.fn(),
|
||||
confirmConsent: vi.fn(),
|
||||
developerMode: false,
|
||||
signedOnly: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
async function renderTab() {
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
resources: { en: { translation: en } },
|
||||
});
|
||||
return render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PluginsTab />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PluginsTab", () => {
|
||||
it("tells people community plugins are coming and links the guide", async () => {
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse));
|
||||
|
||||
expect(
|
||||
screen.getByText(en.plugins.manager.communitySoon, { exact: false }),
|
||||
).toBeTruthy();
|
||||
const link = screen.getByText(en.plugins.manager.communitySubmit);
|
||||
expect(link.getAttribute("href")).toBe(docsUrl("communityRegistry"));
|
||||
});
|
||||
|
||||
it("switches to a table and remembers it", async () => {
|
||||
await renderTab();
|
||||
expect(screen.queryByRole("table")).toBeNull();
|
||||
expect(
|
||||
screen.getByLabelText(en.plugins.manager.columns.title),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(en.panel.listView));
|
||||
|
||||
expect(screen.getByRole("table")).toBeTruthy();
|
||||
expect(screen.getByText(en.plugins.manager.table.version)).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByLabelText(en.plugins.manager.columns.title),
|
||||
).toBeNull();
|
||||
expect(localStorage.getItem("pluginsTab.view")).toBe("list");
|
||||
|
||||
cleanup();
|
||||
await renderTab();
|
||||
expect(screen.getByRole("table")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the saved column count for the grid", async () => {
|
||||
localStorage.setItem("pluginsTab.columns", "5");
|
||||
const { container } = await renderTab();
|
||||
const grid = container.querySelector<HTMLElement>(
|
||||
"[style*='grid-template-columns']",
|
||||
);
|
||||
expect(grid?.style.gridTemplateColumns).toContain("/ 5");
|
||||
});
|
||||
|
||||
it("fits auto columns to the tab width, not the window", async () => {
|
||||
const { container } = await renderTab();
|
||||
const grid = container.querySelector<HTMLElement>(
|
||||
"[style*='grid-template-columns']",
|
||||
);
|
||||
expect(grid?.style.gridTemplateColumns).toContain("auto-fill");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import { CapabilityRow } from "@/plugins/plugin-bits";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("CapabilityRow", () => {
|
||||
it("resolves capability ids that contain a colon", async () => {
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
plugins: {
|
||||
capabilities: {
|
||||
"users:impersonate": {
|
||||
title: "Act as other users",
|
||||
consequence: "It can do anything a user can.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<CapabilityRow capability="users:impersonate" />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Act as other users")).toBeTruthy();
|
||||
expect(screen.getByText("It can do anything a user can.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,485 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginSummary, RegistryPluginEntry } from "@/api/plugins-api";
|
||||
import {
|
||||
betaFeedbackUrl,
|
||||
betaToTry,
|
||||
isPrerelease,
|
||||
categoriesOf,
|
||||
describeContributions,
|
||||
formatBytes,
|
||||
formatCount,
|
||||
groupChanges,
|
||||
matchesQuery,
|
||||
mergeReleaseNotes,
|
||||
mergePlugins,
|
||||
orderByRisk,
|
||||
pluginSource,
|
||||
pluginStatus,
|
||||
reportIssueUrl,
|
||||
requestFeatureUrl,
|
||||
sortPlugins,
|
||||
uploadEntry,
|
||||
youtubeEmbedUrl,
|
||||
} from "@/plugins/plugin-model";
|
||||
|
||||
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
return {
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "active",
|
||||
contributes: null,
|
||||
capabilities: ["kv:own"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function entry(
|
||||
overrides: Partial<RegistryPluginEntry> = {},
|
||||
): RegistryPluginEntry {
|
||||
return {
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
description: "Containers",
|
||||
author: "Termix",
|
||||
category: "Infrastructure",
|
||||
versions: [
|
||||
{
|
||||
version: "1.1.0",
|
||||
compatible: true,
|
||||
capabilities: ["kv:own", "ssh:connect"],
|
||||
publishedAt: "2026-10-02",
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
latestVersion: "1.1.0",
|
||||
installed: true,
|
||||
installedVersion: "1.0.0",
|
||||
updateAvailable: true,
|
||||
addedCapabilities: ["ssh:connect"],
|
||||
pinnedVersion: null,
|
||||
autoUpdate: false,
|
||||
bundled: true,
|
||||
installCount: null,
|
||||
installCountSource: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergePlugins", () => {
|
||||
it("joins an installed plugin with its registry entry", () => {
|
||||
const [row] = mergePlugins([summary()], [entry()]);
|
||||
expect(row).toMatchObject({
|
||||
installed: true,
|
||||
version: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
updateAvailable: true,
|
||||
addedCapabilities: ["ssh:connect"],
|
||||
capabilities: ["kv:own"],
|
||||
category: "Infrastructure",
|
||||
inRegistry: true,
|
||||
status: "running",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists registry plugins that are not installed with the latest capabilities", () => {
|
||||
const rows = mergePlugins(
|
||||
[],
|
||||
[entry({ installed: false, installedVersion: null })],
|
||||
);
|
||||
expect(rows[0]).toMatchObject({
|
||||
installed: false,
|
||||
version: null,
|
||||
status: null,
|
||||
capabilities: ["kv:own", "ssh:connect"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the installed features and falls back to the registry", () => {
|
||||
const registry = entry({ features: ["From registry"] });
|
||||
expect(
|
||||
mergePlugins([summary({ features: ["Installed"] })], [registry])[0]
|
||||
.features,
|
||||
).toEqual(["Installed"]);
|
||||
expect(mergePlugins([summary()], [registry])[0].features).toEqual([
|
||||
"From registry",
|
||||
]);
|
||||
expect(mergePlugins([summary()], null)[0].features).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the installed docs link and falls back to the registry", () => {
|
||||
const registry = entry({ docs: "https://docs.example.com/registry" });
|
||||
expect(
|
||||
mergePlugins(
|
||||
[summary({ docs: "https://docs.example.com/installed" })],
|
||||
[registry],
|
||||
)[0].docs,
|
||||
).toBe("https://docs.example.com/installed");
|
||||
expect(mergePlugins([summary()], [registry])[0].docs).toBe(
|
||||
"https://docs.example.com/registry",
|
||||
);
|
||||
expect(mergePlugins([summary()], null)[0].docs).toBeUndefined();
|
||||
expect(mergePlugins([], [registry])[0].docs).toBe(
|
||||
"https://docs.example.com/registry",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps installed plugins when the registry is unreachable", () => {
|
||||
const rows = mergePlugins([summary()], null);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].inRegistry).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginStatus", () => {
|
||||
it("maps loader and stored states", () => {
|
||||
expect(pluginStatus(summary({ state: "failed" }))).toBe("failed");
|
||||
expect(pluginStatus(summary({ state: "blocked" }))).toBe("blocked");
|
||||
expect(pluginStatus(summary({ enabled: false, state: "disabled" }))).toBe(
|
||||
"disabled",
|
||||
);
|
||||
expect(pluginStatus(summary())).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sorting and filtering", () => {
|
||||
const rows = mergePlugins(
|
||||
[],
|
||||
[
|
||||
entry({ id: "b", name: "Beta", category: "Monitoring" }),
|
||||
entry({
|
||||
id: "a",
|
||||
name: "Alpha",
|
||||
category: "Access",
|
||||
description: "Sign in",
|
||||
features: ["Works with Podman"],
|
||||
versions: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
compatible: true,
|
||||
capabilities: [],
|
||||
publishedAt: "2026-10-05",
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
it("sorts by name, date and category", () => {
|
||||
expect(sortPlugins(rows, "name").map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(sortPlugins(rows, "updated").map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(sortPlugins(rows, "category").map((r) => r.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("searches name, description and id", () => {
|
||||
expect(
|
||||
rows.filter((r) => matchesQuery(r, "sign")).map((r) => r.id),
|
||||
).toEqual(["a"]);
|
||||
expect(
|
||||
rows.filter((r) => matchesQuery(r, "podman")).map((r) => r.id),
|
||||
).toEqual(["a"]);
|
||||
expect(rows.filter((r) => matchesQuery(r, " ")).length).toBe(2);
|
||||
});
|
||||
|
||||
it("collects categories", () => {
|
||||
expect(categoriesOf(rows)).toEqual(["Access", "Monitoring"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpers", () => {
|
||||
it("orders capabilities worst first", () => {
|
||||
expect(orderByRisk(["kv:own", "hosts:write", "credentials:read"])).toEqual([
|
||||
"credentials:read",
|
||||
"hosts:write",
|
||||
"kv:own",
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts contributions", () => {
|
||||
expect(
|
||||
describeContributions({
|
||||
tabs: [{}, {}],
|
||||
settings: { admin: [] },
|
||||
auth: { loginMethods: [{}], secondFactors: [] },
|
||||
panels: [],
|
||||
}),
|
||||
).toEqual([
|
||||
{ kind: "tabs", count: 2 },
|
||||
{ kind: "auth", count: 1 },
|
||||
{ kind: "settings", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats sizes", () => {
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
expect(formatBytes(2048)).toBe("2.0 KB");
|
||||
expect(formatBytes(50 * 1024 * 1024)).toBe("50 MB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("popularity", () => {
|
||||
it("sorts by install count, unknown counts last", () => {
|
||||
const rows = mergePlugins(
|
||||
[],
|
||||
[
|
||||
entry({ id: "a", name: "A", installed: false, installCount: 5 }),
|
||||
entry({ id: "b", name: "B", installed: false, installCount: null }),
|
||||
entry({ id: "c", name: "C", installed: false, installCount: 90 }),
|
||||
],
|
||||
);
|
||||
expect(sortPlugins(rows, "popular").map((r) => r.id)).toEqual([
|
||||
"c",
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats counts roughly", () => {
|
||||
expect(formatCount(950)).toBe("950");
|
||||
expect(formatCount(1234)).toBe("1.2k");
|
||||
expect(formatCount(45_000)).toBe("45k");
|
||||
expect(formatCount(2_500_000)).toBe("2.5M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("source and trust", () => {
|
||||
it("tells bundled, signed and unverified copies apart", () => {
|
||||
expect(pluginSource(summary({ source: "bundled", tier: "bundled" }))).toBe(
|
||||
"bundled",
|
||||
);
|
||||
expect(
|
||||
pluginSource(
|
||||
summary({ source: "user", tier: "official", signedBy: "k" }),
|
||||
),
|
||||
).toBe("official");
|
||||
expect(pluginSource(summary({ source: "user", tier: "community" }))).toBe(
|
||||
"unverified",
|
||||
);
|
||||
expect(
|
||||
pluginSource(
|
||||
summary({ source: "user", tier: "unverified", signedBy: "k" }),
|
||||
),
|
||||
).toBe("unverified");
|
||||
});
|
||||
|
||||
it("marks an upload as unverified", () => {
|
||||
const row = uploadEntry({
|
||||
token: "t",
|
||||
id: "notes",
|
||||
name: "Notes",
|
||||
version: "0.2.0",
|
||||
description: "",
|
||||
author: "me",
|
||||
capabilities: ["kv:own"],
|
||||
replaces: "0.1.0",
|
||||
});
|
||||
expect(row).toMatchObject({
|
||||
unverified: true,
|
||||
installed: true,
|
||||
version: "0.1.0",
|
||||
latestVersion: "0.2.0",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportIssueUrl", () => {
|
||||
const base = {
|
||||
id: "docker",
|
||||
repository: "https://github.com/Termix-SSH/Plugin-Docker.git",
|
||||
version: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
status: "failed" as const,
|
||||
source: "official" as const,
|
||||
lastError: "boom",
|
||||
};
|
||||
|
||||
it("fills the bug form on an official plugin repo", () => {
|
||||
const parsed = new URL(reportIssueUrl(base, "26.10.0")!);
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
"https://github.com/Termix-SSH/Plugin-Docker/issues/new",
|
||||
);
|
||||
expect(parsed.searchParams.get("template")).toBe("bug_report.yml");
|
||||
expect(parsed.searchParams.get("termix-version")).toBe("26.10.0");
|
||||
expect(parsed.searchParams.get("plugin-version")).toBe("1.0.0");
|
||||
expect(parsed.searchParams.get("environment")).toContain(
|
||||
"Plugin docker, official, failed",
|
||||
);
|
||||
expect(parsed.searchParams.get("logs")).toBe("boom");
|
||||
expect(parsed.searchParams.has("body")).toBe(false);
|
||||
});
|
||||
|
||||
it("fills the same bug form on an unverified plugin repo", () => {
|
||||
const parsed = new URL(
|
||||
reportIssueUrl({ ...base, source: "unverified" }, "26.10.0")!,
|
||||
);
|
||||
expect(parsed.searchParams.get("template")).toBe("bug_report.yml");
|
||||
expect(parsed.searchParams.get("plugin-version")).toBe("1.0.0");
|
||||
expect(parsed.searchParams.get("environment")).toContain(
|
||||
"Plugin docker, unverified, failed",
|
||||
);
|
||||
expect(parsed.searchParams.has("body")).toBe(false);
|
||||
});
|
||||
|
||||
it("gives nothing for a repo that is not on GitHub", () => {
|
||||
expect(
|
||||
reportIssueUrl({
|
||||
...base,
|
||||
repository: "https://gitlab.com/a/b",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestFeatureUrl", () => {
|
||||
it("opens the feature form on the plugin repo", () => {
|
||||
const parsed = new URL(
|
||||
requestFeatureUrl({
|
||||
repository: "https://github.com/Termix-SSH/Plugin-Docker/",
|
||||
})!,
|
||||
);
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
"https://github.com/Termix-SSH/Plugin-Docker/issues/new",
|
||||
);
|
||||
expect(parsed.searchParams.get("template")).toBe("feature_request.yml");
|
||||
});
|
||||
|
||||
it("gives nothing for a repo that is not on GitHub", () => {
|
||||
expect(
|
||||
requestFeatureUrl({ repository: "https://gitlab.com/a/b" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeReleaseNotes", () => {
|
||||
const registryVersion = (version: string, extra = {}) => ({
|
||||
version,
|
||||
compatible: true,
|
||||
capabilities: [],
|
||||
size: 1,
|
||||
publishedAt: "2026-10-01T00:00:00Z",
|
||||
...extra,
|
||||
});
|
||||
|
||||
it("joins registry and local notes newest first, local winning", () => {
|
||||
const rows = mergeReleaseNotes(
|
||||
[
|
||||
registryVersion("1.2.0", {
|
||||
compatible: false,
|
||||
notes: { changes: [{ type: "added", text: "Registry" }] },
|
||||
}),
|
||||
registryVersion("1.0.0", {
|
||||
releaseNotesUrl: "https://github.com/x/y/releases/tag/v1.0.0",
|
||||
notes: { changes: [{ type: "added", text: "Old" }] },
|
||||
}),
|
||||
],
|
||||
[
|
||||
{
|
||||
version: "1.0.0",
|
||||
date: "2026-09-01",
|
||||
summary: "First.",
|
||||
changes: [{ type: "added", text: "Local" }],
|
||||
},
|
||||
{ version: "0.9.0", changes: [{ type: "fixed", text: "Beta" }] },
|
||||
],
|
||||
);
|
||||
expect(rows.map((r) => r.version)).toEqual(["1.2.0", "1.0.0", "0.9.0"]);
|
||||
expect(rows[0]).toMatchObject({ compatible: false });
|
||||
expect(rows[1]).toEqual({
|
||||
version: "1.0.0",
|
||||
date: "2026-09-01",
|
||||
summary: "First.",
|
||||
changes: [{ type: "added", text: "Local" }],
|
||||
compatible: true,
|
||||
releaseNotesUrl: "https://github.com/x/y/releases/tag/v1.0.0",
|
||||
});
|
||||
expect(rows[2]).toMatchObject({ date: null, compatible: true });
|
||||
});
|
||||
|
||||
it("is empty with nothing to show", () => {
|
||||
expect(mergeReleaseNotes([], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupChanges", () => {
|
||||
it("groups by type in a fixed order", () => {
|
||||
expect(
|
||||
groupChanges([
|
||||
{ type: "fixed", text: "b" },
|
||||
{ type: "added", text: "a" },
|
||||
{ type: "fixed", text: "c" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "added", items: ["a"] },
|
||||
{ type: "fixed", items: ["b", "c"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("youtubeEmbedUrl", () => {
|
||||
it("builds a privacy embed from a video id only", () => {
|
||||
expect(youtubeEmbedUrl("dQw4w9WgXcQ")).toBe(
|
||||
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses anything that is not an id", () => {
|
||||
for (const value of [
|
||||
undefined,
|
||||
"",
|
||||
"https://evil.example/x",
|
||||
"dQw4w9WgXcQ/../../x",
|
||||
"javascript:alert(1)",
|
||||
]) {
|
||||
expect(youtubeEmbedUrl(value)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("betas", () => {
|
||||
it("knows a prerelease", () => {
|
||||
expect(isPrerelease("1.2.0-beta.1")).toBe(true);
|
||||
expect(isPrerelease("1.2.0")).toBe(false);
|
||||
expect(isPrerelease(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers a beta only when newer than what is installed on stable", () => {
|
||||
const base = {
|
||||
installed: true,
|
||||
version: "1.1.0",
|
||||
latestBeta: "1.2.0-beta.1",
|
||||
channel: "stable" as const,
|
||||
};
|
||||
expect(betaToTry(base)).toBe("1.2.0-beta.1");
|
||||
expect(betaToTry({ ...base, channel: "beta" })).toBeNull();
|
||||
expect(betaToTry({ ...base, version: "1.2.0-beta.1" })).toBeNull();
|
||||
expect(betaToTry({ ...base, installed: false })).toBeNull();
|
||||
expect(betaToTry({ ...base, latestBeta: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("sends beta feedback to the plugin's repo", () => {
|
||||
const url = new URL(
|
||||
betaFeedbackUrl(
|
||||
{
|
||||
id: "docker",
|
||||
repository: "https://github.com/Termix-SSH/Plugin-Docker",
|
||||
version: "1.2.0-beta.1",
|
||||
lastError: null,
|
||||
},
|
||||
"26.11.0",
|
||||
)!,
|
||||
);
|
||||
expect(url.pathname).toBe("/Termix-SSH/Plugin-Docker/issues/new");
|
||||
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
|
||||
expect(url.searchParams.get("plugin-version")).toBe("1.2.0-beta.1");
|
||||
expect(
|
||||
betaFeedbackUrl({
|
||||
id: "x",
|
||||
repository: "https://gitlab.com/a/b",
|
||||
version: "1.0.0",
|
||||
lastError: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -106,6 +106,22 @@ describe("useFeatureSettings", () => {
|
||||
await waitFor(() => expect(api.getPlugins).toHaveBeenCalled());
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it("picks up a feature enabled while it is open", async () => {
|
||||
api.getPlugins.mockResolvedValue([
|
||||
plugin({ id: "late", name: "Late", enabled: false }),
|
||||
]);
|
||||
const { result } = renderHook(() => useFeatureSettings("user"));
|
||||
await waitFor(() => expect(api.getPlugins).toHaveBeenCalledTimes(1));
|
||||
expect(result.current).toEqual([]);
|
||||
|
||||
api.getPlugins.mockResolvedValue([plugin({ id: "late", name: "Late" })]);
|
||||
window.dispatchEvent(new CustomEvent("termix:plugins-changed"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.map((p) => p.id)).toEqual(["late"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeatureSettingsSection", () => {
|
||||
@@ -155,11 +171,7 @@ describe("FeatureSettingsSection", () => {
|
||||
const save = screen.getByText("common.save").closest("button")!;
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b !== save && b.textContent === "")!,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("switch"));
|
||||
fireEvent.click(save);
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
+13
-4
@@ -9,7 +9,16 @@ vi.mock("@/api/open-tabs-api", async (importOriginal) => ({
|
||||
}));
|
||||
vi.mock("@/main-axios", () => ({ saveUserPreferences: vi.fn() }));
|
||||
|
||||
const { KeybindingsDialog } = await import("../../sidebar/KeybindingsDialog");
|
||||
const { KeybindingsSettings } = await import("@/settings/KeybindingsSettings");
|
||||
const { SurfaceScope } = await import("@/components/surface/surface-scope");
|
||||
|
||||
function renderSettings() {
|
||||
return render(
|
||||
<SurfaceScope>
|
||||
<KeybindingsSettings />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
}
|
||||
const {
|
||||
registerKeybindingAction,
|
||||
registerKeybindingDefault,
|
||||
@@ -31,7 +40,7 @@ const combo = {
|
||||
meta: false,
|
||||
};
|
||||
|
||||
describe("KeybindingsDialog", () => {
|
||||
describe("KeybindingsSettings", () => {
|
||||
it("lists registered defaults and keeps a binding whose plugin is off", async () => {
|
||||
registerKeybindingDefault({
|
||||
id: "default-x",
|
||||
@@ -49,7 +58,7 @@ describe("KeybindingsDialog", () => {
|
||||
updatedAt: "",
|
||||
},
|
||||
]);
|
||||
render(<KeybindingsDialog open onOpenChange={() => {}} />);
|
||||
renderSettings();
|
||||
expect(await screen.findByText("sample:builtInX")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText("newUi.sidebar.keybindings.unavailableAction"),
|
||||
@@ -64,7 +73,7 @@ describe("KeybindingsDialog", () => {
|
||||
scope: "session",
|
||||
editor: () => <span>sample editor</span>,
|
||||
});
|
||||
render(<KeybindingsDialog open onOpenChange={() => {}} />);
|
||||
renderSettings();
|
||||
fireEvent.click(
|
||||
await screen.findByText("newUi.sidebar.keybindings.addBinding"),
|
||||
);
|
||||
@@ -55,7 +55,7 @@ describe("field types", () => {
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button");
|
||||
const toggle = screen.getByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("enabled", true);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
|
||||
const prefsApi = vi.hoisted(() => ({
|
||||
getUserPreferences: vi.fn(),
|
||||
saveUserPreferences: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/api/open-tabs-api", () => prefsApi);
|
||||
|
||||
const theme = vi.hoisted(() => ({ value: "dark", setTheme: vi.fn() }));
|
||||
vi.mock("@/components/theme-provider", () => ({
|
||||
useTheme: () => ({ theme: theme.value, setTheme: theme.setTheme }),
|
||||
}));
|
||||
|
||||
import {
|
||||
AccentPicker,
|
||||
isHexColor,
|
||||
ThemeGrid,
|
||||
THEMES,
|
||||
useAppearanceSettings,
|
||||
} from "@/settings/appearance/appearance-controls";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
prefsApi.getUserPreferences.mockReset();
|
||||
prefsApi.saveUserPreferences.mockClear();
|
||||
theme.setTheme.mockClear();
|
||||
});
|
||||
|
||||
describe("isHexColor", () => {
|
||||
it("accepts 3 and 6 digit hex only", () => {
|
||||
expect(isHexColor("#fff")).toBe(true);
|
||||
expect(isHexColor("#f59145")).toBe(true);
|
||||
expect(isHexColor("f59145")).toBe(false);
|
||||
expect(isHexColor("#f5914")).toBe(false);
|
||||
expect(isHexColor("red")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAppearanceSettings", () => {
|
||||
it("saves to the account only in cloud mode", async () => {
|
||||
prefsApi.getUserPreferences.mockResolvedValue({ storageMode: "local" });
|
||||
const { result } = renderHook(() => useAppearanceSettings());
|
||||
await act(async () => {
|
||||
result.current.setAccent("#123456");
|
||||
});
|
||||
expect(localStorage.getItem("termix-accent")).toBe("#123456");
|
||||
expect(prefsApi.saveUserPreferences).not.toHaveBeenCalled();
|
||||
|
||||
prefsApi.getUserPreferences.mockResolvedValue({ storageMode: "cloud" });
|
||||
await act(async () => {
|
||||
result.current.setTheme("nord");
|
||||
result.current.setFontSize("lg");
|
||||
});
|
||||
expect(theme.setTheme).toHaveBeenCalledWith("nord");
|
||||
expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({
|
||||
theme: "nord",
|
||||
});
|
||||
expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({
|
||||
fontSize: "lg",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThemeGrid", () => {
|
||||
it("offers every theme and reports the pick", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ThemeGrid value="dark" onChange={onChange} />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(THEMES.length);
|
||||
fireEvent.click(buttons[THEMES.findIndex((t) => t.id === "gruvbox")]);
|
||||
expect(onChange).toHaveBeenCalledWith("gruvbox");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccentPicker", () => {
|
||||
it("takes a typed hex on Enter and ignores anything else", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AccentPicker value="#f59145" onChange={onChange} />);
|
||||
const input = screen.getByPlaceholderText("#f97316");
|
||||
fireEvent.change(input, { target: { value: "nope" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.change(input, { target: { value: "#abcdef" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith("#abcdef");
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import { withPluginScope } from "@/plugin-host/scope";
|
||||
|
||||
const getPlugins = vi.fn();
|
||||
vi.mock("@/api/plugins-api", () => ({
|
||||
PLUGINS_CHANGED_EVENT: "termix:plugins-changed",
|
||||
getPlugins: () => getPlugins(),
|
||||
}));
|
||||
|
||||
@@ -297,7 +298,7 @@ describe("HostPluginSections", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button")[0]);
|
||||
fireEvent.click(screen.getAllByRole("switch")[0]);
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true);
|
||||
});
|
||||
@@ -373,7 +374,7 @@ describe("HostFeatureFields", () => {
|
||||
expect(screen.getAllByText("Docker")).toHaveLength(1);
|
||||
expect(screen.queryByText("Warpgate")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button")[0]);
|
||||
fireEvent.click(screen.getAllByRole("switch")[0]);
|
||||
const patch = updateForm.mock.calls[0][0];
|
||||
expect(patch(form).pluginSettings).toEqual({
|
||||
docker: { enableDocker: true },
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterSettingsPage, HIDDEN_ATTR } from "@/settings/page-filter";
|
||||
|
||||
function page() {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<div class="bg-card" id="theme">
|
||||
<span>Appearance</span>
|
||||
<div data-setting-row="" id="font">Font size</div>
|
||||
<div data-setting-row="" id="accent">Accent color</div>
|
||||
</div>
|
||||
<div class="bg-card" id="account"><span>Account</span><div data-setting-row="" id="name">Username</div></div>
|
||||
<div class="hidden"><div class="bg-card" id="admin">Font policy</div></div>
|
||||
`;
|
||||
return root;
|
||||
}
|
||||
|
||||
const hidden = (root: HTMLElement, id: string) =>
|
||||
root.querySelector(`#${id}`)!.hasAttribute(HIDDEN_ATTR);
|
||||
|
||||
describe("filterSettingsPage", () => {
|
||||
it("keeps only matching cards and rows", () => {
|
||||
const root = page();
|
||||
expect(filterSettingsPage(root, "font")).toBe(1);
|
||||
expect(hidden(root, "theme")).toBe(false);
|
||||
expect(hidden(root, "font")).toBe(false);
|
||||
expect(hidden(root, "accent")).toBe(true);
|
||||
expect(hidden(root, "account")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps every row when only the card title matches", () => {
|
||||
const root = page();
|
||||
filterSettingsPage(root, "appearance");
|
||||
expect(hidden(root, "font")).toBe(false);
|
||||
expect(hidden(root, "accent")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores cards in hidden panels and clears on an empty query", () => {
|
||||
const root = page();
|
||||
expect(filterSettingsPage(root, "policy")).toBe(0);
|
||||
expect(filterSettingsPage(root, "")).toBe(2);
|
||||
expect(root.querySelectorAll(`[${HIDDEN_ATTR}]`).length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasSettingsPage,
|
||||
hasVisibleFields,
|
||||
} from "@/settings/settings-fields-util";
|
||||
import type { PluginSettingsField } from "@/api/plugins-api";
|
||||
|
||||
const field = (hidden = false) =>
|
||||
({ key: "a", type: "string", hidden }) as unknown as PluginSettingsField;
|
||||
|
||||
describe("hasVisibleFields", () => {
|
||||
it("ignores hidden fields", () => {
|
||||
expect(hasVisibleFields([field(true)])).toBe(false);
|
||||
expect(hasVisibleFields([field(true), field()])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSettingsPage", () => {
|
||||
it("needs an enabled plugin", () => {
|
||||
expect(
|
||||
hasSettingsPage(
|
||||
{ enabled: false, contributes: { settings: { user: [field()] } } },
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false with no settings or only hidden ones", () => {
|
||||
expect(hasSettingsPage({ enabled: true, contributes: null }, true)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
hasSettingsPage(
|
||||
{ enabled: true, contributes: { settings: { user: [field(true)] } } },
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("counts admin settings only for admins", () => {
|
||||
const plugin = {
|
||||
enabled: true,
|
||||
contributes: { settings: { admin: [field()] } },
|
||||
};
|
||||
expect(hasSettingsPage(plugin, true)).toBe(true);
|
||||
expect(hasSettingsPage(plugin, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("counts user settings for anyone", () => {
|
||||
expect(
|
||||
hasSettingsPage(
|
||||
{ enabled: true, contributes: { settings: { user: [field()] } } },
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import en from "@/locales/en.json";
|
||||
import {
|
||||
CORE_SETTINGS_PAGES,
|
||||
searchSettingsPages,
|
||||
visibleSettingsPages,
|
||||
} from "@/settings/settings-pages";
|
||||
import { SETTINGS_SEARCH_INDEX } from "@/settings/settings-search-index";
|
||||
|
||||
function translate(key: string): string {
|
||||
let node: unknown = en;
|
||||
for (const part of key.split(".")) {
|
||||
node = (node as Record<string, unknown> | undefined)?.[part];
|
||||
}
|
||||
return typeof node === "string" ? node : key;
|
||||
}
|
||||
|
||||
describe("settings pages", () => {
|
||||
it("hides admin pages from users and browser-only pages on desktop", () => {
|
||||
const user = visibleSettingsPages(CORE_SETTINGS_PAGES, {
|
||||
isAdmin: false,
|
||||
isElectron: true,
|
||||
}).map((page) => page.id);
|
||||
expect(user).not.toContain("admin-users");
|
||||
expect(user).not.toContain("security");
|
||||
expect(user).toContain("account");
|
||||
|
||||
const admin = visibleSettingsPages(CORE_SETTINGS_PAGES, {
|
||||
isAdmin: true,
|
||||
isElectron: false,
|
||||
}).map((page) => page.id);
|
||||
expect(admin).toContain("admin-users");
|
||||
expect(admin).toContain("security");
|
||||
});
|
||||
|
||||
it("finds a page by a label shown on it, not only its name", () => {
|
||||
const hits = searchSettingsPages(CORE_SETTINGS_PAGES, "theme", translate);
|
||||
expect(hits.some((hit) => hit.page.id === "appearance")).toBe(true);
|
||||
expect(
|
||||
hits.find((hit) => hit.page.id === "appearance")?.match,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists every page for an empty query", () => {
|
||||
expect(
|
||||
searchSettingsPages(CORE_SETTINGS_PAGES, " ", translate),
|
||||
).toHaveLength(CORE_SETTINGS_PAGES.length);
|
||||
});
|
||||
|
||||
it("gives every core page labels to search, all of them real keys", () => {
|
||||
for (const page of CORE_SETTINGS_PAGES) {
|
||||
expect(translate(page.labelKey)).not.toBe(page.labelKey);
|
||||
expect(page.searchKeys.length).toBeGreaterThan(0);
|
||||
}
|
||||
const missing = Object.values(SETTINGS_SEARCH_INDEX)
|
||||
.flat()
|
||||
.filter((key) => translate(key) === key);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/api/sync-api", () => ({
|
||||
probeServer: vi.fn(),
|
||||
getLinkPreview: vi.fn(),
|
||||
completeLink: vi.fn(),
|
||||
reloginLink: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/auth/ElectronLoginForm", () => ({ ElectronLoginForm: () => null }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const { SurfaceScope } = await import("@/components/surface/surface-scope");
|
||||
const { LinkServerDialog } = await import("@/settings/sync/LinkServerDialog");
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("LinkServerDialog", () => {
|
||||
it("opens inline in the surface instead of a popup", () => {
|
||||
render(
|
||||
<SurfaceScope kind="tab">
|
||||
<LinkServerDialog open onOpenChange={() => {}} onLinked={() => {}} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
expect(screen.getByText("sync.wizard.title")).toBeTruthy();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Bell, Boxes } from "lucide-react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
usePermissions: () => ({ has: () => true, loaded: true }),
|
||||
}));
|
||||
vi.mock("@/main-axios", () => ({
|
||||
getUserPreferences: vi.fn(async () => ({})),
|
||||
saveUserPreferences: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { MobileBar, pickPrimaryItems } from "@/shell/MobileBar";
|
||||
import {
|
||||
RAIL_ITEMS,
|
||||
registerRailItem,
|
||||
resetRegisteredRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetRegisteredRailItems();
|
||||
localStorage.clear();
|
||||
delete (window as { IS_ELECTRON?: boolean }).IS_ELECTRON;
|
||||
});
|
||||
|
||||
function renderBar(onRailClick = vi.fn()) {
|
||||
render(
|
||||
<MobileBar
|
||||
railView="hosts"
|
||||
sidebarOpen={false}
|
||||
username="alice"
|
||||
onRailClick={onRailClick}
|
||||
onOpenTab={vi.fn()}
|
||||
onOpenSettings={vi.fn()}
|
||||
onOpenPalette={vi.fn()}
|
||||
onLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
return onRailClick;
|
||||
}
|
||||
|
||||
describe("MobileBar", () => {
|
||||
it("puts flagged items first and caps the bar at four", () => {
|
||||
const ids = pickPrimaryItems(RAIL_ITEMS).map((item) => item.id);
|
||||
expect(ids.slice(0, 2)).toEqual(["hosts", "quick-connect"]);
|
||||
expect(ids).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("opens a destination from the bar", () => {
|
||||
const onRailClick = renderBar();
|
||||
fireEvent.click(screen.getByLabelText("nav.hosts"));
|
||||
expect(onRailClick).toHaveBeenCalledWith("hosts");
|
||||
});
|
||||
|
||||
it("lists footer items and settings behind More", () => {
|
||||
registerRailItem({
|
||||
id: "inbox",
|
||||
icon: Bell,
|
||||
labelKey: "test.inbox",
|
||||
placement: "footer",
|
||||
pluginId: "demo",
|
||||
});
|
||||
registerRailItem({
|
||||
id: "boxes",
|
||||
icon: Boxes,
|
||||
labelKey: "test.boxes",
|
||||
group: "objects",
|
||||
pluginId: "demo",
|
||||
});
|
||||
renderBar();
|
||||
fireEvent.click(screen.getByLabelText("common.more"));
|
||||
expect(screen.getByText("test.inbox")).toBeTruthy();
|
||||
expect(screen.getByText("nav.settings")).toBeTruthy();
|
||||
expect(screen.getByText("nav.group.objects")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves logout out of More on the desktop", () => {
|
||||
renderBar();
|
||||
fireEvent.click(screen.getByLabelText("common.more"));
|
||||
expect(screen.getByText("common.logout")).toBeTruthy();
|
||||
cleanup();
|
||||
(window as { IS_ELECTRON?: boolean }).IS_ELECTRON = true;
|
||||
renderBar();
|
||||
fireEvent.click(screen.getByLabelText("common.more"));
|
||||
expect(screen.queryByText("common.logout")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves out hidden items", () => {
|
||||
localStorage.setItem("hiddenRailTabs", JSON.stringify(["hosts"]));
|
||||
renderBar();
|
||||
expect(screen.queryByLabelText("nav.hosts")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { createRef } from "react";
|
||||
import { Share2 } from "lucide-react";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron: () => true }));
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
usePermissions: () => ({
|
||||
permissions: [],
|
||||
isAdmin: false,
|
||||
loaded: true,
|
||||
has: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { trackTabHandle, useTabHandlesVersion } from "@/shell/tab-handles";
|
||||
import { TabBar } from "@/shell/TabBar";
|
||||
import {
|
||||
declareActionSlot,
|
||||
registerAction,
|
||||
registerSlotContribution,
|
||||
resetActionRegistry,
|
||||
} from "@/shell/action-registry";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetActionRegistry();
|
||||
});
|
||||
|
||||
describe("trackTabHandle", () => {
|
||||
it("passes no ref through", () => {
|
||||
expect(trackTabHandle(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns one callback per ref that fills it and bumps the version", () => {
|
||||
const ref = createRef<unknown>();
|
||||
const callback = trackTabHandle(ref) as (value: unknown) => void;
|
||||
expect(trackTabHandle(ref)).toBe(callback);
|
||||
|
||||
const { result } = renderHook(() => useTabHandlesVersion());
|
||||
const before = result.current;
|
||||
const handle = { disconnect: () => {} };
|
||||
act(() => callback(handle));
|
||||
expect(ref.current).toBe(handle);
|
||||
expect(result.current).toBe(before + 1);
|
||||
|
||||
act(() => callback(handle));
|
||||
expect(result.current).toBe(before + 1);
|
||||
|
||||
act(() => callback(null));
|
||||
expect(ref.current).toBeNull();
|
||||
expect(result.current).toBe(before + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tab inline buttons", () => {
|
||||
it("show as soon as the tab's handle attaches", () => {
|
||||
declareActionSlot({ id: "tab.inline", accepts: ["button"] });
|
||||
registerAction("test.share", () => {});
|
||||
registerSlotContribution("tab.inline", {
|
||||
actionId: "test.share",
|
||||
titleKey: "share",
|
||||
icon: Share2,
|
||||
when: (context) =>
|
||||
typeof (context.handle as { getShareTarget?: unknown } | undefined)
|
||||
?.getShareTarget === "function",
|
||||
});
|
||||
const terminalRef = createRef<unknown>();
|
||||
const tabs = [
|
||||
{ id: "terminal-1", type: "terminal", label: "web-01", terminalRef },
|
||||
] as unknown as Tab[];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId="terminal-1"
|
||||
splits={[]}
|
||||
activeSplitFull={false}
|
||||
onSetActiveTab={() => {}}
|
||||
onCloseTab={() => {}}
|
||||
onRefreshTab={() => {}}
|
||||
onReorderTabs={() => {}}
|
||||
onSplitAction={() => {}}
|
||||
isAppFullscreen={false}
|
||||
onToggleAppFullscreen={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle("share")).toBeNull();
|
||||
|
||||
const attach = trackTabHandle(terminalRef) as (value: unknown) => void;
|
||||
act(() => attach({ getShareTarget: () => null }));
|
||||
expect(screen.getByTitle("share")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { AdminUserManagePanel } from "../../sidebar/AdminUserManagePanel";
|
||||
import { SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import type { AdminUser } from "../../sidebar/AdminManagementSections";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
@@ -48,7 +49,7 @@ function makeUser(overrides: Partial<AdminUser> = {}): AdminUser {
|
||||
id: "u2",
|
||||
username: "bob",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
isExternal: false,
|
||||
passwordHash: "hash",
|
||||
dataUnlocked: true,
|
||||
...overrides,
|
||||
@@ -60,14 +61,16 @@ function renderPanel(
|
||||
callbacks: Partial<Record<string, () => void>> = {},
|
||||
) {
|
||||
return render(
|
||||
<AdminUserManagePanel
|
||||
user={user}
|
||||
roles={[]}
|
||||
onBack={callbacks.onBack ?? vi.fn()}
|
||||
onOpenHostTab={callbacks.onOpenHostTab as never}
|
||||
onUserDeleted={callbacks.onUserDeleted ?? vi.fn()}
|
||||
onSecondFactorsReset={callbacks.onSecondFactorsReset ?? vi.fn()}
|
||||
/>,
|
||||
<SurfaceScope>
|
||||
<AdminUserManagePanel
|
||||
user={user}
|
||||
roles={[]}
|
||||
onBack={callbacks.onBack ?? vi.fn()}
|
||||
onOpenHostTab={callbacks.onOpenHostTab as never}
|
||||
onUserDeleted={callbacks.onUserDeleted ?? vi.fn()}
|
||||
onSecondFactorsReset={callbacks.onSecondFactorsReset ?? vi.fn()}
|
||||
/>
|
||||
</SurfaceScope>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,7 +196,7 @@ describe("AdminUserManagePanel", () => {
|
||||
|
||||
await userEvent.click(screen.getByText("admin.manageTabDanger"));
|
||||
await userEvent.click(screen.getByText("admin.deleteUser"));
|
||||
await userEvent.click(screen.getByText("common.confirm"));
|
||||
await userEvent.click(screen.getByText("common.delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.deleteUser).toHaveBeenCalledWith("bob");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Bell, Boxes } from "lucide-react";
|
||||
import { AppRail } from "../../sidebar/AppRail";
|
||||
@@ -13,6 +13,11 @@ vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
|
||||
saveUserPreferences: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
usePermissions: () => ({ has: () => true, loaded: true }),
|
||||
}));
|
||||
@@ -20,6 +25,7 @@ vi.mock("@/hooks/use-permissions", () => ({
|
||||
afterEach(() => {
|
||||
resetRegisteredRailItems();
|
||||
localStorage.clear();
|
||||
delete (window as { IS_ELECTRON?: boolean }).IS_ELECTRON;
|
||||
});
|
||||
|
||||
function renderRail(onRailClick = vi.fn()) {
|
||||
@@ -30,6 +36,8 @@ function renderRail(onRailClick = vi.fn()) {
|
||||
username="alice"
|
||||
isAdmin={false}
|
||||
onRailClick={onRailClick}
|
||||
onOpenSettings={vi.fn()}
|
||||
onOpenNavigationSettings={vi.fn()}
|
||||
onLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -37,6 +45,15 @@ function renderRail(onRailClick = vi.fn()) {
|
||||
}
|
||||
|
||||
describe("AppRail", () => {
|
||||
it("shows logout in the browser but not on the desktop", () => {
|
||||
renderRail();
|
||||
expect(screen.getByLabelText("common.logout")).toBeTruthy();
|
||||
cleanup();
|
||||
(window as { IS_ELECTRON?: boolean }).IS_ELECTRON = true;
|
||||
renderRail();
|
||||
expect(screen.queryByLabelText("common.logout")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts a footer item above the profile with its badge", async () => {
|
||||
registerRailItem({
|
||||
id: "inbox",
|
||||
@@ -55,7 +72,7 @@ describe("AppRail", () => {
|
||||
const onRailClick = renderRail();
|
||||
|
||||
const inbox = screen.getByTitle("test.inbox");
|
||||
const profile = screen.getAllByTitle("nav.userProfile")[0];
|
||||
const profile = screen.getAllByTitle("nav.openSettings")[0];
|
||||
const boxes = screen.getByTitle("test.boxes");
|
||||
// Footer items render after the main list and before the profile.
|
||||
expect(
|
||||
@@ -82,6 +99,75 @@ describe("AppRail", () => {
|
||||
renderRail();
|
||||
expect(screen.queryByTitle("test.inbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("groups items into bands with a labelled divider", () => {
|
||||
registerRailItem({
|
||||
id: "fleets",
|
||||
icon: Boxes,
|
||||
labelKey: "test.fleets",
|
||||
group: "objects",
|
||||
pluginId: "demo",
|
||||
});
|
||||
renderRail();
|
||||
expect(screen.getByText("nav.group.tools")).toBeTruthy();
|
||||
const fleets = screen.getByTitle("test.fleets");
|
||||
const connections = screen.getByTitle("nav.connections");
|
||||
expect(
|
||||
fleets.compareDocumentPosition(connections) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists hidden items and brings one back", async () => {
|
||||
localStorage.setItem("hiddenRailTabs", JSON.stringify(["connections"]));
|
||||
renderRail();
|
||||
expect(screen.queryByTitle("nav.connections")).toBeNull();
|
||||
await userEvent.click(screen.getByTitle("nav.hiddenCount"));
|
||||
await userEvent.click(screen.getByTitle("nav.showInRail"));
|
||||
expect(JSON.parse(localStorage.getItem("hiddenRailTabs")!)).toEqual([]);
|
||||
expect(await screen.findByTitle("nav.connections")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens plain settings from the account button", async () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
const onOpenNavigationSettings = vi.fn();
|
||||
render(
|
||||
<AppRail
|
||||
railView="hosts"
|
||||
sidebarOpen={false}
|
||||
username="alice"
|
||||
isAdmin
|
||||
onRailClick={vi.fn()}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onOpenNavigationSettings={onOpenNavigationSettings}
|
||||
onLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText("nav.openSettings"));
|
||||
expect(onOpenSettings).toHaveBeenCalled();
|
||||
expect(onOpenNavigationSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the navigation settings from the rail menu", async () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
const onOpenNavigationSettings = vi.fn();
|
||||
render(
|
||||
<AppRail
|
||||
railView="hosts"
|
||||
sidebarOpen={false}
|
||||
username="alice"
|
||||
isAdmin
|
||||
onRailClick={vi.fn()}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onOpenNavigationSettings={onOpenNavigationSettings}
|
||||
onLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTitle("nav.connections"));
|
||||
await userEvent.click(screen.getByText("nav.openSettings"));
|
||||
expect(onOpenNavigationSettings).toHaveBeenCalled();
|
||||
expect(onOpenSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RailBadge", () => {
|
||||
|
||||
@@ -94,9 +94,8 @@ describe("HostAuthOverrideModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = await screen.findByLabelText(
|
||||
"hosts.sharing.authOverrideCredentialLabel",
|
||||
);
|
||||
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
expect((select as HTMLSelectElement).value).toBe("7");
|
||||
|
||||
fireEvent.change(select, { target: { value: "8" } });
|
||||
@@ -119,9 +118,8 @@ describe("HostAuthOverrideModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = await screen.findByLabelText(
|
||||
"hosts.sharing.authOverrideCredentialLabel",
|
||||
);
|
||||
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
expect(screen.getByText("hosts.sharing.noPersonalCredential")).toBeTruthy();
|
||||
fireEvent.change(select, { target: { value: "" } });
|
||||
expect(screen.getByText("hosts.sharing.authOverrideRequired")).toBeTruthy();
|
||||
@@ -187,9 +185,8 @@ describe("HostAuthOverrideModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = await screen.findByLabelText(
|
||||
"hosts.sharing.authOverrideCredentialLabel",
|
||||
);
|
||||
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
expect((select as HTMLSelectElement).value).toBe("19");
|
||||
expect(remote.get).toHaveBeenCalledWith("/credentials");
|
||||
expect(api.getCredentials).not.toHaveBeenCalled();
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type HostProtocols,
|
||||
} from "../../sidebar/HostEditorData";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { HOST_PROTOCOL_SECRET_KEPT } from "@termix/plugin-sdk/frontend";
|
||||
import { HOST_PROTOCOL_SECRET_KEPT } from "@termix-ssh/plugin-sdk/frontend";
|
||||
|
||||
const sshOnly: HostProtocols = {
|
||||
enableSsh: true,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@/main-axios", () => ({
|
||||
getHostAccess: vi.fn(async () => ({ accessList: [] })),
|
||||
getUserList: vi.fn(async () => ({
|
||||
users: [
|
||||
{ userId: "me", username: "admin" },
|
||||
{ userId: "u2", username: "bob" },
|
||||
],
|
||||
})),
|
||||
getUserInfo: vi.fn(async () => ({ userId: "me", username: "admin" })),
|
||||
getRoles: vi.fn(async () => ({ roles: [] })),
|
||||
shareHost: vi.fn(),
|
||||
shareFolder: vi.fn(),
|
||||
updateHostAccess: vi.fn(),
|
||||
revokeHostAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
import { SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { HostShareModal } from "@/sidebar/HostShareModal";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
describe("HostShareModal", () => {
|
||||
it("does not offer the current user as someone to share with", async () => {
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<HostShareModal
|
||||
open
|
||||
onClose={() => {}}
|
||||
host={{ id: 1, name: "web" } as unknown as Host}
|
||||
/>
|
||||
</SurfaceScope>,
|
||||
);
|
||||
expect(await screen.findByText("bob")).toBeTruthy();
|
||||
expect(screen.queryByText("admin")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MultiPanelHint } from "../../sidebar/MultiPanelHint";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const STORAGE_KEY = "termix_multiPanelHintDismissed";
|
||||
|
||||
function renderHint(props: Partial<Parameters<typeof MultiPanelHint>[0]> = {}) {
|
||||
return render(
|
||||
<MultiPanelHint
|
||||
canPromote
|
||||
canRightDock
|
||||
onOpenAsTab={vi.fn()}
|
||||
onOpenInRightDock={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MultiPanelHint", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("shows both actions to a user who has not dismissed it", () => {
|
||||
renderHint();
|
||||
expect(screen.getByText("nav.multiPanelHintTitle")).toBeTruthy();
|
||||
expect(screen.getByText("nav.openAsTab")).toBeTruthy();
|
||||
expect(screen.getByText("nav.openInRightDock")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("spells out how to do each action without the hint", () => {
|
||||
// The whole point of the hint is that the knowledge outlives it, so the
|
||||
// shortcut and the permanent button must both be named.
|
||||
const { container } = renderHint();
|
||||
expect(container.textContent).toContain("nav.multiPanelHintTabHow");
|
||||
expect(container.textContent).toContain("nav.multiPanelHintDockHow");
|
||||
});
|
||||
|
||||
it("stays hidden once dismissed", () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
renderHint();
|
||||
expect(screen.queryByText("nav.multiPanelHintTitle")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides and persists the choice when dismissed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHint();
|
||||
await user.click(screen.getByLabelText("common.dismiss"));
|
||||
|
||||
expect(screen.queryByText("nav.multiPanelHintTitle")).toBeNull();
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
|
||||
});
|
||||
|
||||
it("renders nothing when the panel supports neither action", () => {
|
||||
renderHint({ canPromote: false, canRightDock: false });
|
||||
expect(screen.queryByText("nav.multiPanelHintTitle")).toBeNull();
|
||||
});
|
||||
|
||||
it("only offers the actions the panel actually supports", () => {
|
||||
renderHint({ canPromote: false });
|
||||
expect(screen.queryByText("nav.openAsTab")).toBeNull();
|
||||
expect(screen.getByText("nav.openInRightDock")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("runs the action and dismisses so it does not come back", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenAsTab = vi.fn();
|
||||
renderHint({ onOpenAsTab });
|
||||
|
||||
await user.click(screen.getByText("nav.openAsTab"));
|
||||
|
||||
expect(onOpenAsTab).toHaveBeenCalledOnce();
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
screen,
|
||||
cleanup,
|
||||
@@ -11,6 +12,9 @@ import { FolderSearch, Monitor, Server, Terminal } from "lucide-react";
|
||||
vi.mock("@/api/credentials-api", () => ({
|
||||
getCredentials: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock("@/api/host-defaults-api", () => ({
|
||||
resolveHostDefaults: vi.fn(async () => ({})),
|
||||
}));
|
||||
vi.mock("@/api/auth-methods-api", () => ({
|
||||
getSshAuthProviders: vi.fn(async () => []),
|
||||
}));
|
||||
@@ -83,13 +87,22 @@ function registerDefaults() {
|
||||
});
|
||||
}
|
||||
|
||||
// The panel loads credentials and host defaults on mount; settle them inside
|
||||
// act so their state updates never land after the test ends.
|
||||
async function renderPanel(onConnect = vi.fn()) {
|
||||
await act(async () => {
|
||||
render(<QuickConnectPanel onConnect={onConnect} />);
|
||||
});
|
||||
return onConnect;
|
||||
}
|
||||
|
||||
const buttonName = (key: string) =>
|
||||
`newUi.sidebar.quickConnect.connectToAction:${JSON.stringify({ name: key })}`;
|
||||
|
||||
describe("QuickConnectPanel", () => {
|
||||
it("offers every usable connect target and opt-in tool", () => {
|
||||
it("offers every usable connect target and opt-in tool", async () => {
|
||||
registerDefaults();
|
||||
render(<QuickConnectPanel onConnect={vi.fn()} />);
|
||||
await renderPanel();
|
||||
expect(screen.getByText(buttonName("nav.terminal"))).toBeTruthy();
|
||||
expect(screen.getByText(buttonName("nav.files"))).toBeTruthy();
|
||||
expect(screen.queryByText(buttonName("nav.docker"))).toBeNull();
|
||||
@@ -99,7 +112,7 @@ describe("QuickConnectPanel", () => {
|
||||
it("opens the chosen tab for the typed address", async () => {
|
||||
registerDefaults();
|
||||
const onConnect = vi.fn();
|
||||
render(<QuickConnectPanel onConnect={onConnect} />);
|
||||
await renderPanel(onConnect);
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("newUi.sidebar.quickConnect.hostPlaceholder"),
|
||||
{ target: { value: "10.0.0.1" } },
|
||||
@@ -111,10 +124,10 @@ describe("QuickConnectPanel", () => {
|
||||
expect(host).toMatchObject({ ip: "10.0.0.1", username: "root", port: 22 });
|
||||
});
|
||||
|
||||
it("connects with the top target on Enter", () => {
|
||||
it("connects with the top target on Enter", async () => {
|
||||
registerDefaults();
|
||||
const onConnect = vi.fn();
|
||||
render(<QuickConnectPanel onConnect={onConnect} />);
|
||||
await renderPanel(onConnect);
|
||||
const input = screen.getByPlaceholderText(
|
||||
"newUi.sidebar.quickConnect.hostPlaceholder",
|
||||
);
|
||||
@@ -126,16 +139,16 @@ describe("QuickConnectPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing without an address", () => {
|
||||
it("does nothing without an address", async () => {
|
||||
registerDefaults();
|
||||
const onConnect = vi.fn();
|
||||
render(<QuickConnectPanel onConnect={onConnect} />);
|
||||
await renderPanel(onConnect);
|
||||
fireEvent.click(screen.getByText(buttonName("nav.terminal")));
|
||||
expect(onConnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says so when nothing can connect", () => {
|
||||
render(<QuickConnectPanel onConnect={vi.fn()} />);
|
||||
it("says so when nothing can connect", async () => {
|
||||
await renderPanel();
|
||||
expect(
|
||||
screen.getByText("newUi.sidebar.quickConnect.noTarget"),
|
||||
).toBeTruthy();
|
||||
@@ -172,7 +185,7 @@ describe("QuickConnectPanel", () => {
|
||||
},
|
||||
];
|
||||
const onConnect = vi.fn();
|
||||
render(<QuickConnectPanel onConnect={onConnect} />);
|
||||
await renderPanel(onConnect);
|
||||
fireEvent.change(screen.getByDisplayValue("password"), {
|
||||
target: { value: "tailnet" },
|
||||
});
|
||||
|
||||
@@ -56,3 +56,31 @@ describe("getCredentialRowHeight", () => {
|
||||
).toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detail row", () => {
|
||||
it("drops the username line height in comfortable density only", () => {
|
||||
const shape = {
|
||||
isKey: false,
|
||||
alwaysShowActions: false,
|
||||
actionsOnly: false,
|
||||
isOpen: false,
|
||||
showTags: false,
|
||||
tagCount: 0,
|
||||
};
|
||||
const full = getCredentialRowHeight({ density: "comfortable", ...shape });
|
||||
expect(
|
||||
getCredentialRowHeight({
|
||||
density: "comfortable",
|
||||
...shape,
|
||||
hasDetailRow: false,
|
||||
}),
|
||||
).toBe(full - 15);
|
||||
expect(
|
||||
getCredentialRowHeight({
|
||||
density: "compact",
|
||||
...shape,
|
||||
hasDetailRow: false,
|
||||
}),
|
||||
).toBe(getCredentialRowHeight({ density: "compact", ...shape }));
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,48 @@ import type { PluginSummary } from "@/api/plugins-api";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { createHostEditorForm } from "@/sidebar/HostEditorData";
|
||||
import { HostPluginSections } from "@/settings/HostPluginSections";
|
||||
import recording from "../../../../plugins/session-recording/manifest.json";
|
||||
import sharing from "../../../../plugins/session-sharing/manifest.json";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
afterEach(cleanup);
|
||||
|
||||
const recording = {
|
||||
id: "session-recording",
|
||||
name: "Session Recording",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
editorGroup: "ssh",
|
||||
enableKey: "enableSessionRecording",
|
||||
enableLabelKey: "settings.host.enable.label",
|
||||
enableDescriptionKey: "settings.host.enable.description",
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sharing = {
|
||||
id: "session-sharing",
|
||||
name: "Session Sharing",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
editorGroup: "ssh",
|
||||
fields: [
|
||||
{
|
||||
key: "allowSessionSharing",
|
||||
type: "boolean",
|
||||
labelKey: "settings.host.allowSessionSharing.label",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe.each([
|
||||
{ manifest: recording, key: "enableSessionRecording" },
|
||||
{ manifest: sharing, key: "allowSessionSharing" },
|
||||
@@ -54,7 +88,7 @@ describe.each([
|
||||
setValue={setValue}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
fireEvent.click(screen.getByRole("switch"));
|
||||
expect(setValue).toHaveBeenCalledWith(manifest.id, key, !saved);
|
||||
// These are the values HostEditor writes through each plugin's host settings API.
|
||||
expect(form.pluginSettings).toEqual(pluginSettings);
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { PluginSummary } from "@/api/plugins-api";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { createHostEditorForm } from "@/sidebar/HostEditorData";
|
||||
import { HostPluginSections } from "@/settings/HostPluginSections";
|
||||
import manifest from "../../../../plugins/session-sharing/manifest.json";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
@@ -12,6 +11,26 @@ vi.mock("react-i18next", () => ({
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const manifest = {
|
||||
id: "session-sharing",
|
||||
name: "Session Sharing",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
editorGroup: "ssh",
|
||||
fields: [
|
||||
{
|
||||
key: "allowSessionSharing",
|
||||
type: "boolean",
|
||||
labelKey: "settings.host.allowSessionSharing.label",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("host sharing settings on reopening the editor", () => {
|
||||
it.each([false, true])("retains the saved sharing value %s", (saved) => {
|
||||
const pluginSettings = {
|
||||
@@ -50,7 +69,7 @@ describe("host sharing settings on reopening the editor", () => {
|
||||
);
|
||||
|
||||
// The toggle starts from the server value, including false despite its true default.
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
fireEvent.click(screen.getByRole("switch"));
|
||||
expect(setValue).toHaveBeenCalledWith(
|
||||
"session-sharing",
|
||||
"allowSessionSharing",
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
rightDockableIds,
|
||||
railItemLabel,
|
||||
visibleRailItems,
|
||||
applyRailOrder,
|
||||
moveRailItem,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { isCapturableTabType } from "@/shell/shell-layout";
|
||||
import { registerPanel, resetPanels } from "@/shell/panel-registry";
|
||||
@@ -63,9 +65,19 @@ describe("RAIL_ITEMS", () => {
|
||||
"connections",
|
||||
"quick-connect",
|
||||
"sync",
|
||||
"plugins",
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens the Plugins tab only for plugin managers", () => {
|
||||
const plugins = RAIL_ITEMS.find((item) => item.id === "plugins");
|
||||
expect(plugins).toMatchObject({
|
||||
kind: "tab",
|
||||
placement: "footer",
|
||||
permission: "admin.plugins.manage",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes every visible rail item as hideable", () => {
|
||||
expect(hideableRailIds()).toEqual(
|
||||
visibleRailItems().map((item) => item.id),
|
||||
@@ -265,3 +277,50 @@ describe("permittedRailItems", () => {
|
||||
expect(ids).toEqual(["open"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rail order", () => {
|
||||
const item = (id: string, group: "objects" | "tools" = "tools") =>
|
||||
({ id, icon: Boxes, labelKey: id, group }) as const;
|
||||
const items = [
|
||||
item("hosts", "objects"),
|
||||
item("credentials", "objects"),
|
||||
item("snippets"),
|
||||
item("macros"),
|
||||
item("history"),
|
||||
];
|
||||
|
||||
it("leaves the default order without a saved one", () => {
|
||||
expect(applyRailOrder(items, undefined)).toBe(items);
|
||||
expect(applyRailOrder(items, [])).toBe(items);
|
||||
});
|
||||
|
||||
it("puts listed items in the saved order and keeps the rest in place", () => {
|
||||
const ids = applyRailOrder(items, ["history", "snippets"]).map(
|
||||
(entry) => entry.id,
|
||||
);
|
||||
expect(ids).toEqual([
|
||||
"hosts",
|
||||
"credentials",
|
||||
"history",
|
||||
"macros",
|
||||
"snippets",
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves an item before another, or to the end of its band", () => {
|
||||
expect(moveRailItem(items, "history", "snippets")).toEqual([
|
||||
"hosts",
|
||||
"credentials",
|
||||
"history",
|
||||
"snippets",
|
||||
"macros",
|
||||
]);
|
||||
expect(moveRailItem(items, "hosts", null)).toEqual([
|
||||
"credentials",
|
||||
"hosts",
|
||||
"snippets",
|
||||
"macros",
|
||||
"history",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { HostFolder } from "@/types/ui-types";
|
||||
import { FolderItem } from "@/sidebar/tree/FolderItem/FolderItem";
|
||||
|
||||
vi.mock("@/lib/ServerStatusContext", () => ({
|
||||
useServerStatus: () => ({ getStatus: () => "online" }),
|
||||
}));
|
||||
vi.mock("@/sidebar/tree/HostItem/HostItem", () => ({
|
||||
HostItem: () => null,
|
||||
statusCheckEnabled: () => true,
|
||||
}));
|
||||
|
||||
const folder = {
|
||||
name: "Production",
|
||||
path: "Production",
|
||||
children: [],
|
||||
} as unknown as HostFolder;
|
||||
|
||||
function renderFolder(onToggleFolder = vi.fn()) {
|
||||
const noop = vi.fn();
|
||||
render(
|
||||
<FolderItem
|
||||
folder={folder}
|
||||
flat
|
||||
onOpenTab={noop}
|
||||
onDeleteHost={noop}
|
||||
onDuplicateHost={noop}
|
||||
openFolders={new Set()}
|
||||
onToggleFolder={onToggleFolder}
|
||||
selectionMode={false}
|
||||
selectedHostIds={new Set()}
|
||||
onToggleSelect={noop}
|
||||
onToggleSelectFolder={noop}
|
||||
openMenuHostId={null}
|
||||
onMenuOpenChange={noop}
|
||||
openTrayHostId={null}
|
||||
onTrayOpenChange={noop}
|
||||
onManageFolder={noop}
|
||||
onDeleteFolder={noop}
|
||||
onOpenAllSessions={noop}
|
||||
onMoveHostsToFolder={noop}
|
||||
draggedHostIds={null}
|
||||
onDragHostStart={noop}
|
||||
onDragEnd={noop}
|
||||
/>,
|
||||
);
|
||||
return onToggleFolder;
|
||||
}
|
||||
|
||||
it("never nests the folder actions inside a button element", () => {
|
||||
renderFolder();
|
||||
for (const button of document.querySelectorAll("button")) {
|
||||
expect(button.parentElement?.closest("button")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("toggles the folder by click, Enter and Space", () => {
|
||||
const onToggle = renderFolder();
|
||||
const header = document.querySelector('[role="button"][aria-expanded]')!;
|
||||
fireEvent.click(header);
|
||||
fireEvent.keyDown(header, { key: "Enter" });
|
||||
fireEvent.keyDown(header, { key: " " });
|
||||
expect(onToggle).toHaveBeenCalledTimes(3);
|
||||
expect(onToggle).toHaveBeenCalledWith("Production");
|
||||
});
|
||||
|
||||
it("ignores keys pressed on a folder action", () => {
|
||||
const onToggle = renderFolder();
|
||||
const action = document.querySelector("button")!;
|
||||
fireEvent.keyDown(action, { key: "Enter" });
|
||||
expect(onToggle).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { registerHostProtocol } from "@/sidebar/host-protocols";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
@@ -43,7 +44,11 @@ vi.mock("@/main-axios", () => ({
|
||||
getHostPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
import { HostItem } from "../../../../sidebar/tree/HostItem/HostItem";
|
||||
import {
|
||||
HostItem,
|
||||
formatHostAddress,
|
||||
} from "../../../../sidebar/tree/HostItem/HostItem";
|
||||
import { defaultHostRowFields } from "@/types/host-sidebar-preferences";
|
||||
|
||||
const baseHost: Host = {
|
||||
id: "1",
|
||||
@@ -356,3 +361,84 @@ describe("HostItem click behavior", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostItem row fields", () => {
|
||||
function renderWith(
|
||||
fields: Partial<ReturnType<typeof defaultHostRowFields>>,
|
||||
) {
|
||||
return render(
|
||||
<HostItem
|
||||
host={baseHost}
|
||||
onOpenTab={noop}
|
||||
onEditHost={noop}
|
||||
onDelete={noop}
|
||||
onDuplicate={noop}
|
||||
density="comfortable"
|
||||
rowFields={{ ...defaultHostRowFields(), ...fields }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("shows user@address by default", () => {
|
||||
renderWith({});
|
||||
expect(screen.getByText("root@10.0.0.5")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the address when turned off", () => {
|
||||
renderWith({ showAddress: false });
|
||||
expect(screen.queryByText(/10\.0\.0\.5/)).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the pin icon when turned off", () => {
|
||||
renderWith({ showPinIcon: false });
|
||||
expect(document.querySelector(".lucide-pin")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats the address from the chosen parts", () => {
|
||||
expect(
|
||||
formatHostAddress(baseHost, { showUsername: false, showPort: true }),
|
||||
).toBe("10.0.0.5:22");
|
||||
expect(
|
||||
formatHostAddress(baseHost, { showUsername: true, showPort: false }),
|
||||
).toBe("root@10.0.0.5");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the enabled protocol username for non-SSH hosts", () => {
|
||||
const dispose = registerHostProtocol({
|
||||
id: "rdp",
|
||||
pluginId: "remote-desktop",
|
||||
settingKey: "enableRdp",
|
||||
defaultPort: 3389,
|
||||
titleKey: "rdp",
|
||||
icon: () => null,
|
||||
});
|
||||
try {
|
||||
const host: Host = {
|
||||
...baseHost,
|
||||
enableSsh: false,
|
||||
pluginSettings: { "remote-desktop": { enableRdp: true } },
|
||||
protocolAuth: {
|
||||
rdp: {
|
||||
authType: "credential",
|
||||
credentialId: 6,
|
||||
username: "Administrator",
|
||||
fields: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const fields = { showUsername: true, showPort: false };
|
||||
expect(formatHostAddress(host, fields)).toBe("Administrator@10.0.0.5");
|
||||
expect(formatHostAddress({ ...host, enableSsh: true }, fields)).toBe(
|
||||
"root@10.0.0.5",
|
||||
);
|
||||
expect(formatHostAddress({ ...host, protocolAuth: {} }, fields)).toBe(
|
||||
"10.0.0.5",
|
||||
);
|
||||
expect(formatHostAddress(host, { ...fields, showUsername: false })).toBe(
|
||||
"10.0.0.5",
|
||||
);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
statusCheckEnabled,
|
||||
} from "@/sidebar/tree/HostItem/HostItem";
|
||||
|
||||
// Minimal host factory – only the fields buildStatusTooltip reads.
|
||||
// Minimal host factory, only the fields buildStatusTooltip reads.
|
||||
function makeHost(overrides: Partial<Host> = {}): Host {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -90,7 +90,7 @@ describe("buildStatusTooltip", () => {
|
||||
it("does not render key paths when a translator is supplied", () => {
|
||||
const host = makeHost();
|
||||
const tooltip = buildStatusTooltip(host, "online", t);
|
||||
// The tooltip must never show the raw key path – that means the
|
||||
// The tooltip must never show the raw key path, that means the
|
||||
// translation resource is missing, which is the bug #1265 fixed.
|
||||
expect(tooltip).not.toMatch(/hosts\.status\./);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { showsInBar } from "@/sidebar/tree/host-bar-actions";
|
||||
|
||||
describe("showsInBar", () => {
|
||||
it("follows the action's own default without a choice", () => {
|
||||
expect(showsInBar({}, { id: "terminal" })).toBe(true);
|
||||
expect(showsInBar({}, { id: "maintenance", tray: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the user's choice win either way", () => {
|
||||
expect(
|
||||
showsInBar({ maintenance: true }, { id: "maintenance", tray: false }),
|
||||
).toBe(true);
|
||||
expect(showsInBar({ "ai-agent": false }, { id: "ai-agent" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
describe("SSH password prompts", () => {
|
||||
it("accepts a full password and preserves whitespace", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(
|
||||
<TOTPDialog
|
||||
isOpen
|
||||
prompt="Password:"
|
||||
mode="password"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = container.querySelector("input")!;
|
||||
expect(input.type).toBe("password");
|
||||
expect(input).not.toHaveAttribute("maxLength");
|
||||
expect(input).not.toHaveAttribute("pattern");
|
||||
expect(input).not.toHaveAttribute("inputMode", "numeric");
|
||||
fireEvent.change(input, { target: { value: " LongPassword!123 " } });
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
expect(onSubmit).toHaveBeenCalledWith(" LongPassword!123 ");
|
||||
expect(screen.queryByText("sshAuth.totpRequired")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still restricts verification codes to digits", () => {
|
||||
const { container } = render(
|
||||
<TOTPDialog
|
||||
isOpen
|
||||
prompt="Code:"
|
||||
mode="totp"
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("input")).toHaveAttribute(
|
||||
"pattern",
|
||||
"[0-9]*",
|
||||
);
|
||||
expect(container.querySelector("input")).toHaveAttribute("maxLength", "8");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("TOTPDialog", () => {
|
||||
it("submits the code", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<TOTPDialog isOpen prompt="" onSubmit={onSubmit} onCancel={vi.fn()} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "sshAuth.totpRequired" }),
|
||||
).toBeTruthy();
|
||||
fireEvent.change(screen.getByPlaceholderText("000000"), {
|
||||
target: { value: "123456" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "sshAuth.totpVerify" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith("123456");
|
||||
});
|
||||
|
||||
it("sends an empty answer for a push prompt and then waits", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<TOTPDialog
|
||||
isOpen
|
||||
prompt=""
|
||||
mode="push"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "sshAuth.mfaSendRequest" }),
|
||||
);
|
||||
expect(onSubmit).toHaveBeenCalledWith("");
|
||||
expect(screen.getByText("sshAuth.mfaWaitingApproval")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("cancels", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<TOTPDialog isOpen prompt="" onSubmit={vi.fn()} onCancel={onCancel} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
render(
|
||||
<TOTPDialog
|
||||
isOpen={false}
|
||||
prompt=""
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,11 @@ describe("defaultCredentialSidebarPreferences", () => {
|
||||
density: "comfortable",
|
||||
showTags: true,
|
||||
trayTrigger: "always",
|
||||
showUsername: true,
|
||||
showUsageCount: true,
|
||||
showTypeBadge: true,
|
||||
showPinIcon: true,
|
||||
showSharedBadge: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -51,6 +56,11 @@ describe("sanitizeCredentialSidebarPreferences", () => {
|
||||
density: "compact",
|
||||
showTags: false,
|
||||
trayTrigger: "click",
|
||||
showUsername: false,
|
||||
showUsageCount: false,
|
||||
showTypeBadge: false,
|
||||
showPinIcon: true,
|
||||
showSharedBadge: false,
|
||||
},
|
||||
};
|
||||
expect(sanitizeCredentialSidebarPreferences(valid)).toEqual(valid);
|
||||
@@ -90,3 +100,14 @@ describe("sanitizeCredentialSidebarPreferences", () => {
|
||||
expect(result.version).toBe(CREDENTIAL_SIDEBAR_PREFS_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("credential row fields", () => {
|
||||
it("keeps boolean choices and defaults the rest", () => {
|
||||
const prefs = sanitizeCredentialSidebarPreferences({
|
||||
display: { showUsername: false, showTypeBadge: "off" },
|
||||
});
|
||||
expect(prefs.display.showUsername).toBe(false);
|
||||
expect(prefs.display.showTypeBadge).toBe(true);
|
||||
expect(prefs.display.showUsageCount).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,13 @@ describe("defaultHostSidebarPreferences", () => {
|
||||
openOnDoubleClick: false,
|
||||
showFolderPaths: true,
|
||||
hostClickBehavior: "newTab",
|
||||
barActions: {},
|
||||
showAddress: true,
|
||||
showUsername: true,
|
||||
showPort: false,
|
||||
showPinIcon: true,
|
||||
showSharedBadge: true,
|
||||
showBadges: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -67,6 +74,13 @@ describe("sanitizeHostSidebarPreferences", () => {
|
||||
openOnDoubleClick: true,
|
||||
showFolderPaths: false,
|
||||
hostClickBehavior: "focusExistingDoubleClickNew",
|
||||
barActions: { maintenance: true },
|
||||
showAddress: false,
|
||||
showUsername: false,
|
||||
showPort: true,
|
||||
showPinIcon: false,
|
||||
showSharedBadge: false,
|
||||
showBadges: true,
|
||||
},
|
||||
};
|
||||
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
|
||||
@@ -145,3 +159,50 @@ describe("sanitizeHostSidebarPreferences", () => {
|
||||
expect(result.version).toBe(HOST_SIDEBAR_PREFS_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("barActions", () => {
|
||||
it("keeps boolean choices for valid action ids only", () => {
|
||||
const prefs = sanitizeHostSidebarPreferences({
|
||||
display: {
|
||||
barActions: {
|
||||
maintenance: true,
|
||||
"ai-agent": false,
|
||||
"Bad Id": true,
|
||||
docker: "yes",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(prefs.display.barActions).toEqual({
|
||||
maintenance: true,
|
||||
"ai-agent": false,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to no overrides", () => {
|
||||
expect(sanitizeHostSidebarPreferences({}).display.barActions).toEqual({});
|
||||
expect(
|
||||
sanitizeHostSidebarPreferences({ display: { barActions: [] } }).display
|
||||
.barActions,
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("row fields", () => {
|
||||
it("keeps boolean row field choices", () => {
|
||||
const prefs = sanitizeHostSidebarPreferences({
|
||||
display: { showAddress: false, showPort: true, showBadges: false },
|
||||
});
|
||||
expect(prefs.display.showAddress).toBe(false);
|
||||
expect(prefs.display.showPort).toBe(true);
|
||||
expect(prefs.display.showBadges).toBe(false);
|
||||
expect(prefs.display.showUsername).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to defaults for non-boolean values", () => {
|
||||
const prefs = sanitizeHostSidebarPreferences({
|
||||
display: { showAddress: "no", showPort: 1 },
|
||||
});
|
||||
expect(prefs.display.showAddress).toBe(true);
|
||||
expect(prefs.display.showPort).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_ONBOARDING_STEPS,
|
||||
LEGACY_SEEN,
|
||||
MAX_SEEN_ENTRIES,
|
||||
mergeSeen,
|
||||
pluginStepKey,
|
||||
sanitizeSeen,
|
||||
} from "@/types/onboarding";
|
||||
|
||||
describe("onboarding seen map", () => {
|
||||
it("keys plugin steps by plugin id", () => {
|
||||
expect(pluginStepKey("remote-desktop", "guacd")).toBe(
|
||||
"remote-desktop:guacd",
|
||||
);
|
||||
});
|
||||
|
||||
it("merges per key keeping the higher version", () => {
|
||||
expect(mergeSeen({ a: 2, b: 1 }, { a: 1, b: 3, c: 1 })).toEqual({
|
||||
a: 2,
|
||||
b: 3,
|
||||
c: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops invalid entries", () => {
|
||||
expect(
|
||||
sanitizeSeen({ ok: 1, zero: 0, neg: -1, frac: 1.2, str: "1", "": 1 }),
|
||||
).toEqual({ ok: 1 });
|
||||
expect(sanitizeSeen(["a"])).toEqual({});
|
||||
expect(sanitizeSeen(null)).toEqual({});
|
||||
});
|
||||
|
||||
it("caps the number of entries", () => {
|
||||
const big: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_SEEN_ENTRIES + 20; i++) big[`k${i}`] = 1;
|
||||
expect(Object.keys(sanitizeSeen(big))).toHaveLength(MAX_SEEN_ENTRIES);
|
||||
});
|
||||
|
||||
it("legacy seen covers every core step at launch", () => {
|
||||
expect(Object.keys(LEGACY_SEEN).sort()).toEqual(
|
||||
Object.keys(CORE_ONBOARDING_STEPS).sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyOrder,
|
||||
isPluginChoice,
|
||||
resolvePluginChoices,
|
||||
type ChoicePlugin,
|
||||
} from "@/types/plugin-onboarding";
|
||||
|
||||
const plugins: ChoicePlugin[] = [
|
||||
{ id: "base", dependencies: [] },
|
||||
{ id: "mid", dependencies: ["base"] },
|
||||
{ id: "top", dependencies: ["mid"] },
|
||||
{ id: "solo", dependencies: [] },
|
||||
];
|
||||
|
||||
describe("resolvePluginChoices", () => {
|
||||
it("leaves consistent choices alone", () => {
|
||||
const choices = {
|
||||
base: "enabled",
|
||||
mid: "enabled",
|
||||
top: "disabled",
|
||||
solo: "remove",
|
||||
} as const;
|
||||
const result = resolvePluginChoices(plugins, choices);
|
||||
expect(result.choices).toEqual(choices);
|
||||
expect(result.adjustments).toEqual([]);
|
||||
});
|
||||
|
||||
it("enables the whole chain under an enabled plugin", () => {
|
||||
const result = resolvePluginChoices(plugins, {
|
||||
base: "remove",
|
||||
mid: "disabled",
|
||||
top: "enabled",
|
||||
});
|
||||
expect(result.choices).toEqual({
|
||||
base: "enabled",
|
||||
mid: "enabled",
|
||||
top: "enabled",
|
||||
});
|
||||
expect(result.adjustments).toEqual([
|
||||
{ id: "base", from: "remove", to: "enabled", requiredBy: ["mid"] },
|
||||
{ id: "mid", from: "disabled", to: "enabled", requiredBy: ["top"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a dependency of a disabled plugin instead of removing it", () => {
|
||||
const result = resolvePluginChoices(plugins, {
|
||||
base: "remove",
|
||||
mid: "disabled",
|
||||
});
|
||||
expect(result.choices.base).toBe("disabled");
|
||||
});
|
||||
|
||||
it("lets a removed plugin take nothing with it", () => {
|
||||
const result = resolvePluginChoices(plugins, {
|
||||
base: "remove",
|
||||
mid: "remove",
|
||||
top: "remove",
|
||||
});
|
||||
expect(result.adjustments).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores unknown dependencies and survives cycles", () => {
|
||||
const cyclic: ChoicePlugin[] = [
|
||||
{ id: "a", dependencies: ["b", "ghost"] },
|
||||
{ id: "b", dependencies: ["a"] },
|
||||
];
|
||||
const result = resolvePluginChoices(cyclic, { a: "enabled", b: "remove" });
|
||||
expect(result.choices).toEqual({ a: "enabled", b: "enabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOrder", () => {
|
||||
it("enables dependencies first and removes dependents first", () => {
|
||||
const order = applyOrder(plugins, {
|
||||
base: "enabled",
|
||||
mid: "enabled",
|
||||
top: "enabled",
|
||||
});
|
||||
expect(order.enable).toEqual(["base", "mid", "top"]);
|
||||
|
||||
const removal = applyOrder(plugins, {
|
||||
base: "remove",
|
||||
mid: "remove",
|
||||
top: "remove",
|
||||
solo: "disabled",
|
||||
});
|
||||
expect(removal.remove).toEqual(["top", "mid", "base"]);
|
||||
expect(removal.disable).toEqual(["solo"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPluginChoice", () => {
|
||||
it("accepts only the three choices", () => {
|
||||
expect(isPluginChoice("enabled")).toBe(true);
|
||||
expect(isPluginChoice("remove")).toBe(true);
|
||||
expect(isPluginChoice("delete")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
sanitizeUiPreferences,
|
||||
UI_PREFERENCES_VERSION,
|
||||
} from "@/types/ui-preferences";
|
||||
import { LEGACY_SEEN } from "@/types/onboarding";
|
||||
|
||||
describe("defaultUiPreferences", () => {
|
||||
it("starts every user on balanced with no overrides", () => {
|
||||
@@ -16,9 +17,10 @@ describe("defaultUiPreferences", () => {
|
||||
expect(defaults.preset).toBe("balanced");
|
||||
expect(defaults.overrides).toEqual({});
|
||||
expect(defaults.onboarding).toEqual({
|
||||
completedVersion: 0,
|
||||
seen: {},
|
||||
completedAt: null,
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,9 +38,10 @@ describe("sanitizeUiPreferences", () => {
|
||||
preset: "simple" as const,
|
||||
overrides: { hostList: { density: "compact" as const, showTags: true } },
|
||||
onboarding: {
|
||||
completedVersion: 1,
|
||||
seen: { welcome: 1, "remote-desktop:guacd": 2 },
|
||||
completedAt: "2026-01-01T00:00:00.000Z",
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
},
|
||||
};
|
||||
expect(sanitizeUiPreferences(valid)).toEqual(valid);
|
||||
@@ -56,11 +59,28 @@ describe("sanitizeUiPreferences", () => {
|
||||
|
||||
it("ignores a malformed onboarding block", () => {
|
||||
expect(sanitizeUiPreferences({ onboarding: "done" }).onboarding).toEqual({
|
||||
completedVersion: 0,
|
||||
seen: {},
|
||||
completedAt: null,
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates the old completedVersion shape to the legacy seen map", () => {
|
||||
const onboarding = sanitizeUiPreferences({
|
||||
onboarding: { completedVersion: 2, completedAt: null, skipped: true },
|
||||
}).onboarding;
|
||||
expect(onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
expect(onboarding.baselinePending).toBe(true);
|
||||
expect(onboarding.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an old never-completed shape as new", () => {
|
||||
expect(
|
||||
sanitizeUiPreferences({ onboarding: { completedVersion: 0 } }).onboarding
|
||||
.seen,
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeUiOverrides", () => {
|
||||
@@ -175,7 +195,7 @@ describe("PRESETS.balanced", () => {
|
||||
});
|
||||
|
||||
describe("plugin areas", () => {
|
||||
it("moves version 1 docker and host metrics overrides to their plugins", () => {
|
||||
it("drops 2.8 area names, even from an old version", () => {
|
||||
const upgraded = sanitizeUiPreferences({
|
||||
version: 1,
|
||||
preset: "balanced",
|
||||
@@ -184,10 +204,7 @@ describe("plugin areas", () => {
|
||||
hostMetrics: { columns: 2 },
|
||||
},
|
||||
});
|
||||
expect(upgraded.overrides).toEqual({
|
||||
"plugin:docker": { containerLayout: "table" },
|
||||
"plugin:host-metrics": { columns: 2 },
|
||||
});
|
||||
expect(upgraded.overrides).toEqual({});
|
||||
});
|
||||
|
||||
it("resolves a plugin area from its presets and the user's overrides", () => {
|
||||
@@ -220,24 +237,12 @@ describe("plugin areas", () => {
|
||||
});
|
||||
|
||||
describe("areas that moved into plugins", () => {
|
||||
it("maps version 2 terminal and file manager overrides to their plugins", () => {
|
||||
it("drops the old names", () => {
|
||||
expect(
|
||||
sanitizeUiOverrides(
|
||||
{
|
||||
terminal: { toolbarDensity: "expanded" },
|
||||
fileManager: { viewMode: "list" },
|
||||
},
|
||||
2,
|
||||
),
|
||||
).toEqual({
|
||||
"plugin:ssh-terminal": { toolbarDensity: "expanded" },
|
||||
"plugin:file-manager": { viewMode: "list" },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops the old names from a current payload", () => {
|
||||
expect(
|
||||
sanitizeUiOverrides({ terminal: { toolbarDensity: "icon" } }),
|
||||
sanitizeUiOverrides({
|
||||
terminal: { toolbarDensity: "icon" },
|
||||
fileManager: { viewMode: "list" },
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user