mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
release-2.7.0 (#1264)
* feat: redesign host/credential sidebars with synced preferences and manual drag-to-reorder * chore: run format * chore(deps-dev): bump @types/pg in the dev-patch-updates group (#1162) Bumps the dev-patch-updates group with 1 update: [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg). Updates `@types/pg` from 8.20.0 to 8.20.3 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pg) --- updated-dependencies: - dependency-name: "@types/pg" dependency-version: 8.20.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-minor-updates group with 4 updates (#1163) Bumps the dev-minor-updates group with 4 updates: [react-hook-form](https://github.com/react-hook-form/react-hook-form), [react-icons](https://github.com/react-icons/react-icons), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `react-hook-form` from 7.79.0 to 7.84.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.79.0...v7.84.0) Updates `react-icons` from 5.6.0 to 5.7.0 - [Release notes](https://github.com/react-icons/react-icons/releases) - [Commits](https://github.com/react-icons/react-icons/compare/v5.6.0...v5.7.0) Updates `typescript-eslint` from 8.61.1 to 8.66.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/typescript-eslint) Updates `vite` from 8.0.16 to 8.2.0 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/create-vite@8.2.0/packages/vite) --- updated-dependencies: - dependency-name: react-hook-form dependency-version: 7.84.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-icons dependency-version: 5.7.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.66.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: vite dependency-version: 8.2.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> * chore(deps): bump the prod-patch-updates group with 3 updates (#1164) Bumps the prod-patch-updates group with 3 updates: [jose](https://github.com/panva/jose), [js-yaml](https://github.com/nodeca/js-yaml) and [nanoid](https://github.com/ai/nanoid). Updates `jose` from 6.2.7 to 6.2.8 - [Release notes](https://github.com/panva/jose/releases) - [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md) - [Commits](https://github.com/panva/jose/compare/v6.2.7...v6.2.8) Updates `js-yaml` from 5.2.2 to 5.2.3 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.2.2...5.2.3) Updates `nanoid` from 6.0.0 to 6.0.1 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/6.0.0...6.0.1) --- updated-dependencies: - dependency-name: jose dependency-version: 6.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: js-yaml dependency-version: 5.2.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: nanoid dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump undici in the prod-minor-updates group (#1165) Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.9.0 to 8.10.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the major-updates group with 2 updates (#1166) Bumps the major-updates group with 2 updates: [@types/better-sqlite3](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/better-sqlite3) and [jsdom](https://github.com/jsdom/jsdom). Updates `@types/better-sqlite3` from 7.6.13 to 9.6.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/better-sqlite3) Updates `jsdom` from 29.1.1 to 30.0.1 - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1) --- updated-dependencies: - dependency-name: "@types/better-sqlite3" dependency-version: 9.6.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: jsdom dependency-version: 30.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: stop resyncAutoIncrement failing on tables without an id column (#1173) The Postgres branch asked pg_get_serial_sequence(table, 'id') about every table a fixture had inserted into. That function raises 42703 when the column does not exist, rather than returning null, so any seed touching a table keyed on something else took down the fixture. host_sidebar_preferences is keyed on user_id and has no id at all, which is why the Postgres job on dev-2.7.0 fails for every pull request. Drive the lookup from information_schema so a missing id column yields no row instead of an error. A text primary key still returns a null sequence and is still skipped, as before. * chore: install the git hooks that were already configured (#1174) husky, lint-staged, commitlint and their config have been in the repo since v1.8.0 (#429): .husky/pre-commit runs lint-staged, .husky/commit-msg runs commitlint, the lint-staged globs are in package.json and the commitlint rules in .commitlintrc.json. None of it has ever run. husky only takes effect once it sets core.hooksPath, and that happens in the prepare lifecycle script, which the package did not define -- so every clone installed the tooling and left the hooks unwired. That is why formatting keeps failing in CI rather than locally: three of the four open pull requests fail lint-and-build on prettier alone, touching between one and five files each, and the check is the first place anyone finds out. prepare falls back to true so a checkout without a .git directory cannot break installation. The Docker build passes --ignore-scripts, so it never runs this at all. Also pin the Prettier extension to the repo's own copy via prettier.prettierPath, and let .vscode/settings.json out of .gitignore so it applies to everyone. The extension bundles its own prettier otherwise, which formats to a different version's rules than the one CI enforces. * fix: derive the ssh_credentials rebuild from the live schema (#1172) The startup rebuild that drops the old username NOT NULL constraint restated the table's columns as a literal and then copied rows with INSERT INTO temp SELECT <every live column>. The table has gained columns since that literal was written — cert_public_key, pin, sort_order and sync_id are all added by addColumnIfNotExists before the rebuild runs — so the destination was narrower than the source. SQLite rejected the INSERT on a column count mismatch, the error was swallowed as a warning, and the constraint survived every restart. Read the CREATE TABLE statement back from sqlite_master and rewrite just the table name and the username constraint, so the replacement table cannot fall behind the real one. Copy rows by explicit column name rather than positionally, and replay the table's indexes, which DROP TABLE would otherwise take with it along with the sync_id uniqueness. * fix: make audit_logs.user_id nullable on fresh SQLite installs (#1171) The audit trail is meant to outlive the account it belongs to: deleting a user nulls user_id and keeps username for attribution. schema.ts, the Drizzle migrations and AuditLogRepository.anonymizeByUserId were all written against that, but the runtime bootstrap still created user_id TEXT NOT NULL. A second CREATE TABLE IF NOT EXISTS further down migrateSchema() had the correct nullable column, but it can never run — the primary bootstrap has already created the table, so IF NOT EXISTS is a no-op. Every fresh install therefore got the old constraint, and user deletion failed with "NOT NULL constraint failed: audit_logs.user_id" for any account that had logged in at least once, via both the admin delete path and the OIDC account-link cleanup. Fix the primary bootstrap, and rebuild the table on existing databases using the same pattern already used for ssh_credentials.username, since SQLite cannot ALTER a column. * fix: key the sync upsert on the row it just looked up (#1175) A sync push locates the stored row twice -- once to decide insert vs update, once to write it -- and the two lookups were spelled out separately. Only the read knew about singleton entities; the write always keyed on table.id. userPreferences is the only singleton, and user_preferences is the one synced table with no id column: its primary key is user_id. table.id was therefore undefined, and drizzle emitted a comparison with nothing on its left: ( = ? and "user_preferences"."user_id" = ?) The insert branch was unaffected, so the first push of preferences succeeded and every push after it -- the steady state -- failed with SqliteError: near "=": syntax error. Preference sync never converged, and both sides ship the same handler, so the desktop's embedded backend failed identically. Extract the lookup into locateSyncRow() and use it for the read, the update and the tombstone delete, so the three cannot drift apart again. The tombstone path already handled singletons correctly; it now shares the one expression rather than keeping a third copy of it. * fix: refuse an SSH connection whose host id resolves elsewhere (#1176) A client identifies a host by the numeric row id of the database it is displaying. With the desktop connection origin set to "Remote server", that id is resolved against the sync server's ssh_data instead, and the two autoincrement sequences need not line up -- they diverge as soon as each side accumulates inserts and deletes in a different order. resolveHostById() then returns whichever row owns that id here, and the handler takes the address, the credentials, the jump hosts and the stored host key from it. The session opens on a machine the user did not pick, while the host list, host details and export all keep showing the right one. Commands run on the wrong server, a host key mismatch is reported for the wrong reason, and anything typed at the prompt goes to the wrong place. Compare the resolved address against the one the client sent, and refuse when they disagree. Checking at the point the row is loaded covers every use of it rather than each site separately. Addresses are compared with brackets stripped and casing folded, so an IPv6 literal or a hostname written differently is not treated as a different machine; when the server has no address stored, the client's own details are used as before. This stops the wrong-machine session. It does not make delegated connections work when the ids have drifted -- that needs the host to be addressed by syncId across the boundary, which the connection protocol does not currently carry. * fix: refuse SFTP and Docker console on a mismatched host id too (#1177) The wrong-machine guard added for SSH covered one of the paths that resolve a client-supplied host id against this server's ssh_data. The file manager and the Docker console take the same id from the same client and dial whatever row owns it here. The file manager then browses, edits and deletes files on that machine, and the Docker console attaches to its daemon -- both while the UI shows the host the user actually picked. Reuse hostAddressMismatch at each point the row is loaded. The two file manager sites sit inside "failed to resolve credentials, carry on" handlers, so the refusal is a distinct error type those catches rethrow; swallowing it would resume the connection this is meant to stop. The Docker console reports over its socket, as it does for every other refusal. The user-facing wording now lives next to the check instead of being written out at each site. Still uncovered, and not fixable this way: file-manager's transfer session, jump-host-chain and the proxmox routes resolve an id with no client-supplied address to compare it against. Those need the host to be addressed by syncId across the boundary. * feat: address hosts by syncId when a connection is delegated (#1178) A numeric host id belongs to the database that produced it. The desktop app lists hosts from its embedded database and names them by row id, so when a connection is delegated to a sync server that id is resolved against a different table, whose autoincrement sequence has no reason to agree. The row it lands on is a different machine, and it supplies the address, the credentials, the jump hosts and the stored host key. #1176 and #1177 made that refuse rather than connect. Refusing is right, but it leaves "Remote server" unusable once the ids have drifted, which is the state the reporter was in. syncId already names a host identically on both sides -- remote sync relies on it, ssh_data.sync_id is unique, and the API already returns it. It just never reached the backend: hostToSSHHost() builds its result field by field and dropped it. Carry it through, and resolve with it when it is present: resolveHostBySyncId(syncId, userId) // translate, then reuse -> findHostIdBySyncId(syncId) // this database's own row id -> resolveHostById(hostId, userId) // permissions, decryption, audit The translation is deliberately not scoped to a user -- sync_id is unique across the table and a shared host belongs to someone else -- so access stays with the permission check in the id-based path, which the new tests cover. An unknown syncId resolves to nothing rather than falling back to the numeric id: an unknown host is precisely where guessing picks the wrong machine. Clients that send no syncId are unchanged, address comparison included, so an older desktop keeps its safety net instead of breaking. * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix: keep already-shared hosts sharing their SSH authentication (#1179) Sharing a host used to hand the owner's SSH authentication to the recipient unconditionally. 2.6.1 put that behind ssh_data.share_ssh_auth, added as NOT NULL DEFAULT 0. Existing rows took the default, so every host shared before the upgrade stopped supplying credentials the moment the column appeared. The snapshot in collectProtocolSnapshots() is guarded by host.shareSshAuth, so nothing was captured; resolveRecipientSharedHostAuthentication() then fell through to "required" and the recipient got "No valid authentication method provided" on a host that had worked the day before. Downgrading to 2.6.0 restored it, since that code has no such column to consult. Backfill the flag for hosts that already appear in host_access. That is where the previous behaviour was in effect and where the owner had already agreed to share; hosts nobody has shared keep the new default and stay off until their owner shares them. Guarded by a settings key so it runs once. Without that, an owner who turns sharing back off would have it turned on again by the next restart. * fix: let a single credential disable 2FA again (#1180) The disable dialog has one field, labelled "Enter TOTP code or password", and its caller passes that value as disableTOTP(input) -- so it arrives as `password` with `totp_code` undefined. That call has been unchanged since v2.3.0. 2.5.1 changed the route to require both: if (!totp_code || (!userRecord.isOidc && !password)) -> 400 replacing `const credential = password || totp_code`. The first check has rejected every attempt since, whatever the user typed, so nobody has been able to turn 2FA off -- the client reports the generic "Failed to disable 2FA", which hides which check failed. Take one credential again and try it as a TOTP code, a backup code, then the account password. verifyTotpReauth still refuses the password itself, so that comparison stays in the route; an OIDC user has no password hash and reaches neither. The backup-codes route has the same shape but no caller in the UI -- its codes are returned when TOTP is enabled -- so it is left alone rather than changed blind. * fix: attach user-managed CA certificates over SFTP too (#1181) opkssh-cert-auth.ts exports two helpers that end in the same _applyCertToConnection: setupOPKSSHCertAuth, and setupCACertAuth for user-managed CA-signed -cert.pub files. The file manager called the first one twice and the second one never. So a host whose key is paired with a CA-signed certificate authenticated in a terminal and failed over SFTP, while OPKSSH certificates -- going through the other helper -- worked in both. The file manager was not missing certificate support in general; it was missing one of the two paths into it. The connection also never carried the certificate to begin with: cert_public_key was not among the fields copied into resolvedCredentials, so both places that build an SFTP connection now read it and attach it where the private key is prepared -- the dedicated transfer session and the main connect route. An unusable certificate is logged and skipped rather than failing the connection. The key alone may still be accepted, which is what happened while this was not wired up at all, and turning that into a hard failure would break setups that currently work. Reported in #1160 with the call-site asymmetry already traced; the reporter noted they could not confirm the link to their failure, having moved off SSH CAs. The asymmetry is real either way and reproduces the symptom exactly. * fix: authenticate the desktop Docker console WebSocket (#1182) The console WS opted out of the query token: buildOriginWsUrl({ ..., includeLocalJwt: false }) leaving it with no credential at all on the desktop. The browser WebSocket API cannot set an Authorization header, and while Electron's main process injects a remembered JWT cookie, it requires an exact origin match -- the cookie belongs to the API origin (localhost:30001) while the console connects to 127.0.0.1:30009, so nothing is attached. The backend then closes the handshake with 1008 before it logs anything, which is why the log has no docker-console entries while stats and logs polling keep succeeding on the same host. The web build is unaffected: it connects same-origin and its cookie is sent normally. Drop the opt-out so the console carries the local JWT like the SSH terminal does -- the same token, the same query parameter, and the backend already reads it there. Guacamole passes includeLocalJwt: false too, but rdp/vnc/telnet always resolve to "remote", so that call never reaches the local branch. * fix: use getClientIp in getRequestMeta for correct audit-log IPs (#1183) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: use getClientIp in getRequestMeta for correct audit-log IPs getRequestMeta had near-duplicate, strictly worse forwarded-header logic: the array branch didn't split/trim, there was no socket-peer fallback, and it returned "" instead of "unknown". Delegate to getClientIp so the audit trail gets the same correctness as the terminal login-alert path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * feat: add terminal image handoff (#1170) * chore: sync Crowdin translations * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * feat: add terminal image handoff Add authenticated browser upload and clipboard image handoff for terminal agents. Normalize images through Sharp, enforce storage and request limits, preserve host-visible paths, and provide a stable three-button terminal toolbar. * docs: document terminal image handoff deployment --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix(desktop): stop suppressing the update prompt, and make the version badge reachable (#1167) * fix(desktop): stop suppressing the update prompt for users who need it The startup update modal stored its dismissal under the local app version rather than the remote version being offered, and the up-to-date branch wrote that key with no user interaction at all. A user who launched while current had their own version recorded; once the next release shipped, `dismissedVersion === currentVersion` still held and the modal was skipped on every launch. It reappeared only after the user had already updated -- the inverse of what it is for. Present since v2.3.0. Key the dismissal on the offered remote version instead. The change is backward compatible: an existing key holding 2.6.0 compares unequal against a remote 2.6.1, so affected installs are prompted on their next launch. When the check itself fails there is no remote version, so nothing is recorded and no future prompt is suppressed. That left the version badge as the only remaining signal, and it was an inert span on both surfaces that render it -- the profile panel and the dashboard stats bar -- even though the `getVersionInfo()` response it is built from already carries `latest_release.html_url`. Extract the duplicated badge into `components/version-badge.tsx` and make the update case a link to the release, with an accessible name that says where it goes. The beta and stable cases stay inert. `getVersionInfo()` returned `Record<string, unknown>`, so the release URL was unreachable without a cast; give it a `VersionInfo` type that keeps an index signature, since `SystemOverviewWidget` reads `updateAvailable` off the same response. * test: cover the read that actually reaches the badge The extracted VersionBadge is unit-tested, but the line that decides whether it ever receives a URL -- pulling `latest_release.html_url` out of the version response -- was duplicated at both call sites and asserted nowhere. A wrong property there compiles (the response type keeps an index signature) and every existing test still passes. Give it a name, `releaseUrlFrom`, use it from both surfaces, and test it: the happy path, a response with no release, a release with no URL, and a missing response, since the caller's fetch can reject. Empty string is the contract the badge reads as "nothing to link to", so it stays an inert span rather than rendering a dead anchor. * docs: state the index signature's real reason The comment claimed the version endpoint carries fields beyond the typed ones, citing `updateAvailable`. It does not -- `GET /version` returns status, localVersion, version, remoteVersion, latest_release, cached and cache_age, and nothing else. SystemOverviewWidget reads `updateAvailable` off it regardless, which is why the permissive index signature has to stay, but that is a stale read rather than an undocumented field. Say so accurately. * Send alerts in Discord channels with Webhooks (#1158) * feat(utils): add discord webhook sender Add a utility to send alert embeds to Discord webhooks. * fix(utils): validate DNS and use global fetch for outbound requests Prevent private destination access and rely on global fetch after DNS validation. * chore(logger): include extra context in logs Show additional sanitized context entries for clearer diagnostics. * feat(alerts): support discord channel type in routes and engine Accept discord channels and route alerts to the Discord sender. * feat(ui): add Discord option to notification channel dialog Allow creating/editing Discord webhook channels with username/avatar. * fix(ui/api): accept structured config payload for notification channels Allow the client to pass structured config objects (or strings) when creating/updating channels. * chore: sync Crowdin translations * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * chore: add url to SENSITIVE_FIELDS for discord url * fix: enforce SSRF protection on outbound fetches Use `undici.fetch` with the custom DNS lookup hook to ensure the validated DNS resolution is the one used for the connection. Fix DNS lookup/address validation bugs and add coverage for private, public and invalid addresses, including the resolution issue affecting Discord endpoints. * chore: prettier format * fix: validate all DNS addresses and close dispatcher * fix DNS lookup validation and callback handling * update safe outbound fetch tests * ensure created dispatcher is properly closed * chore: remode url from SENSITIVE_FIELDS for other logs --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix tmux UTF-8 path handling (#1157) Co-authored-by: Carl <scarlettme@qq.com> * chore: update package lock * chore: update gitnore * fix: [BUG] (#1049) https://github.com/Termix-SSH/Support/issues/1049 * fix: test commitlint path fix (#1021) * fix: SGR mouse-tracking escape codes printed as text (#1023) * fix: quote $1 in commit-msg hook so it works from git worktrees * fix: [BUG] could not connect to the database (#1057) https://github.com/Termix-SSH/Support/issues/1057 * fix: [BUG] VNC connect macOS screen sharing failed (#1063) https://github.com/Termix-SSH/Support/issues/1063 * fix: [BUG] Meta key (#1075) https://github.com/Termix-SSH/Support/issues/1075 * fix: [BUG] Remote sync doesn't work with Termix behind nginx proxy (#1085) https://github.com/Termix-SSH/Support/issues/1085 * fix: [BUG] webhook not working (#1080) https://github.com/Termix-SSH/Support/issues/1080 * fix: [BUG] First server sync doesn't refresh UI (#1084) https://github.com/Termix-SSH/Support/issues/1084 * fix: [BUG] How to enable SSL using custom certificate (#1083) https://github.com/Termix-SSH/Support/issues/1083 * fix: [BUG] Sudo Password Auto-fill Persistance (#1098) https://github.com/Termix-SSH/Support/issues/1098 * feat: [FEATURE] Expand Snippets Function (#1031) https://github.com/Termix-SSH/Support/issues/1031 * feat: [FEATURE] (#1055) https://github.com/Termix-SSH/Support/issues/1055 * feat: [FEATURE] Support for Headscale API Keys (hskey prefix) and Custom API Endpoints (#1013) https://github.com/Termix-SSH/Support/issues/1013 * feat: [FEATURE] Allow paste on non https (#1026) https://github.com/Termix-SSH/Support/issues/1026 * feat: be-azerty layout (#1073) https://github.com/Termix-SSH/Support/issues/1073 * feat: Keyboard shortcuts to move between open tabs (#1069) https://github.com/Termix-SSH/Support/issues/1069 * feat: Session Logs as a downloadable text file (#1058) https://github.com/Termix-SSH/Support/issues/1058 * fix: persist and auto-fill saved SSH and sudo passwords * fix: persist docker runtime selection and docker manager UI issues * feat: Allow excluding specific mounts from disk usage metrics (#1046) https://github.com/Termix-SSH/Support/issues/1046 * feat: Expand Snippets Function (#1031) https://github.com/Termix-SSH/Support/issues/1031 * chore: restore the prettier baseline on dev-2.7.0 (#1185) Five files on dev-2.7.0 do not match prettier, so `npx prettier --check .` fails and takes lint-and-build with it — on every pull request, whatever it changes. Formatting only, produced by `npx prettier --write` on exactly the files the check names. No logic touched: tsc passes for both configs, backend 148 files / 1106 tests and UI 71 files / 479 tests all pass. * test: keep the tmux escaping test runnable on Windows (#1184) The escaping check ran its command through /bin/sh. That binary does not exist on Windows, and Windows is a supported platform for the desktop app, so `npm test` fails there on a test about string quoting. CI is ubuntu-only and would never see it. Assert the escaped string directly, which covers the rule on every platform, and keep the round trip through a real shell as a separate case guarded by platform -- it is the stronger evidence where a shell exists. * chore: drop the unreachable table probes from migrateSchema (#1186) Eleven blocks in migrateSchema() guarded a CREATE TABLE IF NOT EXISTS behind SELECT id FROM <table> LIMIT 1, for tables the primary bootstrap had already created earlier in the same startup. The probe could not throw, so the catch never ran. Two of those unreachable copies had drifted from the definition actually in use. sessions had lost ON DELETE CASCADE, and session_recordings still carried user_id TEXT NOT NULL with ON DELETE CASCADE and no username -- the shape from before audit trails were made to outlive the account. They would have taken effect had anything ever reordered startup. Kept, because they are not the same thing: - blocks whose catch runs ALTER TABLE ADD COLUMN. CREATE TABLE IF NOT EXISTS is a no-op on a table that exists, so a database created before a column was added still needs the ALTER. Those probe a column, not a table. - blocks that are a table's only creation point. - the user_open_tabs block, which is a data migration; its SELECT is a precondition, not a probe. Deletion only, no behaviour change. * fix: repair the frontend type-check and clear the 299 errors behind it (#1189) * fix: repair frontend type-check configuration and the errors it exposed The root tsconfig.json is solution-style with "files": [], so the `npx tsc --noEmit` that CI runs compiles nothing at all. Frontend types have therefore never been checked, and 299 errors had accumulated behind that no-op. This clears just over half of them; nothing here changes runtime behaviour. Configuration: - "@/types" resolved through the "@/*" fallback to src/ui/types, which does not exist. Added an explicit mapping to src/types/index.ts. - src/vite-env.d.ts sits outside the include list, so import.meta.env and the ?url import suffix were unknown. Added. - src/ui/types/ held a single file, keybindings.ts, while every other shared type lives in src/types/. Six modules imported it as "@/types/keybindings" and silently resolved to nothing. Moved. Type definitions that had fallen behind the code: - guacamoleConfig and terminalConfig were Record<string, unknown> in ui-types while the editor read concrete fields off them. Both now use the real interfaces; GuacamoleConfig is extracted from its inline definition in guacamole-api.ts so the two cannot drift again. - customThemeColors and TerminalTheme["colors"] described the same object with different optionality. Aligned. - FileWindow declared its own SSHHost whose authType was "password" | "key", which no longer matches the eight the app supports. - connectSSH and listSSHFiles returned Record<string, unknown>, so every field the callers destructured arrived as unknown. - AxiosRequestConfig and AxiosResponse were used without being imported. Also adds asHttpError() for the handful of catch blocks that reached into an unknown binding, and narrows the Host | HostFolder comparator and the RailItem union at the points where the discriminant was not carrying. Note: dbHealthMonitor.reportDatabaseError was being called with a second argument it does not accept, so the authenticated-or-not flag was already being discarded at runtime. Dropped the argument to match the signature; whether that flag was meant to gate the report is worth a separate look. * fix: clear more of the frontend type-check baseline Continues the previous commit; 140 errors down to 70. Three of these were real defects rather than missing annotations. Defects: - DashboardTab counted active tunnels by comparing status to "CONNECTED", but CONNECTION_STATES.CONNECTED is "connected" and that is what the tunnel manager emits, so the count was always zero. Now compares against the constant. - QuickActionsCard requires isAdmin and gates a block of admin-only actions on it, but neither call site passed it — those actions never rendered. Both call sites also passed isAdmin to HostStatusCard, which does not accept it; the prop had evidently been moved and the call sites missed. - The host editor stores jump host ids as strings and sent them straight to an API typed for numbers. Backend host lookups compare against an integer column, which a string does not match on Postgres or MySQL. Converted. Types brought in line with the data: - Host and HostData were missing hasPassword, hasSudoPassword, sortOrder, instanceId, connectionOrigin, vaultProfileId, syncId, and the "vault" authType; TabContextTab was missing the "tunnel" tab, which TabContext already branched on. - statsConfig and terminalConfig used inline shapes that had drifted from StatsConfig and TerminalConfig. Both now reference the real interfaces; excludedMounts, which the editor reads, was added to StatsConfig. - downloadSSHFile, generateKeyPair and generatePublicKeyFromPrivate all returned Record<string, unknown> while callers read named fields. - The Guacamole declarations were missing Keyboard.reset, Client.onfile, InputStream.sendAck, Status.Code and BlobReader, all already in use. - NetworkTopologyNode/Edge could not be discriminated, though the graph code tells them apart by testing for source/target. ProxyNode.type is now 4 | 5 | "http" | "socks4" | "socks5". The editor writes the string spellings while proxy-helper.ts tests for "http" and casts everything else to 4|5 before handing it to the socks client, so a chained proxy reaches it as "socks5" rather than 5. Typed as what is actually stored; reconciling the two spellings needs a migration decision and is left alone here. * fix: continue clearing the frontend type-check baseline 70 errors down to 44. Dead configuration removed: - Terminal set terminal.options.bellStyle on xterm, which dropped the option in v5. The host editor still exposes the setting and stores it; it has simply had no effect on the terminal since that upgrade. Making the bell work again means handling the onBell event and is left alone. - CodeEditor passed scrollPastEnd to basicSetup, which has no such option. - FileManager passed an id to openWindow, which assigns its own and discards what it is given — the component was already being rendered under a different id than the one the caller held. Widgets that were registered but unreachable: - DockerActivityWidget and SshQuickConnectWidget register under "docker_activity" and "ssh_quick_connect", neither of which was in WidgetTypeId, and both referenced config interfaces that did not exist. Added the ids and the two interfaces, inferred from their edit forms and defaultConfig. More endpoints given their real return types: getRecentFiles, getPinnedFiles, getFolderShortcuts (arrays, not records), downloadSSHFile, copySSHItem, generateKeyPair, generatePublicKeyFromPrivate and getSnippets. parseGuacamoleConfig() handles the host row carrying guacamoleConfig either parsed or as raw JSON, which GuacamoleApp was reading fields off directly. TerminalHostConfig was missing name, which it reads for the activity log. * fix: continue clearing the frontend type-check baseline 44 errors down to 17. Host and AuditLog are now type aliases rather than interfaces. An interface has no implicit index signature, so neither could be assigned to the `[key: string]: unknown` shapes that TerminalHostConfig, HostMetricsTab's HostConfig and several helpers declare — eight errors came from that alone. More dead configuration: - i18n passed checkWhitelist to the language detector, which no longer has that option; supportedLngs already covers it. - SSHAuthDialog passed scrollPastEnd to basicSetup, same as CodeEditor. - AudioPreview's onLoadedMetadata never fired: react-h5-audio-player spells the prop onLoadedMetaData. - MarkdownRenderer destructured `inline` from code(), which react-markdown removed in v9, so the flag was always undefined and every inline span took the block branch when it happened to carry a language class. Now derived from whether a className is present at all. - SnippetsPanel put a title prop on a lucide icon, which does not forward it; changed to aria-label so the hint is actually reachable. updateHostConfig in TabContext replaced tab.hostConfig wholesale with the six-field literal it receives, dropping everything else the tab held about the host. It now merges onto the existing config. Also: getReleasesRSS, getUserAlerts and getVersionInfo have real return types (UpdateLog kept private copies of two of them, and VersionInfo was missing `version`, which the endpoint sends and the panel renders); wakeOnLan and vncCredentialId get the numeric ids they are typed for; and the tmux formatter takes i18next's TFunction instead of a hand-written signature it does not satisfy. * fix: clear the last frontend type errors and make CI actually run the check Baseline is now zero, so the check can be turned on. `npx tsc --noEmit` — what CI ran and what `npm run type-check` was — compiles nothing: the root tsconfig.json is solution-style with "files": [], and plain tsc does not follow project references. Both are now `tsc -b`, which builds tsconfig.app.json and tsconfig.node.json. Verified by planting a type error and watching the command fail. Last defects in this batch: - patchOpenTab could not carry hostId, so quick-connect's "save this host and attach the tab to it" call was passing a field excluded from the type all the way down. The column exists and updateForUser spreads whatever it receives, so the write worked; only the types disagreed. Widened front to back. - The file-comparison window opened without x, y, width or height — every other openWindow call passes them — and sent a `type` field WindowInstance does not have. - HostEditor gated a block on authType === "warpgate", which is not one of the eight authType values. Unreachable, and it held only a label and a description. Removed. - FileManager passed onLoadDirectory to a sidebar that neither declares nor reads it, and FileManagerApp passed embedded to a FileManager that has no such prop. - TunnelApp's minimal Host was missing three required flags. The remainder were assertions at boundaries that are genuinely loose: bulk host import takes rows assembled from untyped input and validates them server-side, and a vi.fn() whose body only throws infers never. * feat: add drive file browser and drag-and-drop upload for RDP (#1187) Drive redirection could already be enabled per host, but the redirected drive lived inside guacd with no way to reach it from the browser: the client never handled onfilesystem, so the mounted volume was writable from Windows and invisible from Termix. Add a file browser panel that lists the drive, downloads files, and uploads them, plus drag-and-drop onto the display which opens the panel and uploads into the directory currently shown. The disable-upload and disable-download connection settings are honoured by the UI, not just passed to guacd. A rejected upload stops the BlobWriter without firing onerror or oncomplete, so the error ack is watched explicitly; otherwise the transfer would hang forever. Directory reads carry a deadline for the same reason. Also declares Guacamole.Object, Client.onfilesystem, BlobReader and BlobWriter in the local type definitions, which previously omitted them. * fix: keep the mouse working on touch-capable devices in RDP/VNC (#1190) Reported as "mouse input broken, keyboard fine" after 2.5.1 (#1102). 2.5.1 bound Guacamole.Mouse unconditionally. 2.6.0 replaced that with a three-way branch on touchMode, and the touch branches replace the mouse binding instead of adding to it: if (touchMode === "touchscreen") new Guacamole.Mouse.Touchscreen(el) else if (touchMode === "touchpad") new Guacamole.Mouse.Touchpad(el) else new Guacamole.Mouse(el) The two do not overlap. Guacamole.Mouse listens for mousedown/mousemove/ mouseup; Touchscreen and Touchpad listen only for touchstart/touchmove/ touchend. So in a touch mode nothing is listening for the mouse at all. touchMode defaults to "touchscreen" whenever navigator.maxTouchPoints > 0, which is true of every laptop with a touchscreen — machines that are still driven by a mouse. Those users lost the pointer entirely while the keyboard kept working, because Guacamole.Keyboard is bound independently. The physical pointer is now always bound and a touch emulator is layered on top when one is selected. Extracted to bindPointerInput() so the binding is testable; the test fails against the old branch. Note the issue also carries a second, unrelated report where well-formed mouse frames do reach guacd and the VNC leg ignores them. That one is not this, and the guacd image is pinned to 1.6.0 in both 2.5.1 and 2.6.1, so it is not an upgrade either. * fix: deduplicate /api/folders requests to prevent intermittent folder disappearance (#1191) * chore: sync Crowdin translations * fix: deduplicate /api/folders requests to prevent intermittent folder disappearance getSSHFolders() had no request deduplication while getSSHHosts() used a TTL cache with in-flight dedupe. When loadHosts() fired multiple times during rapid navigation between Credentials and Hosts panels, the folder response could arrive after the hosts response, causing the sidebar tree to render without folder metadata. - Add foldersCache (10s TTL) in hosts-request-cache.ts - Wrap getSSHFolders() API call in getCachedSSHFolders() - Invalidate folders cache on renameFolder, updateFolderMetadata, deleteAllHostsInFolder, and renameCredentialFolder - Include foldersCache in invalidateHostsAndStatusCaches() Closes Termix-SSH/Support#1103 Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> --------- Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * chore(deps): bump undici from 8.9.0 to 8.10.0 in the prod-minor-updates group (#1195) * chore: sync Crowdin translations * chore(deps): bump undici in the prod-minor-updates group Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.9.0 to 8.10.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: proxmox metrics integration * feat: add folder select to the host multi select feature * feat: implement context aware terminal toolbar with quick links, host info, image pasting, etc * feat: made toolbar open file manager at path * fix: delete folder route not invalidating host list cache * fix: match host list icons with tab bar iconfix * fix: change sidebar reset button icon to seperate against fullscreen button * feat: unify connection system and add connection logs to guacd hosts * fix: make mobile terminal scrollback match xterm wheel behavior (#1198) * fix: route mobile terminal scrolling through xterm viewport * docs: document mobile terminal touch scrolling * chore: add a note to not place files in docs * chore: remove touch imput from docs * feat: improve snippet system with variable snippets and collapse settings * feat: new fleet system with snippet, packages, files, and inventory features * fix: command pallete not loading new activity and made enter load first item * feat: add subhost from parent host organization feature * feat: add workspaces feature to save tab layout * perf: greatly improved performance across metrics polling and host management for enterprise users * feat: add a onboarding system with a new interface simplicity system * feat: finalize the multi dialect database system * fix: bind trusted MFA devices to client installs (#1202) * fix: merge OIDC group claims across sources (#1203) * fix: allow disabling SSH keepalives (#1204) * fix: distinguish reachable and available hosts (#1206) * fix: throttle session activity persistence (#1207) * fix: preserve saved RDP connection settings (#1208) * fix: authenticate tunnel status stream (#1209) * fix: select quick-created credentials (#1210) * fix: stagger initial metrics collection (#1211) * fix: stagger initial metrics collection * fix: admit reachable hosts to initial metrics * fix: prevent long host names shifting dashboard metrics (#1205) * feat: add global touch input settings (#1201) Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: keep host list row sizing stable (#1213) * fix(guacamole): correct Windows key mapping (#1216) * fix: normalize OIDC discovery issuer URLs (#1218) * fix: prompt for RDP domain credentials (#1212) * fix: route status checks by connection origin (#1214) * fix: restore desktop Tailscale configuration (#1215) * fix(docker): restore Node 24 for ssh2 native crypto (#1217) * feat: added new automations feature with events, channels, and steps * feat: allowed some tabs in the app rail to be opened as its own tab or in a new right sidebar * feat: expand onboarding process with more customization/features * feat: initial implementation of the termix ai feature * chore: run linter * fix: issue #424 (#424) https://github.com/Termix-SSH/Support/issues/424 * fix: Not working without internet connection. Missing OPKSSH binary in pre-built image. (#1133) https://github.com/Termix-SSH/Support/issues/1133 * fix: SQLite forceSave on telemetry writes causes periodic SSH terminal stalls in 2.6.x (#1109) https://github.com/Termix-SSH/Support/issues/1109 * feat: How to enable SSL using custom certificate (#1083) https://github.com/Termix-SSH/Support/issues/1083 * fix: show profile API key after creation (#1221) * feat: add trusted proxy authentication (#1222) * fix: clarify SSH agent authentication (#1224) * feat: add first-class split screen tabs (#1226) * feat: add split tab data model * feat: make split screens top-level tabs * feat: persist and manage split layouts * feat: launch native RDP on Windows desktop (#1223) * feat: launch native RDP on Windows * style: format native RDP launcher * feat: enhance custom disk and network metrics (#1220) * feat: enhance host disk and network metrics * fix: align enhanced metrics types * fix: preserve Proxmox guest identity on edit (#1219) * fix: preserve Proxmox guest identity on edit * fix: type Proxmox guest source metadata * chore: dead-code cleanup and small refactors (#1225) * chore: remove dead code and unused exports * chore: remove unused api client functions * chore: remove unused backend helpers * refactor: extract getErrorMessage helper for repeated error extraction * refactor: unify error message extraction across backend with getErrorMessage * refactor: unify error message extraction in frontend with getErrorMessage * refactor: merge duplicate imports from the same module * refactor: use Array.includes in TabBar * chore: drop biome, keep prettier as the single formatter * style: apply prettier formatting to refactored files * fix: close active tab with Ctrl+W on Windows * fix: make tray Quit terminate the desktop app * feat: verify host transfer integrity * fix: reuse transfer sessions during verification * feat: select the fastest host transfer route * feat: tune host transfers adaptively * feat: adapt background polling to activity (#1233) * feat: adapt background polling to activity * feat: extend adaptive polling coverage * feat: make polling cost and network aware (#1234) * feat: make repeat navigation feel instant (#1235) * feat: make file operations feel immediate (#1236) * feat: preload likely user actions (#1237) * feat: preload likely file previews * feat: preload likely host tools * feat: preload likely file viewers * fix: replace stale terminal input listeners * feat: add links to docs for all new features * chore: update readme * fix: warn before discarding host changes (#1229) * feat: learn local host action preferences (#1238) * feat(terminal-toolbar): add bounded movable desktop toolbar (#1239) * feat: add local adaptive decision engine (#1240) * feat: adapt speculative resource usage (#1241) * feat: persist adaptive transfer profiles (#1242) * Fix .preferred_username when using LDAP login. (#1243) * chore: sync Crowdin translations * Fix .preferred_username when using LDAP login. Strips internal LDAP prefix from username. --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * feat: learn direct transfer routes (#1244) * feat: learn speculative preload usefulness (#1245) * fix: - Adjusting the SSH Authentication from Vault to something else fails (#1152) https://github.com/Termix-SSH/Support/issues/1152 * fix: terminal graphical display, special characters inserted, distorted - `midnight comma... (#1145) https://github.com/Termix-SSH/Support/issues/1145 * feat: single click on host in list opens session - should be only on double click (#1146) https://github.com/Termix-SSH/Support/issues/1146 * feat: Terminal: custom font/ font selection/ how-to for adding a font - `MesloLGS NF` (#1140) https://github.com/Termix-SSH/Support/issues/1140 * fix: revert host single click to open session, make double click an option (#1146) Single click opens a session again by default. The old double click behavior can be turned on in Customize Sidebar. * chore: drop prettier check from beta release workflow, run formatter * chore: patch dependabot vulnerabilities via npm overrides * fix: reset adaptive resource state between tests to stop cross-test leaks * feat: replace terminal toolbar density popover with a native select * fix: pin hardwareConcurrency in adaptive budget tests so CI cores don't change the tier * fix: allow dylib files in mac universal arch rules so mas build packages sharp * feat: add file manager trash (#1250) * feat: add inheritable connection defaults (#1246) * feat: add desktop local terminal (#1247) * feat: add interactive terminal macros (#1248) * feat: add adaptive SSH local echo (#1249) * fix: sync desktop host changes immediately (#1252) * fix: route desktop sharing through synced server (#1253) * Fix terminal image uploads and add safe diagnostics (#1254) * feat: add configurable terminal image storage backends * feat: add admin image storage settings * fix: preserve native clipboard PNG uploads * fix: quote terminal image paths safely * docs: record image storage security remediation plan * fix: close remote image SFTP channels * fix: restrict remote image SFTP permissions * fix: bound remote image SFTP writes * fix: add best effort remote image retention * fix: cap normalized image output size * fix: bound concurrent image processing * fix: fail closed on local image inspection errors * test: cover fail closed image storage and atomic settings * fix: enforce remote image quota and upload admission * fix: serialize remote quota and verify existing paths * fix: use synchronous sqlite settings transaction * fix: keep settings transactions portable across dialects * fix: bound image processing admission queue * fix: serialize remote image quota across processes * fix: recover stale remote image locks safely * fix: preserve remote storage errors during unlock * fix: fail closed when stale lock removal fails * fix: harden image upload resource and storage cleanup * fix: bound SFTP operations and lock lifetime * fix: bound SFTP acquisition and cleanup callbacks * fix: close late SFTP channels and test cleanup stalls * fix: preserve SFTP inspection client context * feat: add image upload source metadata * fix: expose image upload metadata in logs * chore: exclude internal plan from pull request * style: apply prettier formatting --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: batch of security hardening fixes (#1255) * fix: bind desktop auto-session loopback check to the TCP peer address * fix: escape HTML entities in Vault OIDC callback responses * fix: route homepage ping and rss through the SSRF-safe outbound fetch * fix: scope tunnel status endpoints to hosts the caller can access * chore: update release notes * chore: update release notes to write more about the ai integration * fix: unbreak windows and macos electron builds after node-pty Install Spectre-mitigated MSVC libs on the Windows runner and cover node-pty's spawn-helper in the macOS universal arch rules. * fix: rework connection defaults ui into a dialog and add missing i18n keys * fix: rework macros panel with i18n, plain text matching, and list layout * feat: add docs links for trash, connection defaults, and local echo * fix: make image storage and trash tests pass on windows * fix: stop docs links squeezing sidebar panel headers * fix: put automations docs link back on the tabs row * fix(desktop): keep Linux credential storage working on unrecognised desktops (#1261) Chromium resolves safeStorage's backend from XDG_CURRENT_DESKTOP and falls back to the basic_text store for any desktop it has no mapping for, which covers every wlroots-style compositor (Hyprland, sway, niri, river). isEncryptionAvailable() reports false for that store, so saveRemoteSyncJwt refused every write and the OIDC sign-in it was storing appeared to succeed. The sync engine then found no JWT and reported the session as expired, which sent users looking at their OIDC provider for a fault that was never there. Name the libsecret backend explicitly on those desktops. They run an ordinary Secret Service, so that is enough to make encryption available again. KWallet desktops keep their auto-detected backend, an explicit --password-store still wins, and no stored secret can be orphaned by the switch because isEncryptionAvailable() gated every write that would have created one. Also stop discarding the {success: false} the main process returns when it cannot store a credential: on a machine with no Secret Service at all, the sign-in now says so instead of silently completing. Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com> * chore: update release notes * chore: update release notes * fix(file-manager): widen trash dialog so names and paths are not cut off * fix(sidebar): stop hover action tray overlapping the row below it * fix(hosts): make real status colors toggle actually apply * feat(local-terminal): add rail button and fix hardcoded tab label * chore: update release notes * fix(ai): hide assistant everywhere when admin disables it globally * fix(automations): fix concurrency race, wire docker and internal event triggers Claim the in-flight slot in the same tick it is checked, poll container state for docker_event triggers, emit the internal events, apply the schedule time zone, and expose the concurrency policy in the editor. * fix(sidebar): rework host and credential drag-to-reorder Adds a lock toggle in the sort menu and fixes reorder positioning, cross-folder drops, and the duplicate drop indicator. * chore(sidebar): drop unused sortKey prop from host and credential trees * fix(sidebar): fix row height in click tray mode so status stripes stop overlapping * fix(onboarding): remove add-first-host step that closed onboarding mid-flow * fix(release): upload release notes so Mac App Store review submission stops failing * chore: sync Crowdin translations for 2.7.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: T3rM1nAt0-R <niraj.sangani91@gmail.com> Co-authored-by: Horziox <horziox.dev@gmail.com> Co-authored-by: William Shi <184219650@qq.com> Co-authored-by: Carl <scarlettme@qq.com> Co-authored-by: Raw_Nuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: njz-cvm <njz@cvm.com> Co-authored-by: Alexandre VARGAS <alexandre.vargas.lopez@gmail.com> Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com>
This commit is contained in:
co-authored by
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ZacharyZcR
kacperpietrzyk
Brennan Neoh
Claude Sonnet 5
T3rM1nAt0-R
Horziox
William Shi
Carl
Raw_Nuke
njz-cvm
Alexandre VARGAS
alexandre-vl
parent
5021ccf3e2
commit
7ae1648c25
@@ -64,6 +64,7 @@ describe("guacamole API origin", () => {
|
||||
await getGuacamoleTokenFromHost(9, "rdp", {
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
domain: "EXAMPLE",
|
||||
});
|
||||
|
||||
expect(remoteApiMock.post).toHaveBeenCalledWith(
|
||||
@@ -72,7 +73,25 @@ describe("guacamole API origin", () => {
|
||||
protocol: "rdp",
|
||||
promptedUsername: "admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "EXAMPLE",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("sends an empty prompted domain for local RDP accounts", async () => {
|
||||
isElectronMock.mockReturnValue(false);
|
||||
|
||||
await getGuacamoleTokenFromHost(9, "rdp", {
|
||||
username: "local-admin",
|
||||
password: "secret",
|
||||
domain: "",
|
||||
});
|
||||
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
|
||||
protocol: "rdp",
|
||||
promptedUsername: "local-admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const statsApiMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const remoteStatsApiMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const sshHostApiMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const resolveConnectionOriginMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
statsApi: statsApiMock,
|
||||
sshHostApi: sshHostApiMock,
|
||||
getRemoteStatsApi: () => remoteStatsApiMock,
|
||||
isElectron: () => true,
|
||||
handleApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/hosts-request-cache", () => ({
|
||||
getCachedServerStatuses: (loader: () => Promise<unknown>) => loader(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/connection-origin", () => ({
|
||||
resolveConnectionOrigin: resolveConnectionOriginMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getAllServerStatuses,
|
||||
getServerMetricsById,
|
||||
} from "../../api/host-metrics-status-api";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.electronAPI = {
|
||||
invoke: vi.fn(async (channel: string) =>
|
||||
channel === "get-remote-sync-config"
|
||||
? { serverUrl: "https://termix.example.test" }
|
||||
: null,
|
||||
),
|
||||
} as unknown as NonNullable<typeof window.electronAPI>;
|
||||
statsApiMock.get.mockResolvedValue({ data: { 1: { status: "online" } } });
|
||||
remoteStatsApiMock.get.mockResolvedValue({
|
||||
data: { 2: { status: "reachable" } },
|
||||
});
|
||||
});
|
||||
|
||||
describe("status check origin routing", () => {
|
||||
it("only asks the embedded backend to poll local-origin hosts", async () => {
|
||||
sshHostApiMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 1, connectionOrigin: "local" },
|
||||
{ id: 2, connectionOrigin: "remote" },
|
||||
],
|
||||
});
|
||||
resolveConnectionOriginMock.mockImplementation(async (host) =>
|
||||
host.connectionOrigin === "local" ? "local" : "remote",
|
||||
);
|
||||
|
||||
await expect(getAllServerStatuses()).resolves.toEqual({
|
||||
1: { status: "online" },
|
||||
2: { status: "reachable" },
|
||||
});
|
||||
|
||||
expect(statsApiMock.get).toHaveBeenCalledWith("/status", {
|
||||
timeout: 2000,
|
||||
params: { hostIds: "1" },
|
||||
__silentRetry: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends an empty allowlist when every host uses the remote server", async () => {
|
||||
sshHostApiMock.get.mockResolvedValue({
|
||||
data: [{ id: 2, connectionOrigin: "remote" }],
|
||||
});
|
||||
resolveConnectionOriginMock.mockResolvedValue("remote");
|
||||
|
||||
await getAllServerStatuses();
|
||||
|
||||
expect(statsApiMock.get).toHaveBeenCalledWith("/status", {
|
||||
timeout: 2000,
|
||||
params: { hostIds: "" },
|
||||
__silentRetry: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("metrics request coalescing", () => {
|
||||
it("shares simultaneous reads for the same host", async () => {
|
||||
statsApiMock.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: { cpu: { percent: 12 } },
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
getServerMetricsById(991),
|
||||
getServerMetricsById(991),
|
||||
]);
|
||||
|
||||
expect(first).toEqual({ cpu: { percent: 12 } });
|
||||
expect(second).toBe(first);
|
||||
expect(statsApiMock.get).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { streamServerSentEvents } from "@/api/sse-stream";
|
||||
|
||||
describe("streamServerSentEvents", () => {
|
||||
it("sends the supplied authentication options and parses split events", async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("event: stat"));
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'uses\ndata: {"tunnel":{"status":"connected"}}\n\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(stream, { status: 200 }));
|
||||
const onEvent = vi.fn();
|
||||
const headers = new Headers({ Authorization: "Bearer token" });
|
||||
|
||||
await streamServerSentEvents(
|
||||
"http://localhost/stream",
|
||||
{ credentials: "include", headers },
|
||||
onEvent,
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("http://localhost/stream", {
|
||||
credentials: "include",
|
||||
headers,
|
||||
});
|
||||
expect(onEvent).toHaveBeenCalledWith({
|
||||
event: "statuses",
|
||||
data: '{"tunnel":{"status":"connected"}}',
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unauthorized stream", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(null, { status: 401 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
streamServerSentEvents("http://localhost/stream", {}, vi.fn()),
|
||||
).rejects.toThrow("status 401");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { releaseUrlFrom } from "../../api/system-status-api";
|
||||
|
||||
describe("releaseUrlFrom", () => {
|
||||
it("hands back the release page so the version badge can link to it", () => {
|
||||
expect(
|
||||
releaseUrlFrom({
|
||||
status: "requires_update",
|
||||
localVersion: "2.6.0",
|
||||
remoteVersion: "2.6.1",
|
||||
latest_release: {
|
||||
tag_name: "release-2.6.1-tag",
|
||||
name: "release-2.6.1",
|
||||
published_at: "2026-08-06T19:49:43Z",
|
||||
html_url:
|
||||
"https://github.com/Termix-SSH/Termix/releases/tag/release-2.6.1-tag",
|
||||
body: "",
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
"https://github.com/Termix-SSH/Termix/releases/tag/release-2.6.1-tag",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an empty string when the response carries no release", () => {
|
||||
// The badge treats "" as "nothing to link to" and stays an inert span, so
|
||||
// a version response without a release must not produce a dead anchor.
|
||||
expect(
|
||||
releaseUrlFrom({ status: "up_to_date", localVersion: "2.6.1" }),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("returns an empty string when the release carries no URL", () => {
|
||||
expect(
|
||||
releaseUrlFrom({
|
||||
status: "requires_update",
|
||||
latest_release: { tag_name: "release-2.6.1-tag" },
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("tolerates a missing response, since the caller's fetch can fail", () => {
|
||||
expect(releaseUrlFrom(undefined)).toBe("");
|
||||
expect(releaseUrlFrom(null)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const authApiMock = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn() }));
|
||||
const handleApiErrorMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: authApiMock,
|
||||
statsApi: { get: vi.fn(), patch: vi.fn() },
|
||||
handleApiError: handleApiErrorMock,
|
||||
}));
|
||||
|
||||
import { getTailscaleDevices } from "../../api/settings-api";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getTailscaleDevices", () => {
|
||||
it("preserves configured-key state when discovery fails", async () => {
|
||||
authApiMock.get.mockRejectedValue({
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
data: {
|
||||
devices: [],
|
||||
hasApiKey: true,
|
||||
error: "Failed to fetch Tailscale devices",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(getTailscaleDevices()).resolves.toEqual({
|
||||
devices: [],
|
||||
hasApiKey: true,
|
||||
error: "Failed to fetch Tailscale devices",
|
||||
});
|
||||
expect(handleApiErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses normal API error handling when no structured state is available", async () => {
|
||||
const error = { isAxiosError: true, response: { data: {} } };
|
||||
authApiMock.get.mockRejectedValue(error);
|
||||
|
||||
await expect(getTailscaleDevices()).rejects.toBe(error);
|
||||
expect(handleApiErrorMock).toHaveBeenCalledWith(
|
||||
error,
|
||||
"fetch Tailscale devices",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const authApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ data: [] })),
|
||||
post: vi.fn(async () => ({ data: {} })),
|
||||
patch: vi.fn(async () => ({ data: {} })),
|
||||
put: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: { success: true } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: authApiMock,
|
||||
handleApiError: (error: unknown) => error,
|
||||
}));
|
||||
|
||||
import {
|
||||
listWorkspaces,
|
||||
createWorkspace,
|
||||
renameWorkspace,
|
||||
updateWorkspaceContent,
|
||||
deleteWorkspace,
|
||||
duplicateWorkspace,
|
||||
setDefaultWorkspace,
|
||||
applyWorkspaceServer,
|
||||
getLastSessionWorkspace,
|
||||
saveLastSessionWorkspace,
|
||||
} from "../../api/workspaces-api";
|
||||
import type { WorkspacePayload } from "@/types/ui-types";
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
authApiMock.post.mockClear();
|
||||
authApiMock.patch.mockClear();
|
||||
authApiMock.put.mockClear();
|
||||
authApiMock.delete.mockClear();
|
||||
});
|
||||
|
||||
const samplePayload: WorkspacePayload = {
|
||||
version: 1,
|
||||
tabs: [],
|
||||
activeSlotId: null,
|
||||
splitMode: "none",
|
||||
paneTabIds: [null, null, null, null, null, null],
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[100]],
|
||||
};
|
||||
|
||||
describe("workspaces-api", () => {
|
||||
it("listWorkspaces GETs /workspaces", async () => {
|
||||
authApiMock.get.mockResolvedValueOnce({ data: [{ id: 1 }] });
|
||||
const result = await listWorkspaces();
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/workspaces");
|
||||
expect(result).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it("createWorkspace POSTs /workspaces with the payload", async () => {
|
||||
await createWorkspace({
|
||||
name: "Test A",
|
||||
color: "#fff",
|
||||
payload: samplePayload,
|
||||
});
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces", {
|
||||
name: "Test A",
|
||||
color: "#fff",
|
||||
payload: samplePayload,
|
||||
});
|
||||
});
|
||||
|
||||
it("renameWorkspace PATCHes /workspaces/:id", async () => {
|
||||
await renameWorkspace(5, { name: "New Name" });
|
||||
expect(authApiMock.patch).toHaveBeenCalledWith("/workspaces/5", {
|
||||
name: "New Name",
|
||||
});
|
||||
});
|
||||
|
||||
it("updateWorkspaceContent PUTs /workspaces/:id/content", async () => {
|
||||
await updateWorkspaceContent(5, samplePayload);
|
||||
expect(authApiMock.put).toHaveBeenCalledWith("/workspaces/5/content", {
|
||||
payload: samplePayload,
|
||||
});
|
||||
});
|
||||
|
||||
it("deleteWorkspace DELETEs /workspaces/:id", async () => {
|
||||
await deleteWorkspace(5);
|
||||
expect(authApiMock.delete).toHaveBeenCalledWith("/workspaces/5");
|
||||
});
|
||||
|
||||
it("duplicateWorkspace POSTs /workspaces/:id/duplicate with a name", async () => {
|
||||
await duplicateWorkspace(5, "Test A (copy)");
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/duplicate", {
|
||||
name: "Test A (copy)",
|
||||
});
|
||||
});
|
||||
|
||||
it("setDefaultWorkspace POSTs /workspaces/:id/set-default", async () => {
|
||||
await setDefaultWorkspace(5);
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/set-default");
|
||||
});
|
||||
|
||||
it("applyWorkspaceServer POSTs /workspaces/:id/apply", async () => {
|
||||
await applyWorkspaceServer(5);
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/apply");
|
||||
});
|
||||
|
||||
it("getLastSessionWorkspace GETs /workspaces/last-session", async () => {
|
||||
authApiMock.get.mockResolvedValueOnce({ data: null });
|
||||
const result = await getLastSessionWorkspace();
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/workspaces/last-session");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("saveLastSessionWorkspace PUTs /workspaces/last-session", async () => {
|
||||
await saveLastSessionWorkspace(samplePayload);
|
||||
expect(authApiMock.put).toHaveBeenCalledWith("/workspaces/last-session", {
|
||||
payload: samplePayload,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { VersionBadge } from "../../components/version-badge";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// `t` is mocked to the identity above, so every label below is the raw key.
|
||||
const RELEASE_URL =
|
||||
"https://github.com/Termix-SSH/Termix/releases/tag/release-2.6.1-tag";
|
||||
const LINK_LABEL = "versionCheck.updateLinkLabel";
|
||||
const UPDATE_TEXT = "dashboard.updateAvailable".toUpperCase();
|
||||
|
||||
describe("VersionBadge", () => {
|
||||
it("links to the release when an update is available", () => {
|
||||
render(<VersionBadge status="requires_update" releaseUrl={RELEASE_URL} />);
|
||||
|
||||
// Queried by its accessible name: the badge text alone ("UPDATE
|
||||
// AVAILABLE") never says where the link goes.
|
||||
const link = screen.getByRole("link", { name: LINK_LABEL });
|
||||
expect(link.getAttribute("href")).toBe(RELEASE_URL);
|
||||
expect(link.getAttribute("target")).toBe("_blank");
|
||||
expect(link.getAttribute("rel")).toContain("noopener");
|
||||
expect(link.getAttribute("title")).toBe(LINK_LABEL);
|
||||
expect(link.textContent).toBe(UPDATE_TEXT);
|
||||
});
|
||||
|
||||
it("keeps the yellow 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("hover:underline");
|
||||
});
|
||||
|
||||
it("stays an inert badge when the response carried no release URL", () => {
|
||||
// A dead anchor is worse than no anchor: it looks actionable and isn't.
|
||||
render(<VersionBadge status="requires_update" />);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(screen.getByText(UPDATE_TEXT)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stays inert when up to date, even if a release URL is known", () => {
|
||||
render(<VersionBadge status="up_to_date" releaseUrl={RELEASE_URL} />);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(screen.getByText("dashboardTab.stable")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stays inert on the beta channel, which the release page does not serve", () => {
|
||||
render(<VersionBadge status="beta" releaseUrl={RELEASE_URL} />);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(screen.getByText("dashboard.beta".toUpperCase())).toBeTruthy();
|
||||
});
|
||||
|
||||
// The dashboard's stats bar sits in a flex column, so its badge needs
|
||||
// `w-fit` to stop the background stretching the full column width. The
|
||||
// profile panel's badge does not. Both must come out of one component.
|
||||
it("appends the caller's className without dropping the status classes", () => {
|
||||
render(
|
||||
<VersionBadge
|
||||
status="requires_update"
|
||||
releaseUrl={RELEASE_URL}
|
||||
className="w-fit"
|
||||
/>,
|
||||
);
|
||||
|
||||
const className = screen.getByRole("link").className;
|
||||
expect(className).toContain("w-fit");
|
||||
expect(className).toContain("bg-yellow-500/20");
|
||||
});
|
||||
|
||||
it("applies the className on the inert span too", () => {
|
||||
render(<VersionBadge status="up_to_date" className="w-fit" />);
|
||||
|
||||
const badge = screen.getByText("dashboardTab.stable");
|
||||
expect(badge.tagName).toBe("SPAN");
|
||||
expect(badge.className).toContain("w-fit");
|
||||
expect(badge.className).toContain("bg-accent-brand/20");
|
||||
});
|
||||
|
||||
it("emits no stray whitespace class when no className is passed", () => {
|
||||
render(<VersionBadge status="up_to_date" />);
|
||||
|
||||
const badge = screen.getByText("dashboardTab.stable");
|
||||
expect(badge.className).toBe(badge.className.trim());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getUiPreferences: vi.fn(),
|
||||
saveUiPreferences: vi.fn(async () => {}),
|
||||
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => api);
|
||||
|
||||
import {
|
||||
UiPreferencesProvider,
|
||||
useAreaPreferences,
|
||||
useUiPreferencesContext,
|
||||
} from "@/contexts/UiPreferencesContext";
|
||||
import { defaultUiPreferences, PRESETS } from "@/types/ui-preferences";
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <UiPreferencesProvider>{children}</UiPreferencesProvider>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
api.getUiPreferences.mockReset();
|
||||
api.saveUiPreferences.mockReset();
|
||||
api.getUserPreferences.mockReset();
|
||||
api.getUiPreferences.mockResolvedValue(defaultUiPreferences());
|
||||
api.saveUiPreferences.mockResolvedValue(undefined);
|
||||
api.getUserPreferences.mockResolvedValue({ storageMode: "local" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("useAreaPreferences", () => {
|
||||
it("falls back to balanced outside a provider so isolated renders match today", () => {
|
||||
const { result } = renderHook(() => useAreaPreferences("hostList"));
|
||||
expect(result.current).toEqual(PRESETS.balanced.hostList);
|
||||
});
|
||||
|
||||
it("resolves preset values through the provider", async () => {
|
||||
api.getUiPreferences.mockResolvedValue({
|
||||
...defaultUiPreferences(),
|
||||
preset: "simple",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAreaPreferences("hostList"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.trayTrigger).toBe(
|
||||
PRESETS.simple.hostList.trayTrigger,
|
||||
),
|
||||
);
|
||||
expect(result.current.showResourceBars).toBe(false);
|
||||
});
|
||||
|
||||
it("layers overrides over the preset", async () => {
|
||||
api.getUiPreferences.mockResolvedValue({
|
||||
...defaultUiPreferences(),
|
||||
preset: "simple",
|
||||
overrides: { hostList: { showResourceBars: true } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAreaPreferences("hostList"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait on a value only the loaded state has -- showResourceBars:true is
|
||||
// also balanced's value, so asserting it first can pass pre-fetch.
|
||||
await waitFor(() =>
|
||||
expect(result.current.trayTrigger).toBe(
|
||||
PRESETS.simple.hostList.trayTrigger,
|
||||
),
|
||||
);
|
||||
expect(result.current.showResourceBars).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UiPreferencesProvider", () => {
|
||||
it("paints from the localStorage cache before the server responds", async () => {
|
||||
localStorage.setItem(
|
||||
"uiPreferences",
|
||||
JSON.stringify({ ...defaultUiPreferences(), preset: "advanced" }),
|
||||
);
|
||||
let resolveRemote: (value: unknown) => void = () => {};
|
||||
api.getUiPreferences.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveRemote = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAreaPreferences("hostList"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.density).toBe("compact");
|
||||
|
||||
await act(async () => {
|
||||
resolveRemote(defaultUiPreferences());
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the cached value when the fetch fails but still marks loaded", async () => {
|
||||
localStorage.setItem(
|
||||
"uiPreferences",
|
||||
JSON.stringify({ ...defaultUiPreferences(), preset: "simple" }),
|
||||
);
|
||||
api.getUiPreferences.mockRejectedValue(new Error("offline"));
|
||||
|
||||
const { result } = renderHook(() => useUiPreferencesContext(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current?.loaded).toBe(true));
|
||||
expect(result.current?.preferences.preset).toBe("simple");
|
||||
});
|
||||
|
||||
it("writes the cache immediately on update", async () => {
|
||||
const { result } = renderHook(() => useUiPreferencesContext(), { wrapper });
|
||||
await waitFor(() => expect(result.current?.loaded).toBe(true));
|
||||
|
||||
act(() => result.current?.setPreset("simple"));
|
||||
|
||||
const cached = JSON.parse(localStorage.getItem("uiPreferences") ?? "{}");
|
||||
expect(cached.preset).toBe("simple");
|
||||
});
|
||||
|
||||
it("does not save to the backend in local storage mode", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useUiPreferencesContext(), { wrapper });
|
||||
await vi.waitFor(() => expect(result.current?.loaded).toBe(true));
|
||||
|
||||
act(() => result.current?.setPreset("simple"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
|
||||
expect(api.saveUiPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("debounces a cloud save and sends only the changed slice", async () => {
|
||||
vi.useFakeTimers();
|
||||
api.getUserPreferences.mockResolvedValue({ storageMode: "cloud" });
|
||||
|
||||
const { result } = renderHook(() => useUiPreferencesContext(), { wrapper });
|
||||
await vi.waitFor(() => expect(result.current?.loaded).toBe(true));
|
||||
|
||||
act(() => result.current?.setPreset("advanced"));
|
||||
act(() => result.current?.setOverride("hostList", "showTags", false));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
|
||||
expect(api.saveUiPreferences).toHaveBeenCalledTimes(1);
|
||||
expect(api.saveUiPreferences).toHaveBeenCalledWith({
|
||||
preset: "advanced",
|
||||
overrides: { hostList: { showTags: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears an override by sending null", async () => {
|
||||
vi.useFakeTimers();
|
||||
api.getUserPreferences.mockResolvedValue({ storageMode: "cloud" });
|
||||
api.getUiPreferences.mockResolvedValue({
|
||||
...defaultUiPreferences(),
|
||||
overrides: { hostList: { showTags: false } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUiPreferencesContext(), { wrapper });
|
||||
await vi.waitFor(() => expect(result.current?.loaded).toBe(true));
|
||||
|
||||
act(() => result.current?.clearArea("hostList"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
|
||||
expect(api.saveUiPreferences).toHaveBeenCalledWith({
|
||||
overrides: { hostList: null },
|
||||
});
|
||||
expect(result.current?.preferences.overrides).toEqual({});
|
||||
});
|
||||
|
||||
it("prunes an area once its last override is cleared", async () => {
|
||||
api.getUiPreferences.mockResolvedValue({
|
||||
...defaultUiPreferences(),
|
||||
overrides: { hostList: { showTags: false } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUiPreferencesContext(), { wrapper });
|
||||
await waitFor(() => expect(result.current?.loaded).toBe(true));
|
||||
|
||||
act(() => result.current?.setOverride("hostList", "showTags", null));
|
||||
|
||||
expect(result.current?.preferences.overrides).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { HostStatusCard } from "@/dashboard/DashboardTab";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("HostStatusCard", () => {
|
||||
it("truncates long host identity without shrinking the metrics", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
name: "a-very-long-host-name-that-must-not-shift-the-status-columns",
|
||||
ip: "a-very-long-hostname.example.internal",
|
||||
online: false,
|
||||
} as Host;
|
||||
|
||||
render(
|
||||
<HostStatusCard
|
||||
hosts={[host]}
|
||||
hostMetrics={new Map()}
|
||||
onOpenTab={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildRdpFile, validateNativeRdpOptions } =
|
||||
require("../../../../electron/native-rdp.cjs") as {
|
||||
buildRdpFile: (options: Record<string, unknown>) => string;
|
||||
validateNativeRdpOptions: (options: Record<string, unknown>) => unknown;
|
||||
};
|
||||
|
||||
describe("native Windows RDP", () => {
|
||||
it("creates a credential-prompting RDP file without a password", () => {
|
||||
const result = buildRdpFile({
|
||||
host: "server.example.com",
|
||||
port: 3390,
|
||||
username: "alice",
|
||||
domain: "EXAMPLE",
|
||||
password: "must-not-be-written",
|
||||
});
|
||||
|
||||
expect(result).toContain("full address:s:server.example.com:3390");
|
||||
expect(result).toContain("username:s:EXAMPLE\\alice");
|
||||
expect(result).toContain("prompt for credentials:i:1");
|
||||
expect(result).not.toContain("must-not-be-written");
|
||||
});
|
||||
|
||||
it("rejects unsafe host and port values", () => {
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "bad\r\nvalue" }),
|
||||
).not.toThrow();
|
||||
expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow();
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "server", port: 70000 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fieldLabel,
|
||||
humanize,
|
||||
mentionLabel,
|
||||
toolLabel,
|
||||
} from "@/features/ai/labels";
|
||||
import { AI_TOOLS } from "../../../../backend/ai/tools/catalog";
|
||||
|
||||
describe("humanize", () => {
|
||||
it("splits camelCase into words", () => {
|
||||
// The case that shipped as "thresholddurationseconds".
|
||||
expect(humanize("thresholdDurationSeconds")).toBe(
|
||||
"Threshold duration seconds",
|
||||
);
|
||||
});
|
||||
|
||||
it("splits snake_case into words", () => {
|
||||
expect(humanize("propose_create_alert_rule")).toBe(
|
||||
"Propose create alert rule",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a single word alone apart from casing", () => {
|
||||
expect(humanize("port")).toBe("Port");
|
||||
});
|
||||
|
||||
it("returns the input when there is nothing to split", () => {
|
||||
expect(humanize("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fieldLabel", () => {
|
||||
it("uses the mapped name where there is one", () => {
|
||||
expect(fieldLabel("thresholdDurationSeconds")).toBe("Duration (seconds)");
|
||||
expect(fieldLabel("cooldownMinutes")).toBe("Cooldown (minutes)");
|
||||
expect(fieldLabel("ip")).toBe("Address");
|
||||
});
|
||||
|
||||
it("falls back to humanizing an unmapped key", () => {
|
||||
expect(fieldLabel("someFutureField")).toBe("Some future field");
|
||||
});
|
||||
|
||||
it("never returns a run-on identifier", () => {
|
||||
for (const key of ["thresholdDurationSeconds", "hostIds", "triggerType"]) {
|
||||
expect(fieldLabel(key)).not.toBe(key.toLowerCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolLabel", () => {
|
||||
it("phrases tools as actions rather than function names", () => {
|
||||
expect(toolLabel("list_hosts")).toBe("Reading hosts");
|
||||
expect(toolLabel("propose_create_alert_rule")).toBe("Create alert rule");
|
||||
});
|
||||
|
||||
it("covers every tool in the catalog", () => {
|
||||
// A new tool without a label would surface its raw snake_case name.
|
||||
for (const tool of AI_TOOLS) {
|
||||
const label = toolLabel(tool.name);
|
||||
expect(label, tool.name).not.toContain("_");
|
||||
expect(label, tool.name).not.toBe(tool.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mentionLabel", () => {
|
||||
it("capitalises the mention kinds", () => {
|
||||
expect(mentionLabel("host")).toBe("Host");
|
||||
expect(mentionLabel("snippet")).toBe("Snippet");
|
||||
expect(mentionLabel("automation")).toBe("Automation");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFilePreviewKind } from "../../../features/file-manager/file-preview-preload";
|
||||
|
||||
describe("resolveFilePreviewKind", () => {
|
||||
it.each([
|
||||
["photo.webp", "image"],
|
||||
["movie.webm", "video"],
|
||||
["voice.m4a", "audio"],
|
||||
["README.md", "markdown"],
|
||||
["manual.pdf", "pdf"],
|
||||
["notes.txt", "text"],
|
||||
["app.tsx", "code"],
|
||||
["Dockerfile", "code"],
|
||||
["LICENSE", "unknown"],
|
||||
] as const)("classifies %s as %s", (filename, expected) => {
|
||||
expect(resolveFilePreviewKind(filename)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FileItem } from "@/types/index";
|
||||
import {
|
||||
addOptimisticItem,
|
||||
childPath,
|
||||
removePaths,
|
||||
renameOptimisticItem,
|
||||
restoreItems,
|
||||
} from "../../../features/file-manager/optimistic-file-list";
|
||||
|
||||
const files: FileItem[] = [
|
||||
{ name: "alpha.txt", path: "/work/alpha.txt", type: "file", size: 10 },
|
||||
{ name: "docs", path: "/work/docs", type: "directory" },
|
||||
];
|
||||
|
||||
describe("optimistic file list updates", () => {
|
||||
it("builds child paths without duplicate separators", () => {
|
||||
expect(childPath("/", "file.txt")).toBe("/file.txt");
|
||||
expect(childPath("/work/", "file.txt")).toBe("/work/file.txt");
|
||||
});
|
||||
|
||||
it("adds and rolls back a provisional item", () => {
|
||||
const added = addOptimisticItem(files, "/work", "new.txt", "file", 0);
|
||||
expect(added.at(-1)).toMatchObject({
|
||||
name: "new.txt",
|
||||
path: "/work/new.txt",
|
||||
type: "file",
|
||||
size: 0,
|
||||
});
|
||||
expect(removePaths(added, new Set(["/work/new.txt"]))).toEqual(files);
|
||||
});
|
||||
|
||||
it("renames immediately and can reverse the same item", () => {
|
||||
const renamed = renameOptimisticItem(files, "/work/alpha.txt", "beta.txt");
|
||||
expect(renamed[0]).toMatchObject({
|
||||
name: "beta.txt",
|
||||
path: "/work/beta.txt",
|
||||
size: 10,
|
||||
});
|
||||
expect(
|
||||
renameOptimisticItem(renamed, "/work/beta.txt", "alpha.txt"),
|
||||
).toEqual(files);
|
||||
});
|
||||
|
||||
it("restores only failed deletions without duplicating survivors", () => {
|
||||
const remaining = removePaths(files, new Set(["/work/docs"]));
|
||||
expect(restoreItems(remaining, [files[1], files[0]])).toEqual(files);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GuacamoleToolbar } from "../../../features/guacamole/GuacamoleToolbar.js";
|
||||
import type { GuacamoleDisplayHandle } from "../../../features/guacamole/GuacamoleDisplay.js";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
describe("GuacamoleToolbar Windows key", () => {
|
||||
it("sends Super_L for Win shortcuts and the sticky modifier", () => {
|
||||
const sendKey = vi.fn();
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey,
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
const { getByText } = render(
|
||||
<GuacamoleToolbar displayRef={displayRef} protocol="rdp" />,
|
||||
);
|
||||
|
||||
fireEvent.click(getByText("Win+L"));
|
||||
expect(sendKey.mock.calls).toEqual([
|
||||
[0xffeb, true],
|
||||
[0x006c, true],
|
||||
[0x006c, false],
|
||||
[0xffeb, false],
|
||||
]);
|
||||
|
||||
sendKey.mockClear();
|
||||
fireEvent.click(getByText("Win"));
|
||||
expect(sendKey.mock.calls).toEqual([
|
||||
[0xffeb, true],
|
||||
[0xffeb, false],
|
||||
]);
|
||||
|
||||
sendKey.mockClear();
|
||||
fireEvent.click(getByText("guacamole.toolbar.win"));
|
||||
expect(sendKey).toHaveBeenCalledWith(0xffeb, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
STREAM_INDEX_MIMETYPE,
|
||||
basename,
|
||||
isDirectoryMimetype,
|
||||
joinPath,
|
||||
parentPath,
|
||||
parseDirectoryIndex,
|
||||
} from "../../../features/guacamole/guacamole-filesystem.js";
|
||||
|
||||
describe("path helpers", () => {
|
||||
it("joins onto the root without doubling the separator", () => {
|
||||
expect(joinPath("/", "Users")).toBe("/Users");
|
||||
expect(joinPath("/Users", "kei")).toBe("/Users/kei");
|
||||
});
|
||||
|
||||
it("stops walking up at the root", () => {
|
||||
expect(parentPath("/Users/kei")).toBe("/Users");
|
||||
expect(parentPath("/Users")).toBe("/");
|
||||
expect(parentPath("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("reads the trailing segment as the name", () => {
|
||||
expect(basename("/Users/kei/notes.txt")).toBe("notes.txt");
|
||||
expect(basename("notes.txt")).toBe("notes.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDirectoryMimetype", () => {
|
||||
it("treats only the stream index mimetype as a directory", () => {
|
||||
expect(isDirectoryMimetype(STREAM_INDEX_MIMETYPE)).toBe(true);
|
||||
expect(isDirectoryMimetype("text/plain")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDirectoryIndex", () => {
|
||||
it("sorts directories before files and alphabetises within each group", () => {
|
||||
const json = JSON.stringify({
|
||||
"/report.pdf": "application/pdf",
|
||||
"/apps": STREAM_INDEX_MIMETYPE,
|
||||
"/notes.txt": "text/plain",
|
||||
"/Documents": STREAM_INDEX_MIMETYPE,
|
||||
});
|
||||
|
||||
expect(parseDirectoryIndex(json, "/").map((e) => e.name)).toEqual([
|
||||
"apps",
|
||||
"Documents",
|
||||
"notes.txt",
|
||||
"report.pdf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks entries carrying the stream index mimetype as directories", () => {
|
||||
const json = JSON.stringify({
|
||||
"/apps": STREAM_INDEX_MIMETYPE,
|
||||
"/notes.txt": "text/plain",
|
||||
});
|
||||
const [dir, file] = parseDirectoryIndex(json, "/");
|
||||
|
||||
expect(dir).toEqual({
|
||||
name: "apps",
|
||||
path: "/apps",
|
||||
mimetype: STREAM_INDEX_MIMETYPE,
|
||||
isDirectory: true,
|
||||
});
|
||||
expect(file.isDirectory).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps absolute keys as-is when listing a nested directory", () => {
|
||||
const json = JSON.stringify({ "/Users/kei/notes.txt": "text/plain" });
|
||||
|
||||
expect(parseDirectoryIndex(json, "/Users/kei")[0].path).toBe(
|
||||
"/Users/kei/notes.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a relative key against the directory being listed", () => {
|
||||
const json = JSON.stringify({ "notes.txt": "text/plain" });
|
||||
|
||||
expect(parseDirectoryIndex(json, "/Users/kei")[0].path).toBe(
|
||||
"/Users/kei/notes.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty directory", () => {
|
||||
expect(parseDirectoryIndex("{}", "/")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bindPointerInput } from "../../../features/guacamole/guacamole-pointer.js";
|
||||
|
||||
function listenedEvents(): {
|
||||
element: HTMLElement;
|
||||
events: () => string[];
|
||||
} {
|
||||
const element = document.createElement("div");
|
||||
const seen: string[] = [];
|
||||
const original = element.addEventListener.bind(element);
|
||||
element.addEventListener = ((type: string, ...rest: unknown[]) => {
|
||||
seen.push(type);
|
||||
return (original as (t: string, ...r: unknown[]) => void)(type, ...rest);
|
||||
}) as typeof element.addEventListener;
|
||||
return { element, events: () => seen };
|
||||
}
|
||||
|
||||
describe("bindPointerInput", () => {
|
||||
// A touchscreen laptop reports maxTouchPoints > 0 and still has a mouse.
|
||||
// Binding only the touch emulator left the pointer dead on those machines.
|
||||
it.each([
|
||||
["no touch mode", null],
|
||||
["touchscreen", "touchscreen" as const],
|
||||
["touchpad", "touchpad" as const],
|
||||
])("keeps the physical pointer bound with %s", (_label, touchMode) => {
|
||||
const { element, events } = listenedEvents();
|
||||
|
||||
bindPointerInput(element, touchMode, vi.fn());
|
||||
|
||||
expect(events()).toContain("mousedown");
|
||||
expect(events()).toContain("mousemove");
|
||||
expect(events()).toContain("mouseup");
|
||||
});
|
||||
|
||||
it("adds touch listeners only when a touch mode is selected", () => {
|
||||
const withoutTouch = listenedEvents();
|
||||
bindPointerInput(withoutTouch.element, null, vi.fn());
|
||||
const plainTouchCount = withoutTouch
|
||||
.events()
|
||||
.filter((e) => e.startsWith("touch")).length;
|
||||
|
||||
const withTouch = listenedEvents();
|
||||
bindPointerInput(withTouch.element, "touchscreen", vi.fn());
|
||||
const touchModeCount = withTouch
|
||||
.events()
|
||||
.filter((e) => e.startsWith("touch")).length;
|
||||
|
||||
expect(touchModeCount).toBeGreaterThan(plainTouchCount);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const getVersionInfo = vi.fn();
|
||||
const getDatabaseHealth = vi.fn(async () => ({ status: "ok" }));
|
||||
vi.mock("@/api/system-status-api", () => ({
|
||||
getVersionInfo: (...args: unknown[]) => getVersionInfo(...args),
|
||||
getDatabaseHealth: () => getDatabaseHealth(),
|
||||
}));
|
||||
vi.mock("@/api/dashboard-api", () => ({
|
||||
getUptime: vi.fn(async () => ({ formatted: "1d 2h" })),
|
||||
}));
|
||||
|
||||
// The real one installs an interval and a visibilitychange listener; the
|
||||
// component's first fetch happens outside it, which is all these tests need.
|
||||
vi.mock("../../../features/homepage/use-visible-interval", () => ({
|
||||
runVisibleInterval: () => () => {},
|
||||
}));
|
||||
|
||||
import { SystemOverviewWidget } from "../../../features/homepage/widgets/SystemOverviewWidget";
|
||||
import type { CanvasWidget } from "@/types/homepage-types";
|
||||
|
||||
const widget: CanvasWidget = {
|
||||
id: 1,
|
||||
typeId: "system_overview",
|
||||
title: "Termix",
|
||||
config: {},
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 10,
|
||||
h: 6,
|
||||
zOrder: 0,
|
||||
};
|
||||
|
||||
function renderWidget() {
|
||||
render(
|
||||
<SystemOverviewWidget
|
||||
widget={widget}
|
||||
config={{ showVersion: true, showDbHealth: false, showUptime: false }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getVersionInfo.mockReset();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("SystemOverviewWidget update indicator", () => {
|
||||
it("asks /version to actually check the remote release", async () => {
|
||||
// Regression: it used to pass checkRemote=false, which makes the endpoint
|
||||
// return early without ever comparing against the latest release.
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "up_to_date",
|
||||
localVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() => expect(getVersionInfo).toHaveBeenCalledTimes(1));
|
||||
expect(getVersionInfo).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("flags an available update from the status the endpoint returns", async () => {
|
||||
// Regression: it used to read `updateAvailable`, a field /version does not
|
||||
// return in either mode, so the row could never appear.
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "requires_update",
|
||||
localVersion: "2.6.0",
|
||||
remoteVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("homepage.overviewUpdateAvailable")).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
|
||||
it("labels that row 'Update' rather than 'Up to date'", async () => {
|
||||
// homepage.overviewUpdate is the string "Up to date", which read as the
|
||||
// label of an update-available row said "Up to date Update available".
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "requires_update",
|
||||
localVersion: "2.6.0",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("homepage.overviewUpdateLabel")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByText("homepage.overviewUpdate")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no update row when the server is current", async () => {
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "up_to_date",
|
||||
localVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() => expect(screen.getByText("2.6.1")).toBeTruthy());
|
||||
expect(screen.queryByText("homepage.overviewUpdateAvailable")).toBeNull();
|
||||
expect(screen.queryByText("homepage.overviewUpdateLabel")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no update row on a beta build, which is ahead rather than behind", async () => {
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "beta",
|
||||
localVersion: "2.7.0",
|
||||
remoteVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() => expect(screen.getByText("2.7.0")).toBeTruthy());
|
||||
expect(screen.queryByText("homepage.overviewUpdateAvailable")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
resolveShareLink: vi.fn(async () => {
|
||||
resolveShareLink: vi.fn(async (): Promise<unknown> => {
|
||||
throw new Error("resolveShareLink not mocked for this test");
|
||||
}),
|
||||
ShareLinkError: class ShareLinkError extends Error {
|
||||
|
||||
@@ -0,0 +1,894 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getServerMetricsById: vi.fn(),
|
||||
startMetricsPolling: vi.fn(),
|
||||
stopMetricsPolling: vi.fn(),
|
||||
}));
|
||||
const hostActionsApi = vi.hoisted(() => ({
|
||||
getSshActions: vi.fn(),
|
||||
}));
|
||||
const mobileApi = vi.hoisted(() => ({
|
||||
isMobile: false as boolean | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/host-metrics-status-api", () => api);
|
||||
vi.mock("@/sidebar/tree/HostItem/HostItem", () => hostActionsApi);
|
||||
vi.mock("@/hooks/use-mobile", () => ({
|
||||
useIsMobile: () => mobileApi.isMobile,
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
({
|
||||
"terminalToolbar.detachTmux": "Detach tmux",
|
||||
"terminalToolbar.detachTmuxDescription": "Detach tmux session",
|
||||
"terminalToolbar.image": "Image actions (images only)",
|
||||
"terminalToolbar.upload": "Upload image",
|
||||
"terminalToolbar.uploadImage": "Upload an image to the host",
|
||||
"terminalToolbar.uploadingImage": "Uploading image…",
|
||||
"terminalToolbar.paste": "Paste image",
|
||||
"terminalToolbar.pasteImage":
|
||||
"Paste image from clipboard (images only)",
|
||||
"terminalToolbar.showActions": "Show toolbar",
|
||||
"terminalToolbar.showToolbar": "Expand toolbar",
|
||||
"terminalToolbar.hideToolbar": "Hide toolbar",
|
||||
"terminalToolbar.layout": "Toolbar display mode",
|
||||
"terminalToolbar.layoutIcon": "Icon only",
|
||||
"terminalToolbar.layoutLabeled": "Icons and labels",
|
||||
"terminalToolbar.layoutExpanded": "Expanded with metrics",
|
||||
"terminalToolbar.moveToolbar": "Move toolbar",
|
||||
"terminalToolbar.moveToolbarHint": "Drag to reposition the toolbar",
|
||||
"terminalToolbar.imageActionFailed": "Image action failed",
|
||||
"terminalToolbar.retry": "Retry",
|
||||
"terminalToolbar.dismissError": "Dismiss image error",
|
||||
})[key] ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TerminalToolbar } from "@/features/terminal/TerminalToolbar";
|
||||
import {
|
||||
clampToolbarPosition,
|
||||
getResponsiveToolbarDensity,
|
||||
} from "@/features/terminal/toolbar-geometry";
|
||||
|
||||
const host = { id: 7, connectionType: "ssh" } as unknown as Host;
|
||||
|
||||
function renderToolbar(
|
||||
overrides: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
|
||||
) {
|
||||
const props: React.ComponentProps<typeof TerminalToolbar> = {
|
||||
host,
|
||||
isConnected: true,
|
||||
isTmuxAttached: true,
|
||||
onTmuxDetach: vi.fn(),
|
||||
isImageUploading: false,
|
||||
onUploadImage: vi.fn(),
|
||||
onPasteImage: vi.fn(),
|
||||
isFocused: true,
|
||||
...overrides,
|
||||
};
|
||||
return { ...render(<TerminalToolbar {...props} />), props };
|
||||
}
|
||||
|
||||
// jsdom lacks the pointer APIs Radix Select needs to open its popup.
|
||||
beforeEach(() => {
|
||||
Element.prototype.hasPointerCapture ??= () => false;
|
||||
Element.prototype.setPointerCapture ??= () => {};
|
||||
Element.prototype.releasePointerCapture ??= () => {};
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
vi.clearAllMocks();
|
||||
mobileApi.isMobile = false;
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 1024,
|
||||
});
|
||||
localStorage.clear();
|
||||
hostActionsApi.getSshActions.mockReturnValue([]);
|
||||
api.startMetricsPolling.mockResolvedValue({ viewerSessionId: "viewer" });
|
||||
api.getServerMetricsById.mockResolvedValue(null);
|
||||
api.stopMetricsPolling.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("responsive toolbar density", () => {
|
||||
it("enters below 110 percent and restores at 115 percent", () => {
|
||||
expect(getResponsiveToolbarDensity("labeled", "labeled", 1099, 1000)).toBe(
|
||||
"icon",
|
||||
);
|
||||
expect(getResponsiveToolbarDensity("labeled", "labeled", 1100, 1000)).toBe(
|
||||
"labeled",
|
||||
);
|
||||
expect(getResponsiveToolbarDensity("labeled", "icon", 1149, 1000)).toBe(
|
||||
"icon",
|
||||
);
|
||||
expect(getResponsiveToolbarDensity("labeled", "icon", 1150, 1000)).toBe(
|
||||
"labeled",
|
||||
);
|
||||
});
|
||||
|
||||
it("never falls back when Icon is explicitly selected", () => {
|
||||
expect(getResponsiveToolbarDensity("icon", "icon", 10, 1000)).toBe("icon");
|
||||
expect(getResponsiveToolbarDensity("icon", "icon", 2000, 1000)).toBe(
|
||||
"icon",
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the prior effective state while measurement is unavailable", () => {
|
||||
expect(getResponsiveToolbarDensity("expanded", "expanded", 0, 1000)).toBe(
|
||||
"expanded",
|
||||
);
|
||||
expect(getResponsiveToolbarDensity("expanded", "icon", 0, 1000)).toBe(
|
||||
"icon",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalToolbar Phase 1", () => {
|
||||
it("clamps vertical movement and preserves over-tall recovery", () => {
|
||||
const hostRect = {
|
||||
left: 0,
|
||||
right: 400,
|
||||
top: 0,
|
||||
bottom: 200,
|
||||
width: 400,
|
||||
height: 200,
|
||||
};
|
||||
const toolbarRect = {
|
||||
left: 100,
|
||||
right: 300,
|
||||
top: 140,
|
||||
bottom: 190,
|
||||
width: 200,
|
||||
height: 50,
|
||||
};
|
||||
expect(
|
||||
clampToolbarPosition({ x: 0, y: -999 }, toolbarRect, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}).y,
|
||||
).toBe(-132);
|
||||
expect(
|
||||
clampToolbarPosition({ x: 0, y: 999 }, toolbarRect, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}).y,
|
||||
).toBe(2);
|
||||
expect(
|
||||
clampToolbarPosition(
|
||||
{ x: 0, y: -999 },
|
||||
{ ...toolbarRect, top: 150, bottom: 350, height: 200 },
|
||||
hostRect,
|
||||
{ x: 0, y: 0 },
|
||||
).y,
|
||||
).toBe(-298);
|
||||
});
|
||||
|
||||
it("keeps horizontal outputs unchanged during simultaneous movement", () => {
|
||||
const hostRect = {
|
||||
left: 100,
|
||||
right: 500,
|
||||
top: 50,
|
||||
bottom: 350,
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
const toolbarRect = {
|
||||
left: 250,
|
||||
right: 450,
|
||||
top: 280,
|
||||
bottom: 330,
|
||||
width: 200,
|
||||
height: 50,
|
||||
};
|
||||
expect(
|
||||
clampToolbarPosition({ x: -999, y: -100 }, toolbarRect, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
).toEqual({ x: -142, y: -100 });
|
||||
expect(
|
||||
clampToolbarPosition({ x: 999, y: 12 }, toolbarRect, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
).toEqual({ x: 42, y: 12 });
|
||||
});
|
||||
|
||||
it("fills the full terminal surface while remaining bottom-centered and pointer transparent", () => {
|
||||
const { container } = renderToolbar();
|
||||
const toolbarHost = container.querySelector("[data-terminal-toolbar-host]");
|
||||
const toolbar = container.querySelector("[data-terminal-toolbar-wide]");
|
||||
expect(toolbarHost).toHaveClass(
|
||||
"absolute",
|
||||
"inset-0",
|
||||
"flex",
|
||||
"items-end",
|
||||
"justify-center",
|
||||
"pointer-events-none",
|
||||
"pb-2",
|
||||
);
|
||||
expect(toolbarHost).not.toHaveClass("inset-x-2", "bottom-2");
|
||||
expect(toolbar).toHaveClass("pointer-events-auto");
|
||||
expect(toolbar).toHaveStyle({ transform: "translate(0px, 0px)" });
|
||||
});
|
||||
|
||||
it("clamps every edge and preserves recovery for an over-wide toolbar", () => {
|
||||
const hostRect = {
|
||||
left: 100,
|
||||
right: 500,
|
||||
top: 50,
|
||||
bottom: 350,
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
const toolbar = {
|
||||
left: 250,
|
||||
right: 450,
|
||||
top: 280,
|
||||
bottom: 330,
|
||||
width: 200,
|
||||
height: 50,
|
||||
};
|
||||
expect(
|
||||
clampToolbarPosition({ x: -500, y: 0 }, toolbar, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
x: -142,
|
||||
y: 0,
|
||||
});
|
||||
expect(
|
||||
clampToolbarPosition({ x: 500, y: 0 }, toolbar, hostRect, { x: 0, y: 0 }),
|
||||
).toEqual({
|
||||
x: 42,
|
||||
y: 0,
|
||||
});
|
||||
expect(
|
||||
clampToolbarPosition({ x: 0, y: -500 }, toolbar, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
x: 0,
|
||||
y: -222,
|
||||
});
|
||||
expect(
|
||||
clampToolbarPosition({ x: 0, y: 500 }, toolbar, hostRect, { x: 0, y: 0 }),
|
||||
).toEqual({
|
||||
x: 0,
|
||||
y: 12,
|
||||
});
|
||||
expect(
|
||||
clampToolbarPosition(
|
||||
{ x: -999, y: 0 },
|
||||
{ ...toolbar, left: -100, right: 600, width: 700 },
|
||||
hostRect,
|
||||
{ x: 0, y: 0 },
|
||||
).x,
|
||||
).toBe(-448);
|
||||
});
|
||||
|
||||
it("clamps an off-screen restored position to actual host bounds", async () => {
|
||||
localStorage.setItem(
|
||||
"termix-terminal-toolbar-position-v2",
|
||||
JSON.stringify({ x: 9000, y: -9000 }),
|
||||
);
|
||||
const original = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function () {
|
||||
if (this.hasAttribute("data-terminal-toolbar-host")) {
|
||||
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
|
||||
}
|
||||
if (this.hasAttribute("data-terminal-toolbar-wide")) {
|
||||
const match = this.style.transform.match(
|
||||
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
|
||||
);
|
||||
const x = Number(match?.[1] ?? 0);
|
||||
const y = Number(match?.[2] ?? 0);
|
||||
return DOMRect.fromRect({
|
||||
x: 250 + x,
|
||||
y: 250 + y,
|
||||
width: 250,
|
||||
height: 50,
|
||||
});
|
||||
}
|
||||
return original.call(this);
|
||||
},
|
||||
);
|
||||
const { container } = renderToolbar();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(container.querySelector("[data-terminal-toolbar-wide]") as HTMLElement)
|
||||
.style.transform,
|
||||
).toBe("translate(0px, 0px)"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the PC toolbar and Image group with adjacent actions", () => {
|
||||
const { container } = renderToolbar();
|
||||
expect(container.querySelector("[data-terminal-toolbar-host]")).toHaveClass(
|
||||
"@container",
|
||||
);
|
||||
expect(container.querySelector("[data-terminal-toolbar-wide]")).toHaveClass(
|
||||
"pointer-events-auto",
|
||||
);
|
||||
expect(
|
||||
container.querySelector("[data-terminal-toolbar-narrow]"),
|
||||
).toBeNull();
|
||||
|
||||
const imageGroup = screen.getAllByRole("group", {
|
||||
name: "Image actions (images only)",
|
||||
})[0];
|
||||
expect(imageGroup.querySelectorAll("button, label")).toHaveLength(2);
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Upload an image to the host" })[0],
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getAllByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
})[0],
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("uses explicit labels, conditional tmux, and compact interactive targets", () => {
|
||||
const { rerender, props } = renderToolbar();
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Detach tmux session" })[0],
|
||||
).toHaveTextContent("Detach tmux");
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Detach tmux session" })[0],
|
||||
).toHaveClass("min-h-8", "min-w-8");
|
||||
rerender(<TerminalToolbar {...props} isTmuxAttached={false} />);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Detach tmux session" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes density as a select and applies the chosen mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderToolbar();
|
||||
const trigger = screen.getByRole("combobox", {
|
||||
name: "Toolbar display mode",
|
||||
});
|
||||
expect(trigger).toHaveTextContent("");
|
||||
await user.click(trigger);
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((option) => option.textContent)).toEqual([
|
||||
"Icon only",
|
||||
"Icons and labels",
|
||||
"Expanded with metrics",
|
||||
]);
|
||||
await user.click(
|
||||
screen.getByRole("option", { name: "Expanded with metrics" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem("termix-terminal-toolbar-density")).toBe(
|
||||
"expanded",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps one file input mounted, resets repeated selections, and exposes loading", () => {
|
||||
const onUploadImage = vi.fn();
|
||||
const { container, rerender, props } = renderToolbar({ onUploadImage });
|
||||
const input = container.querySelector(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement;
|
||||
expect(container.querySelectorAll('input[type="file"]')).toHaveLength(1);
|
||||
const file = new File(["image"], "same.png", { type: "image/png" });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
expect(onUploadImage).toHaveBeenCalledTimes(2);
|
||||
rerender(<TerminalToolbar {...props} isImageUploading />);
|
||||
expect(container.querySelector('input[type="file"]')).toBe(input);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Uploading image…");
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Upload an image to the host" })[0],
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getAllByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
})[0],
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hides clearly, leaves recovery, and closes open UI on disconnect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender, props } = renderToolbar();
|
||||
await user.click(screen.getByRole("button", { name: "Hide toolbar" }));
|
||||
const collapsedMover = screen.getByRole("button", {
|
||||
name: "Move toolbar",
|
||||
});
|
||||
expect(collapsedMover).toHaveAttribute("aria-label", "Move toolbar");
|
||||
await user.click(screen.getByRole("button", { name: "Expand toolbar" }));
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Toolbar display mode" }),
|
||||
).toBeInTheDocument();
|
||||
rerender(<TerminalToolbar {...props} isConnected={false} />);
|
||||
expect(
|
||||
screen.queryByRole("combobox", { name: "Toolbar display mode" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls paste synchronously in the activation handler and does not cancel outside pointerdown", () => {
|
||||
const onPasteImage = vi.fn();
|
||||
renderToolbar({ onPasteImage });
|
||||
const paste = screen.getAllByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
})[0];
|
||||
fireEvent.click(paste);
|
||||
expect(onPasteImage).toHaveBeenCalledOnce();
|
||||
const outside = new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
document.body.dispatchEvent(outside);
|
||||
expect(outside.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("shows an actionable image error and retries the failed activation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPasteImage = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("Clipboard permission denied"))
|
||||
.mockResolvedValueOnce();
|
||||
renderToolbar({ onPasteImage });
|
||||
await user.click(
|
||||
screen.getAllByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
})[0],
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Clipboard permission denied",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(onPasteImage).toHaveBeenCalledTimes(2);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes synchronous image failures through the alert flow", async () => {
|
||||
renderToolbar({
|
||||
onPasteImage: vi.fn(() => {
|
||||
throw new Error("Synchronous clipboard failure");
|
||||
}),
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Synchronous clipboard failure",
|
||||
);
|
||||
});
|
||||
|
||||
it("orders desktop actions and keeps the grab handle persistent", () => {
|
||||
hostActionsApi.getSshActions.mockReturnValue([
|
||||
{ type: "files", icon: () => null, label: "Files" },
|
||||
{ type: "host-metrics", icon: () => null, label: "Host Metrics" },
|
||||
{ type: "tmux_monitor", icon: () => null, label: "Tmux Monitor" },
|
||||
]);
|
||||
const { container } = renderToolbar();
|
||||
const labels = Array.from(
|
||||
container.querySelectorAll("[data-terminal-toolbar-wide] button"),
|
||||
).map((button) => button.getAttribute("aria-label"));
|
||||
expect(labels).toEqual([
|
||||
"Files",
|
||||
"Host Metrics",
|
||||
"Tmux Monitor",
|
||||
"Detach tmux session",
|
||||
"Upload an image to the host",
|
||||
"Paste image from clipboard (images only)",
|
||||
"Toolbar display mode",
|
||||
"Hide toolbar",
|
||||
"Move toolbar",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows three modes and fades the expanded toolbar", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderToolbar();
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
expect(toolbar).toHaveClass(
|
||||
"opacity-30",
|
||||
"transition-opacity",
|
||||
"hover:opacity-100",
|
||||
"focus-within:opacity-100",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("combobox", { name: "Toolbar display mode" }),
|
||||
);
|
||||
expect(await screen.findAllByRole("option")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("gives the collapsed toolbar idle opacity and keyboard recovery", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderToolbar();
|
||||
await user.click(screen.getByRole("button", { name: "Hide toolbar" }));
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
expect(toolbar).toHaveClass(
|
||||
"opacity-100",
|
||||
"hover:opacity-100",
|
||||
"focus-within:opacity-100",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Expand toolbar" }),
|
||||
).toBeVisible();
|
||||
const collapsedMover = screen.getByRole("button", {
|
||||
name: "Move toolbar",
|
||||
});
|
||||
expect(collapsedMover).toBeVisible();
|
||||
expect(
|
||||
container.querySelector(".terminal-toolbar-collapsed-shell"),
|
||||
).toBeInTheDocument();
|
||||
collapsedMover.focus();
|
||||
expect(toolbar).toHaveClass("focus-within:opacity-100");
|
||||
const expandButton = screen.getByRole("button", {
|
||||
name: "Expand toolbar",
|
||||
});
|
||||
expandButton.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Hide toolbar" }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
}, 15_000);
|
||||
|
||||
it("keeps the mover on the right edge across collapse and expansion", async () => {
|
||||
const original = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function () {
|
||||
if (this.hasAttribute("data-terminal-toolbar-host")) {
|
||||
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
|
||||
}
|
||||
if (this.hasAttribute("data-terminal-toolbar-wide")) {
|
||||
const match = this.style.transform.match(
|
||||
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
|
||||
);
|
||||
const collapsed = !this.querySelector(
|
||||
'button[aria-label="Hide toolbar"]',
|
||||
);
|
||||
return DOMRect.fromRect({
|
||||
x: 225 + Number(match?.[1] ?? 0),
|
||||
y: 290 + Number(match?.[2] ?? 0),
|
||||
width: collapsed ? 44 : 250,
|
||||
height: collapsed ? 44 : 50,
|
||||
});
|
||||
}
|
||||
return original.call(this);
|
||||
},
|
||||
);
|
||||
localStorage.setItem(
|
||||
"termix-terminal-toolbar-position-v2",
|
||||
JSON.stringify({ x: 117, y: 2 }),
|
||||
);
|
||||
const { container } = renderToolbar();
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByRole("button", { name: "Hide toolbar" }));
|
||||
expect(toolbar.style.transform).toBe("translate(323px, 2px)");
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByRole("button", { name: "Expand toolbar" }));
|
||||
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
|
||||
});
|
||||
|
||||
it("moves and persists the toolbar position, then stops after pointerup", () => {
|
||||
const { container, unmount } = renderToolbar();
|
||||
const grab = screen.getByRole("button", { name: "Move toolbar" });
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
fireEvent.pointerDown(grab, {
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 1,
|
||||
clientX: 140,
|
||||
clientY: 90,
|
||||
});
|
||||
expect(toolbar.style.transform).toBe("translate(40px, -10px)");
|
||||
fireEvent.pointerUp(grab, { pointerId: 1 });
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("termix-terminal-toolbar-position-v2")!),
|
||||
).toEqual({ x: 40, y: -10 });
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 1,
|
||||
clientX: 500,
|
||||
clientY: 500,
|
||||
});
|
||||
expect(toolbar.style.transform).toBe("translate(40px, -10px)");
|
||||
unmount();
|
||||
const second = renderToolbar();
|
||||
expect(
|
||||
(
|
||||
second.container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement
|
||||
).style.transform,
|
||||
).toBe("translate(40px, -10px)");
|
||||
});
|
||||
|
||||
it("moves on both axes, clamps safely, ignores a different pointer ID, and persists", () => {
|
||||
const original = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function () {
|
||||
if (this.hasAttribute("data-terminal-toolbar-host")) {
|
||||
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
|
||||
}
|
||||
if (this.hasAttribute("data-terminal-toolbar-wide")) {
|
||||
const match = this.style.transform.match(
|
||||
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
|
||||
);
|
||||
return DOMRect.fromRect({
|
||||
x: 225 + Number(match?.[1] ?? 0),
|
||||
y: 290 + Number(match?.[2] ?? 0),
|
||||
width: 250,
|
||||
height: 50,
|
||||
});
|
||||
}
|
||||
return original.call(this);
|
||||
},
|
||||
);
|
||||
const { container } = renderToolbar();
|
||||
const grab = screen.getByRole("button", { name: "Move toolbar" });
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
fireEvent.pointerDown(grab, {
|
||||
pointerId: 11,
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 12,
|
||||
clientX: 30,
|
||||
clientY: -500,
|
||||
});
|
||||
expect(toolbar).toHaveStyle({ transform: "translate(0px, 0px)" });
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 11,
|
||||
clientX: 30,
|
||||
clientY: 120,
|
||||
});
|
||||
expect(toolbar.style.transform).toBe("translate(20px, 2px)");
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 11,
|
||||
clientX: 500,
|
||||
clientY: 500,
|
||||
});
|
||||
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 11,
|
||||
clientX: 500,
|
||||
clientY: 500,
|
||||
});
|
||||
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
|
||||
fireEvent.pointerUp(grab, { pointerId: 11 });
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("termix-terminal-toolbar-position-v2")!),
|
||||
).toEqual({ x: 117, y: 2 });
|
||||
});
|
||||
|
||||
it("avoids ResizeObserver write churn and writes one real reclamp", async () => {
|
||||
let resizeCallback: ResizeObserverCallback | undefined;
|
||||
class ControlledResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", ControlledResizeObserver);
|
||||
let hostWidth = 500;
|
||||
const original = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function () {
|
||||
if (this.hasAttribute("data-terminal-toolbar-host")) {
|
||||
return DOMRect.fromRect({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: hostWidth,
|
||||
height: 300,
|
||||
});
|
||||
}
|
||||
if (this.hasAttribute("data-terminal-toolbar-wide")) {
|
||||
const match = this.style.transform.match(
|
||||
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
|
||||
);
|
||||
return DOMRect.fromRect({
|
||||
x: 125 + Number(match?.[1] ?? 0),
|
||||
y: 242 + Number(match?.[2] ?? 0),
|
||||
width: 250,
|
||||
height: 50,
|
||||
});
|
||||
}
|
||||
return original.call(this);
|
||||
},
|
||||
);
|
||||
const writes = vi.spyOn(Storage.prototype, "setItem");
|
||||
renderToolbar();
|
||||
writes.mockClear();
|
||||
act(() => {
|
||||
resizeCallback?.([], {} as ResizeObserver);
|
||||
resizeCallback?.([], {} as ResizeObserver);
|
||||
});
|
||||
expect(writes).not.toHaveBeenCalledWith(
|
||||
"termix-terminal-toolbar-position-v2",
|
||||
expect.any(String),
|
||||
);
|
||||
hostWidth = 200;
|
||||
act(() => resizeCallback?.([], {} as ResizeObserver));
|
||||
expect(writes).not.toHaveBeenCalledWith(
|
||||
"termix-terminal-toolbar-position-v2",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(
|
||||
writes.mock.calls.filter(
|
||||
([key]) => key === "termix-terminal-toolbar-position-v2",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([undefined, true])(
|
||||
"does not mount or run desktop side effects when mobile is %s",
|
||||
(isMobile) => {
|
||||
mobileApi.isMobile = isMobile;
|
||||
localStorage.setItem("termix-terminal-toolbar-density", "expanded");
|
||||
const writes = vi.spyOn(Storage.prototype, "setItem");
|
||||
const addWindowListener = vi.spyOn(window, "addEventListener");
|
||||
const resizeObserver = vi.fn();
|
||||
vi.stubGlobal("ResizeObserver", resizeObserver);
|
||||
|
||||
const { container } = renderToolbar();
|
||||
|
||||
expect(
|
||||
container.querySelector("[data-terminal-toolbar-host]"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(api.startMetricsPolling).not.toHaveBeenCalled();
|
||||
expect(writes).not.toHaveBeenCalled();
|
||||
expect(resizeObserver).not.toHaveBeenCalled();
|
||||
expect(addWindowListener).not.toHaveBeenCalledWith(
|
||||
"resize",
|
||||
expect.any(Function),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("mounts the toolbar and polls metrics once desktop is confirmed", async () => {
|
||||
mobileApi.isMobile = false;
|
||||
localStorage.setItem("termix-terminal-toolbar-density", "expanded");
|
||||
const { container } = renderToolbar();
|
||||
expect(
|
||||
container.querySelector("[data-terminal-toolbar-host]"),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(api.startMetricsPolling).toHaveBeenCalledWith(7),
|
||||
);
|
||||
});
|
||||
|
||||
it("ends dragging on pointer cancellation and lost capture, and rejects touch", () => {
|
||||
const { container } = renderToolbar();
|
||||
const grab = screen.getByRole("button", { name: "Move toolbar" });
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
fireEvent.pointerDown(grab, {
|
||||
pointerId: 2,
|
||||
pointerType: "touch",
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
fireEvent.pointerMove(grab, { pointerId: 2, clientX: 50, clientY: 50 });
|
||||
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
|
||||
|
||||
fireEvent.pointerDown(grab, {
|
||||
pointerId: 3,
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
fireEvent.pointerCancel(grab, { pointerId: 3 });
|
||||
fireEvent.pointerMove(grab, { pointerId: 3, clientX: 50, clientY: 50 });
|
||||
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
|
||||
|
||||
fireEvent.pointerDown(grab, {
|
||||
pointerId: 4,
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
fireEvent.lostPointerCapture(grab, { pointerId: 4 });
|
||||
fireEvent.pointerMove(grab, { pointerId: 4, clientX: 50, clientY: 50 });
|
||||
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
|
||||
});
|
||||
|
||||
it("ignores stale image completions after an instance change in Strict Mode", async () => {
|
||||
let reject!: (error: Error) => void;
|
||||
const pending = new Promise<void>((_, rejectPromise) => {
|
||||
reject = rejectPromise;
|
||||
});
|
||||
const props = {
|
||||
host,
|
||||
isConnected: true,
|
||||
isTmuxAttached: false,
|
||||
onTmuxDetach: vi.fn(),
|
||||
isImageUploading: false,
|
||||
onUploadImage: vi.fn(),
|
||||
onPasteImage: vi.fn(() => pending),
|
||||
isFocused: true,
|
||||
};
|
||||
const { rerender } = render(
|
||||
<StrictMode>
|
||||
<TerminalToolbar {...props} />
|
||||
</StrictMode>,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
}),
|
||||
);
|
||||
rerender(
|
||||
<StrictMode>
|
||||
<TerminalToolbar
|
||||
{...props}
|
||||
host={{ ...host, id: 8 } as unknown as Host}
|
||||
/>
|
||||
</StrictMode>,
|
||||
);
|
||||
reject(new Error("old instance"));
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stops a metrics viewer that resolves after cleanup", async () => {
|
||||
let resolveStart!: (value: { viewerSessionId: string }) => void;
|
||||
api.startMetricsPolling.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}),
|
||||
);
|
||||
localStorage.setItem("termix-terminal-toolbar-density", "expanded");
|
||||
const view = renderToolbar();
|
||||
view.unmount();
|
||||
resolveStart({ viewerSessionId: "late-viewer" });
|
||||
await waitFor(() =>
|
||||
expect(api.stopMetricsPolling).toHaveBeenCalledWith(7, "late-viewer"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { quoteTerminalImagePath } from "@/features/terminal/terminal-image-path";
|
||||
|
||||
describe("quoteTerminalImagePath", () => {
|
||||
it("preserves spaces as one shell argument", () => {
|
||||
expect(quoteTerminalImagePath("/mnt/my images/clipboard.png")).toBe(
|
||||
"'/mnt/my images/clipboard.png'",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose shell metacharacters as syntax", () => {
|
||||
expect(
|
||||
quoteTerminalImagePath("/tmp/$(touch /tmp/pwned);`id`; 'quoted'.png"),
|
||||
).toBe("'/tmp/$(touch /tmp/pwned);`id`; '\\''quoted'\\''.png'");
|
||||
});
|
||||
|
||||
it("quotes an empty path instead of returning executable input", () => {
|
||||
expect(quoteTerminalImagePath("")).toBe("''");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildImageUploadFormData } from "@/features/terminal/terminal-image-upload";
|
||||
|
||||
describe("buildImageUploadFormData", () => {
|
||||
it("tags a picked-file upload with source=file and a client timestamp", () => {
|
||||
const file = new File(["png-bytes"], "photo.png", { type: "image/png" });
|
||||
|
||||
const form = buildImageUploadFormData(file, "tab-1", "file");
|
||||
|
||||
expect(form.get("image")).toBe(file);
|
||||
expect(form.get("instanceId")).toBe("tab-1");
|
||||
expect(form.get("source")).toBe("file");
|
||||
const timestamp = form.get("clientUploadTimestamp");
|
||||
expect(typeof timestamp).toBe("string");
|
||||
expect(Number.isNaN(Date.parse(timestamp as string))).toBe(false);
|
||||
});
|
||||
|
||||
it("tags a clipboard upload with source=clipboard", () => {
|
||||
const file = new File(["png-bytes"], "clipboard-image.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
const form = buildImageUploadFormData(file, "tab-1", "clipboard");
|
||||
|
||||
expect(form.get("source")).toBe("clipboard");
|
||||
});
|
||||
|
||||
it("uses the caller-provided client timestamp unchanged", () => {
|
||||
const file = new File(["png-bytes"], "photo.png", { type: "image/png" });
|
||||
|
||||
const form = buildImageUploadFormData(
|
||||
file,
|
||||
"tab-1",
|
||||
"file",
|
||||
"2026-08-15T12:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(form.get("clientUploadTimestamp")).toBe("2026-08-15T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installTouchWheelCoordinator,
|
||||
TouchWheelCoordinator,
|
||||
} from "../../../features/terminal/touch-wheel-coordinator";
|
||||
import { TOUCH_INPUT_DEFAULTS } from "@/types/touch-input-settings";
|
||||
|
||||
const point = (clientY: number, clientX = 10) => ({ clientX, clientY });
|
||||
|
||||
const touchEvent = (type: string, clientYValues: number[]) => {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
const touches = clientYValues.map((clientY) => ({ clientX: 10, clientY }));
|
||||
Object.defineProperty(event, "touches", {
|
||||
value: Object.assign(touches, {
|
||||
item: (index: number) => touches[index] ?? null,
|
||||
}),
|
||||
});
|
||||
return event;
|
||||
};
|
||||
|
||||
const createTiming = (momentumAllowed = true) => {
|
||||
let now = 0;
|
||||
let nextHandle = 1;
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
return {
|
||||
timing: {
|
||||
now: () => now,
|
||||
requestFrame: (callback: FrameRequestCallback) => {
|
||||
const handle = nextHandle++;
|
||||
callbacks.set(handle, callback);
|
||||
return handle;
|
||||
},
|
||||
cancelFrame: (handle: number) => callbacks.delete(handle),
|
||||
momentumAllowed: () => momentumAllowed,
|
||||
},
|
||||
advance(milliseconds: number) {
|
||||
now += milliseconds;
|
||||
const pending = [...callbacks.values()];
|
||||
callbacks.clear();
|
||||
pending.forEach((callback) => callback(now));
|
||||
},
|
||||
pendingFrames: () => callbacks.size,
|
||||
};
|
||||
};
|
||||
|
||||
describe("TouchWheelCoordinator", () => {
|
||||
it("consumes configured drag and wheel limits", () => {
|
||||
const emit = vi.fn();
|
||||
const coordinator = new TouchWheelCoordinator(emit, 2, 3, undefined, {
|
||||
...TOUCH_INPUT_DEFAULTS,
|
||||
dragThresholdPx: 2,
|
||||
maxWheelDeltaPx: 3,
|
||||
});
|
||||
coordinator.start(point(20));
|
||||
coordinator.move(point(10));
|
||||
expect(emit.mock.calls.map(([event]) => event.deltaY)).toEqual([
|
||||
3, 3, 3, 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it("accumulates the drag threshold and preserves delta direction", () => {
|
||||
const emit = vi.fn();
|
||||
const coordinator = new TouchWheelCoordinator(emit);
|
||||
|
||||
coordinator.start(point(100));
|
||||
expect(coordinator.move(point(97.5))).toBe(true);
|
||||
expect(coordinator.move(point(93))).toBe(true);
|
||||
coordinator.move(point(101));
|
||||
|
||||
expect(emit.mock.calls.map(([event]) => event.deltaY)).toEqual([7, -8]);
|
||||
});
|
||||
|
||||
it("keeps movement below the tap threshold from scrolling", () => {
|
||||
const emit = vi.fn();
|
||||
const coordinator = new TouchWheelCoordinator(emit);
|
||||
|
||||
coordinator.start(point(100));
|
||||
expect(coordinator.move(point(95))).toBe(true);
|
||||
coordinator.end();
|
||||
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels the gesture when more than one touch is represented", () => {
|
||||
const emit = vi.fn();
|
||||
const coordinator = new TouchWheelCoordinator(emit);
|
||||
|
||||
coordinator.start(point(100));
|
||||
coordinator.move(undefined);
|
||||
expect(coordinator.move(point(80))).toBe(false);
|
||||
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up cancelled state before the next gesture", () => {
|
||||
const emit = vi.fn();
|
||||
const coordinator = new TouchWheelCoordinator(emit);
|
||||
|
||||
coordinator.start(point(100));
|
||||
coordinator.cancel();
|
||||
coordinator.start(point(50));
|
||||
expect(coordinator.move(point(43.5))).toBe(true);
|
||||
|
||||
expect(emit).toHaveBeenCalledOnce();
|
||||
expect(emit.mock.calls[0][0].deltaY).toBe(6.5);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ start: 100, end: 20, direction: 1 },
|
||||
{ start: 20, end: 100, direction: -1 },
|
||||
])(
|
||||
"emits multiple $direction line ticks after release",
|
||||
({ start, end, direction }) => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
|
||||
coordinator.start(point(start));
|
||||
clock.advance(20);
|
||||
coordinator.move(point((start + end) / 2));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(end));
|
||||
coordinator.end();
|
||||
const dragCallCount = emit.mock.calls.length;
|
||||
|
||||
for (let index = 0; index < 12; index++) clock.advance(16);
|
||||
const momentum = emit.mock.calls
|
||||
.slice(dragCallCount)
|
||||
.map(([event]) => event);
|
||||
|
||||
expect(momentum.length).toBeGreaterThan(3);
|
||||
expect(
|
||||
momentum.every(
|
||||
(event) =>
|
||||
event.deltaMode === WheelEvent.DOM_DELTA_LINE &&
|
||||
event.deltaY === direction,
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("decays the number of line ticks emitted over time", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
coordinator.start(point(200));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(100));
|
||||
coordinator.end();
|
||||
const dragCalls = emit.mock.calls.length;
|
||||
|
||||
const frameCounts: number[] = [];
|
||||
for (let index = 0; index < 24; index++) {
|
||||
const before = emit.mock.calls.length;
|
||||
clock.advance(16);
|
||||
frameCounts.push(emit.mock.calls.length - before);
|
||||
}
|
||||
|
||||
expect(emit.mock.calls.length - dragCalls).toBeGreaterThan(3);
|
||||
expect(frameCounts.slice(0, 8).reduce((a, b) => a + b, 0)).toBeGreaterThan(
|
||||
frameCounts.slice(-8).reduce((a, b) => a + b, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the recent movement window despite a short touchend delay", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
coordinator.start(point(100));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(50));
|
||||
clock.advance(80);
|
||||
coordinator.end();
|
||||
|
||||
expect(clock.pendingFrames()).toBe(1);
|
||||
});
|
||||
|
||||
it("bounds momentum velocity, duration, and total travel", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
|
||||
coordinator.start(point(1000));
|
||||
clock.advance(1);
|
||||
coordinator.move(point(0));
|
||||
coordinator.end();
|
||||
const dragCallCount = emit.mock.calls.length;
|
||||
clock.advance(200);
|
||||
const firstFrameTicks = emit.mock.calls.length - dragCallCount;
|
||||
for (let index = 0; index < 30; index++) clock.advance(20);
|
||||
expect(clock.pendingFrames()).toBe(1);
|
||||
for (let index = 0; index < 10; index++) clock.advance(20);
|
||||
const totalTicks = emit.mock.calls.length - dragCallCount;
|
||||
|
||||
expect(firstFrameTicks).toBeLessThanOrEqual(4);
|
||||
expect(totalTicks).toBeLessThanOrEqual(Math.floor(720 / 12));
|
||||
expect(clock.pendingFrames()).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels momentum on a new touch or cancellation", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
|
||||
coordinator.start(point(100));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(50));
|
||||
coordinator.end();
|
||||
expect(clock.pendingFrames()).toBe(1);
|
||||
coordinator.start(point(60));
|
||||
expect(clock.pendingFrames()).toBe(0);
|
||||
|
||||
clock.advance(20);
|
||||
coordinator.move(point(20));
|
||||
coordinator.end();
|
||||
expect(clock.pendingFrames()).toBe(1);
|
||||
coordinator.cancel();
|
||||
expect(clock.pendingFrames()).toBe(0);
|
||||
});
|
||||
|
||||
it("disables momentum when reduced motion is requested", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming(false);
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
|
||||
coordinator.start(point(100));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(50));
|
||||
coordinator.end();
|
||||
|
||||
expect(clock.pendingFrames()).toBe(0);
|
||||
expect(emit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stops momentum if the active terminal mode changes", () => {
|
||||
const emit = vi.fn();
|
||||
let momentumAllowed = true;
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(emit, undefined, undefined, {
|
||||
...clock.timing,
|
||||
momentumAllowed: () => momentumAllowed,
|
||||
});
|
||||
|
||||
coordinator.start(point(100));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(50));
|
||||
coordinator.end();
|
||||
momentumAllowed = false;
|
||||
clock.advance(20);
|
||||
|
||||
expect(emit).toHaveBeenCalledOnce();
|
||||
expect(clock.pendingFrames()).toBe(0);
|
||||
});
|
||||
|
||||
it("retains fractional pixel distance until it forms a whole line tick", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
|
||||
coordinator.start(point(100));
|
||||
clock.advance(100);
|
||||
coordinator.move(point(80));
|
||||
coordinator.end();
|
||||
const dragCallCount = emit.mock.calls.length;
|
||||
for (let index = 0; index < 4; index++) clock.advance(16);
|
||||
expect(emit.mock.calls.length).toBe(dragCallCount);
|
||||
clock.advance(16);
|
||||
expect(emit.mock.calls.length).toBeGreaterThan(dragCallCount);
|
||||
expect(emit.mock.calls[dragCallCount][0]).toMatchObject({
|
||||
deltaY: 1,
|
||||
deltaMode: WheelEvent.DOM_DELTA_LINE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("installTouchWheelCoordinator", () => {
|
||||
it("claims the first single-touch move but preserves taps and multi-touch", () => {
|
||||
const terminalElement = document.createElement("div");
|
||||
const scrollableElement = document.createElement("div");
|
||||
scrollableElement.className = "xterm-scrollable-element";
|
||||
const screenElement = document.createElement("div");
|
||||
screenElement.className = "xterm-screen";
|
||||
scrollableElement.append(screenElement);
|
||||
terminalElement.append(scrollableElement);
|
||||
const dispose = installTouchWheelCoordinator(terminalElement);
|
||||
|
||||
terminalElement.dispatchEvent(touchEvent("touchstart", [100]));
|
||||
const earlyMove = touchEvent("touchmove", [99]);
|
||||
terminalElement.dispatchEvent(earlyMove);
|
||||
expect(earlyMove.defaultPrevented).toBe(true);
|
||||
|
||||
terminalElement.dispatchEvent(touchEvent("touchend", []));
|
||||
const tapEnd = touchEvent("touchend", []);
|
||||
terminalElement.dispatchEvent(tapEnd);
|
||||
expect(tapEnd.defaultPrevented).toBe(false);
|
||||
|
||||
terminalElement.dispatchEvent(touchEvent("touchstart", [100]));
|
||||
const multiTouchMove = touchEvent("touchmove", [90, 80]);
|
||||
terminalElement.dispatchEvent(multiTouchMove);
|
||||
expect(multiTouchMove.defaultPrevented).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("dispatches wheel events at xterm's normal scrollback seam", () => {
|
||||
const terminalElement = document.createElement("div");
|
||||
const scrollableElement = document.createElement("div");
|
||||
scrollableElement.className = "xterm-scrollable-element";
|
||||
const screenElement = document.createElement("div");
|
||||
const canvas = document.createElement("canvas");
|
||||
screenElement.className = "xterm-screen";
|
||||
screenElement.append(canvas);
|
||||
scrollableElement.append(screenElement);
|
||||
terminalElement.append(scrollableElement);
|
||||
const scrollableWheel = vi.fn();
|
||||
const canvasWheel = vi.fn();
|
||||
scrollableElement.addEventListener("wheel", scrollableWheel);
|
||||
canvas.addEventListener("wheel", canvasWheel);
|
||||
const dispose = installTouchWheelCoordinator(terminalElement);
|
||||
|
||||
canvas.dispatchEvent(touchEvent("touchstart", [100]));
|
||||
canvas.dispatchEvent(touchEvent("touchmove", [90]));
|
||||
|
||||
expect(scrollableWheel).toHaveBeenCalledOnce();
|
||||
expect(canvasWheel).not.toHaveBeenCalled();
|
||||
expect((scrollableWheel.mock.calls[0][0] as WheelEvent).deltaY).toBe(10);
|
||||
expect((scrollableWheel.mock.calls[0][0] as WheelEvent).deltaMode).toBe(
|
||||
WheelEvent.DOM_DELTA_PIXEL,
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("dispatches momentum as line-mode wheel ticks from the scrollback seam", () => {
|
||||
let now = 0;
|
||||
let nextHandle = 1;
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
const handle = nextHandle++;
|
||||
callbacks.set(handle, callback);
|
||||
return handle;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
|
||||
callbacks.delete(handle);
|
||||
});
|
||||
|
||||
const terminalElement = document.createElement("div");
|
||||
const scrollableElement = document.createElement("div");
|
||||
scrollableElement.className = "xterm-scrollable-element";
|
||||
const screenElement = document.createElement("div");
|
||||
const canvas = document.createElement("canvas");
|
||||
screenElement.className = "xterm-screen";
|
||||
screenElement.append(canvas);
|
||||
scrollableElement.append(screenElement);
|
||||
terminalElement.append(scrollableElement);
|
||||
const wheels: WheelEvent[] = [];
|
||||
scrollableElement.addEventListener("wheel", (event) =>
|
||||
wheels.push(event as WheelEvent),
|
||||
);
|
||||
const dispose = installTouchWheelCoordinator(terminalElement);
|
||||
|
||||
canvas.dispatchEvent(touchEvent("touchstart", [100]));
|
||||
now = 20;
|
||||
canvas.dispatchEvent(touchEvent("touchmove", [40]));
|
||||
canvas.dispatchEvent(touchEvent("touchend", []));
|
||||
for (let index = 0; index < 8; index++) {
|
||||
now += 16;
|
||||
const pending = [...callbacks.values()];
|
||||
callbacks.clear();
|
||||
pending.forEach((callback) => callback(now));
|
||||
}
|
||||
|
||||
const momentum = wheels.filter(
|
||||
(event) => event.deltaMode === WheelEvent.DOM_DELTA_LINE,
|
||||
);
|
||||
expect(momentum.length).toBeGreaterThan(1);
|
||||
expect(momentum.every((event) => event.deltaY === 1)).toBe(true);
|
||||
expect(wheels.every((event) => event.currentTarget === null)).toBe(true);
|
||||
dispose();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { installTouchWheelCoordinator } from "../../../features/terminal/touch-wheel-coordinator";
|
||||
|
||||
const touchEvent = (type: string, clientYValues: number[]) => {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
const touches = clientYValues.map((clientY) => ({ clientX: 10, clientY }));
|
||||
Object.defineProperty(event, "touches", {
|
||||
value: Object.assign(touches, {
|
||||
item: (index: number) => touches[index] ?? null,
|
||||
}),
|
||||
});
|
||||
return event;
|
||||
};
|
||||
|
||||
const write = (terminal: Terminal, data: string) =>
|
||||
new Promise<void>((resolve) => terminal.write(data, resolve));
|
||||
|
||||
describe("touch wheel coordinator with xterm", () => {
|
||||
let terminal: Terminal | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
terminal?.dispose();
|
||||
container?.remove();
|
||||
terminal = undefined;
|
||||
container = undefined;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const installXtermCharacterMeasurements = () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
fillStyle: "",
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
return this.classList.contains("xterm-char-measure-element")
|
||||
? 32 * 9
|
||||
: 0;
|
||||
},
|
||||
);
|
||||
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
return this.classList.contains("xterm-char-measure-element") ? 18 : 0;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
it("moves real normal-buffer scrollback during a drag and momentum", async () => {
|
||||
installXtermCharacterMeasurements();
|
||||
let now = 0;
|
||||
let nextHandle = 1;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
const handle = nextHandle++;
|
||||
frames.set(handle, callback);
|
||||
return handle;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
|
||||
frames.delete(handle);
|
||||
});
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal({ cols: 80, rows: 10, scrollback: 1_000 });
|
||||
terminal.open(container);
|
||||
await write(
|
||||
terminal,
|
||||
Array.from({ length: 500 }, (_, index) => `${index + 1}\r\n`).join(""),
|
||||
);
|
||||
|
||||
const element = terminal.element!;
|
||||
const dispose = installTouchWheelCoordinator(element);
|
||||
terminal.scrollToBottom();
|
||||
const bottom = terminal.buffer.active.viewportY;
|
||||
|
||||
element.dispatchEvent(touchEvent("touchstart", [20]));
|
||||
now = 20;
|
||||
element.dispatchEvent(touchEvent("touchmove", [100]));
|
||||
const afterDrag = terminal.buffer.active.viewportY;
|
||||
element.dispatchEvent(touchEvent("touchend", []));
|
||||
for (let index = 0; index < 8; index++) {
|
||||
now += 16;
|
||||
const pending = [...frames.values()];
|
||||
frames.clear();
|
||||
pending.forEach((callback) => callback(now));
|
||||
}
|
||||
|
||||
expect(bottom).toBeGreaterThan(400);
|
||||
expect(afterDrag).toBeLessThan(bottom);
|
||||
expect(terminal.buffer.active.viewportY).toBeLessThan(afterDrag);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("preserves alternate-buffer wheel line ticks", async () => {
|
||||
installXtermCharacterMeasurements();
|
||||
let now = 0;
|
||||
let nextHandle = 1;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
const handle = nextHandle++;
|
||||
frames.set(handle, callback);
|
||||
return handle;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
|
||||
frames.delete(handle);
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal({ cols: 80, rows: 10 });
|
||||
terminal.open(container);
|
||||
const data: string[] = [];
|
||||
terminal.onData((value) => data.push(value));
|
||||
await write(terminal, "\x1b[?1049h");
|
||||
|
||||
const element = terminal.element!;
|
||||
const dispose = installTouchWheelCoordinator(element);
|
||||
element.dispatchEvent(touchEvent("touchstart", [100]));
|
||||
now = 20;
|
||||
element.dispatchEvent(touchEvent("touchmove", [0]));
|
||||
data.length = 0;
|
||||
element.dispatchEvent(touchEvent("touchend", []));
|
||||
for (let index = 0; index < 8; index++) {
|
||||
now += 16;
|
||||
const pending = [...frames.values()];
|
||||
frames.clear();
|
||||
pending.forEach((callback) => callback(now));
|
||||
}
|
||||
|
||||
expect(terminal.buffer.active.type).toBe("alternate");
|
||||
expect(data.length).toBeGreaterThan(1);
|
||||
expect(data.every((value) => value === "\x1b[B")).toBe(true);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
|
||||
const getAiStatus = vi.fn();
|
||||
vi.mock("@/api/ai-api", () => ({ getAiStatus }));
|
||||
|
||||
const { useAiAvailability, notifyAiStatusChanged } =
|
||||
await import("../../hooks/use-ai-availability.js");
|
||||
|
||||
beforeEach(() => {
|
||||
getAiStatus.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useAiAvailability", () => {
|
||||
it("starts hidden and unloaded before the status answers", () => {
|
||||
getAiStatus.mockReturnValue(new Promise(() => {}));
|
||||
const { result } = renderHook(() => useAiAvailability());
|
||||
expect(result.current).toEqual({
|
||||
globallyEnabled: false,
|
||||
userEnabled: false,
|
||||
loaded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports both gates open when the admin and the user enabled it", async () => {
|
||||
getAiStatus.mockResolvedValue({
|
||||
globallyEnabled: true,
|
||||
enabled: true,
|
||||
allowReadOnlyCommands: false,
|
||||
});
|
||||
const { result } = renderHook(() => useAiAvailability());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.globallyEnabled).toBe(true);
|
||||
expect(result.current.userEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps userEnabled false when the user has not opted in", async () => {
|
||||
getAiStatus.mockResolvedValue({
|
||||
globallyEnabled: true,
|
||||
enabled: false,
|
||||
allowReadOnlyCommands: false,
|
||||
});
|
||||
const { result } = renderHook(() => useAiAvailability());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.globallyEnabled).toBe(true);
|
||||
expect(result.current.userEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("the admin kill switch wins over the user opt-in", async () => {
|
||||
getAiStatus.mockResolvedValue({
|
||||
globallyEnabled: false,
|
||||
enabled: true,
|
||||
allowReadOnlyCommands: true,
|
||||
});
|
||||
const { result } = renderHook(() => useAiAvailability());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.globallyEnabled).toBe(false);
|
||||
expect(result.current.userEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a failed status call as off", async () => {
|
||||
getAiStatus.mockRejectedValue(new Error("boom"));
|
||||
const { result } = renderHook(() => useAiAvailability());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.globallyEnabled).toBe(false);
|
||||
expect(result.current.userEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("re-reads the status when notified", async () => {
|
||||
getAiStatus.mockResolvedValue({
|
||||
globallyEnabled: true,
|
||||
enabled: true,
|
||||
allowReadOnlyCommands: false,
|
||||
});
|
||||
const { result } = renderHook(() => useAiAvailability());
|
||||
await waitFor(() => expect(result.current.globallyEnabled).toBe(true));
|
||||
|
||||
getAiStatus.mockResolvedValue({
|
||||
globallyEnabled: false,
|
||||
enabled: true,
|
||||
allowReadOnlyCommands: false,
|
||||
});
|
||||
act(() => notifyAiStatusChanged());
|
||||
await waitFor(() => expect(result.current.globallyEnabled).toBe(false));
|
||||
expect(result.current.userEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("also re-reads on the older hiddenRailTabsChanged event", async () => {
|
||||
getAiStatus.mockResolvedValue({
|
||||
globallyEnabled: true,
|
||||
enabled: false,
|
||||
allowReadOnlyCommands: false,
|
||||
});
|
||||
const { result } = renderHook(() => useAiAvailability());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
|
||||
getAiStatus.mockResolvedValue({
|
||||
globallyEnabled: true,
|
||||
enabled: true,
|
||||
allowReadOnlyCommands: false,
|
||||
});
|
||||
act(() => window.dispatchEvent(new Event("hiddenRailTabsChanged")));
|
||||
await waitFor(() => expect(result.current.userEnabled).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import {
|
||||
readStatusColorScheme,
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
} from "../../hooks/use-status-color-scheme";
|
||||
|
||||
const PREFS_KEY = "hostSidebarPreferences";
|
||||
|
||||
function writePrefs(scheme: string) {
|
||||
localStorage.setItem(
|
||||
PREFS_KEY,
|
||||
JSON.stringify({ display: { statusColorScheme: scheme } }),
|
||||
);
|
||||
}
|
||||
|
||||
describe("readStatusColorScheme", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("defaults to accent when nothing is cached", () => {
|
||||
expect(readStatusColorScheme()).toBe("accent");
|
||||
});
|
||||
|
||||
it("reads the scheme out of the sidebar preferences cache", () => {
|
||||
writePrefs("status");
|
||||
expect(readStatusColorScheme()).toBe("status");
|
||||
});
|
||||
|
||||
it("falls back to accent on malformed cache", () => {
|
||||
localStorage.setItem(PREFS_KEY, "not json");
|
||||
expect(readStatusColorScheme()).toBe("accent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStatusColorScheme", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("picks up the cached scheme on mount", () => {
|
||||
writePrefs("status");
|
||||
const { result } = renderHook(() => useStatusColorScheme());
|
||||
expect(result.current).toBe("status");
|
||||
});
|
||||
|
||||
it("updates when the sidebar preferences change event fires", () => {
|
||||
const { result } = renderHook(() => useStatusColorScheme());
|
||||
expect(result.current).toBe("accent");
|
||||
|
||||
act(() => {
|
||||
writePrefs("status");
|
||||
window.dispatchEvent(new Event("hostSidebarPreferencesChanged"));
|
||||
});
|
||||
|
||||
expect(result.current).toBe("status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStatusClasses", () => {
|
||||
it("uses green/red for the status scheme", () => {
|
||||
expect(getStatusClasses(true, "status", "dot")).toContain("emerald");
|
||||
expect(getStatusClasses(false, "status", "dot")).toContain("red");
|
||||
});
|
||||
|
||||
it("uses the accent color for the accent scheme", () => {
|
||||
expect(getStatusClasses(true, "accent", "dot")).toContain("accent-brand");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
computeAdaptivePollDelay,
|
||||
getPollingEnvironmentMultiplier,
|
||||
runAdaptivePolling,
|
||||
} from "../../lib/adaptive-polling.ts";
|
||||
|
||||
describe("adaptive polling", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("slows down after stable results and backs off after failures", () => {
|
||||
const policy = {
|
||||
minIntervalMs: 1000,
|
||||
maxIntervalMs: 16000,
|
||||
stablePollsPerStep: 2,
|
||||
jitterRatio: 0,
|
||||
};
|
||||
expect(
|
||||
computeAdaptivePollDelay(policy, {
|
||||
stablePolls: 4,
|
||||
consecutiveFailures: 0,
|
||||
}),
|
||||
).toBe(4000);
|
||||
expect(
|
||||
computeAdaptivePollDelay(policy, {
|
||||
stablePolls: 0,
|
||||
consecutiveFailures: 3,
|
||||
}),
|
||||
).toBe(8000);
|
||||
});
|
||||
|
||||
it("adds bounded jitter", () => {
|
||||
const policy = {
|
||||
minIntervalMs: 1000,
|
||||
maxIntervalMs: 10000,
|
||||
jitterRatio: 0.1,
|
||||
};
|
||||
const state = { stablePolls: 0, consecutiveFailures: 2 };
|
||||
expect(computeAdaptivePollDelay(policy, state, () => 0)).toBe(3600);
|
||||
expect(computeAdaptivePollDelay(policy, state, () => 1)).toBe(4400);
|
||||
});
|
||||
|
||||
it("limits request duty cycle when polling is expensive", () => {
|
||||
expect(
|
||||
computeAdaptivePollDelay(
|
||||
{
|
||||
minIntervalMs: 1000,
|
||||
maxIntervalMs: 30000,
|
||||
maxRequestDutyCycle: 0.2,
|
||||
jitterRatio: 0,
|
||||
},
|
||||
{
|
||||
stablePolls: 0,
|
||||
consecutiveFailures: 0,
|
||||
lastPollDurationMs: 2000,
|
||||
},
|
||||
),
|
||||
).toBe(8000);
|
||||
});
|
||||
|
||||
it("slows non-critical polling on constrained connections", () => {
|
||||
expect(getPollingEnvironmentMultiplier({ saveData: true })).toBe(2);
|
||||
expect(getPollingEnvironmentMultiplier({ effectiveType: "2g" })).toBe(1.75);
|
||||
expect(getPollingEnvironmentMultiplier({ effectiveType: "4g" })).toBe(1);
|
||||
});
|
||||
|
||||
it("never overlaps requests", async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolvePoll!: () => void;
|
||||
const poll = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolvePoll = resolve;
|
||||
}),
|
||||
);
|
||||
const stop = runAdaptivePolling(
|
||||
poll,
|
||||
{ minIntervalMs: 1000, maxIntervalMs: 4000, jitterRatio: 0 },
|
||||
{ runImmediately: true },
|
||||
);
|
||||
|
||||
expect(poll).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(poll).toHaveBeenCalledTimes(1);
|
||||
resolvePoll();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("slows stable data and resets when data changes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const poll = vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValue(false);
|
||||
const stop = runAdaptivePolling(
|
||||
poll,
|
||||
{
|
||||
minIntervalMs: 1000,
|
||||
maxIntervalMs: 8000,
|
||||
stablePollsPerStep: 1,
|
||||
jitterRatio: 0,
|
||||
},
|
||||
{ runImmediately: true },
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(poll).toHaveBeenCalledTimes(3);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(poll).toHaveBeenCalledTimes(4);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
computeAdaptiveResourceBudget,
|
||||
markAdaptiveResourceUsed,
|
||||
resetAdaptiveResourceState,
|
||||
runAdaptiveBackgroundTask,
|
||||
} from "../../lib/adaptive-resource-budget";
|
||||
import {
|
||||
clearLocalAdaptiveEngine,
|
||||
getLocalAdaptiveStats,
|
||||
} from "../../lib/local-adaptive-engine";
|
||||
|
||||
describe("adaptive resource budget", () => {
|
||||
beforeEach(() => {
|
||||
clearLocalAdaptiveEngine();
|
||||
resetAdaptiveResourceState();
|
||||
// jsdom reports the host's real core count, so the tier the runtime picks
|
||||
// would otherwise depend on the machine (CI runners have 4 cores).
|
||||
Object.defineProperty(navigator, "hardwareConcurrency", {
|
||||
value: 8,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses hard environmental constraints before optional work", () => {
|
||||
expect(computeAdaptiveResourceBudget({ visible: false }).tier).toBe(
|
||||
"paused",
|
||||
);
|
||||
expect(
|
||||
computeAdaptiveResourceBudget({ visible: true, saveData: true }).tier,
|
||||
).toBe("constrained");
|
||||
expect(
|
||||
computeAdaptiveResourceBudget({ visible: true, effectiveType: "3g" })
|
||||
.allowNetworkPrefetch,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("scales eager work down on limited devices", () => {
|
||||
const eager = computeAdaptiveResourceBudget({
|
||||
visible: true,
|
||||
deviceMemoryGb: 8,
|
||||
hardwareConcurrency: 8,
|
||||
});
|
||||
expect(eager).toMatchObject({
|
||||
tier: "eager",
|
||||
maxPrefetchBytes: 512 * 1024,
|
||||
maxConcurrentNetworkPrefetches: 2,
|
||||
});
|
||||
|
||||
const balanced = computeAdaptiveResourceBudget({
|
||||
visible: true,
|
||||
deviceMemoryGb: 4,
|
||||
hardwareConcurrency: 8,
|
||||
});
|
||||
expect(balanced).toMatchObject({
|
||||
tier: "balanced",
|
||||
maxPrefetchBytes: 128 * 1024,
|
||||
maxConcurrentNetworkPrefetches: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires enough poor local feedback before degrading", () => {
|
||||
const environment = { visible: true, deviceMemoryGb: 8 };
|
||||
expect(
|
||||
computeAdaptiveResourceBudget(environment, {
|
||||
observations: 3,
|
||||
successes: 0,
|
||||
cancellations: 0,
|
||||
fallbacks: 0,
|
||||
}).tier,
|
||||
).toBe("eager");
|
||||
expect(
|
||||
computeAdaptiveResourceBudget(environment, {
|
||||
observations: 4,
|
||||
successes: 2,
|
||||
cancellations: 0,
|
||||
fallbacks: 0,
|
||||
}),
|
||||
).toMatchObject({ tier: "balanced", reason: "feedback" });
|
||||
});
|
||||
|
||||
it("degrades only after enough speculative work goes unused", () => {
|
||||
const environment = { visible: true, deviceMemoryGb: 8 };
|
||||
expect(
|
||||
computeAdaptiveResourceBudget(environment, {
|
||||
observations: 4,
|
||||
successes: 4,
|
||||
cancellations: 0,
|
||||
fallbacks: 0,
|
||||
usefulnessObservations: 3,
|
||||
usefulPreloads: 0,
|
||||
}).tier,
|
||||
).toBe("eager");
|
||||
expect(
|
||||
computeAdaptiveResourceBudget(environment, {
|
||||
observations: 4,
|
||||
successes: 4,
|
||||
cancellations: 0,
|
||||
fallbacks: 0,
|
||||
usefulnessObservations: 4,
|
||||
usefulPreloads: 0,
|
||||
}),
|
||||
).toMatchObject({ tier: "balanced", reason: "feedback" });
|
||||
});
|
||||
|
||||
it("bounds concurrency and records aggregate task outcomes", async () => {
|
||||
let releaseFirst!: () => void;
|
||||
let releaseSecond!: () => void;
|
||||
const first = new Promise<void>((resolve) => (releaseFirst = resolve));
|
||||
const second = new Promise<void>((resolve) => (releaseSecond = resolve));
|
||||
|
||||
expect(
|
||||
runAdaptiveBackgroundTask("module", "tab:terminal", () => first),
|
||||
).toBe(true);
|
||||
expect(runAdaptiveBackgroundTask("module", "tab:files", () => second)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
runAdaptiveBackgroundTask("module", "tab:docker", async () => {}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
runAdaptiveBackgroundTask(
|
||||
"network",
|
||||
"file-content:large",
|
||||
async () => {},
|
||||
{
|
||||
estimatedBytes: 512 * 1024 + 1,
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
releaseFirst();
|
||||
releaseSecond();
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
getLocalAdaptiveStats("resource-budget:module")["tab:terminal"]
|
||||
.successes,
|
||||
).toBe(1);
|
||||
expect(
|
||||
getLocalAdaptiveStats("resource-budget:module")["tab:files"].successes,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
// A finished task must free its slot no later than it records its outcome.
|
||||
expect(
|
||||
runAdaptiveBackgroundTask("module", "tab:docker", async () => {}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("records whether foreground navigation used a preload", async () => {
|
||||
expect(
|
||||
runAdaptiveBackgroundTask("module", "tab:files", async () => {}),
|
||||
).toBe(true);
|
||||
markAdaptiveResourceUsed("module", "tab:files");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
getLocalAdaptiveStats("resource-usefulness:module")["tab:files"],
|
||||
).toMatchObject({ observations: 1, successes: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { copyToClipboard } from "../../lib/clipboard";
|
||||
import { copyToClipboard, readFromClipboard } from "../../lib/clipboard";
|
||||
|
||||
describe("copyToClipboard", () => {
|
||||
const originalClipboard = navigator.clipboard;
|
||||
@@ -84,3 +84,99 @@ describe("copyToClipboard", () => {
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFromClipboard", () => {
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
beforeEach(() => {
|
||||
delete (window as { electronClipboard?: unknown }).electronClipboard;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: originalClipboard,
|
||||
configurable: true,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setClipboard(value: unknown) {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
it("uses the electron bridge when present", async () => {
|
||||
const readText = vi.fn().mockResolvedValue("hello");
|
||||
(window as { electronClipboard?: unknown }).electronClipboard = {
|
||||
writeText: vi.fn(),
|
||||
readText,
|
||||
};
|
||||
|
||||
const text = await readFromClipboard();
|
||||
|
||||
expect(text).toBe("hello");
|
||||
expect(readText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses navigator.clipboard when available", async () => {
|
||||
const readText = vi.fn().mockResolvedValue("world");
|
||||
setClipboard({ readText });
|
||||
|
||||
const text = await readFromClipboard();
|
||||
|
||||
expect(text).toBe("world");
|
||||
expect(readText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to execCommand when navigator.clipboard.readText is undefined (non-HTTPS)", async () => {
|
||||
setClipboard({});
|
||||
const execCommand = vi.fn().mockImplementation((command: string) => {
|
||||
if (command === "paste") {
|
||||
const active = document.activeElement as HTMLTextAreaElement;
|
||||
active.value = "fallback";
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
(document as unknown as { execCommand: typeof execCommand }).execCommand =
|
||||
execCommand;
|
||||
|
||||
const text = await readFromClipboard();
|
||||
|
||||
expect(text).toBe("fallback");
|
||||
expect(execCommand).toHaveBeenCalledWith("paste");
|
||||
});
|
||||
|
||||
it("falls back to execCommand when the async read throws", async () => {
|
||||
setClipboard({
|
||||
readText: vi.fn().mockRejectedValue(new Error("denied")),
|
||||
});
|
||||
const execCommand = vi.fn().mockImplementation((command: string) => {
|
||||
if (command === "paste") {
|
||||
const active = document.activeElement as HTMLTextAreaElement;
|
||||
active.value = "retry";
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
(document as unknown as { execCommand: typeof execCommand }).execCommand =
|
||||
execCommand;
|
||||
|
||||
const text = await readFromClipboard();
|
||||
|
||||
expect(text).toBe("retry");
|
||||
expect(execCommand).toHaveBeenCalledWith("paste");
|
||||
});
|
||||
|
||||
it("returns an empty string when every approach fails", async () => {
|
||||
setClipboard({});
|
||||
(document as unknown as { execCommand: () => boolean }).execCommand = () =>
|
||||
false;
|
||||
|
||||
const text = await readFromClipboard();
|
||||
|
||||
expect(text).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fromTriState,
|
||||
parseRemoteDesktopDefaults,
|
||||
parseTerminalDefaults,
|
||||
resolveConnectionDefaults,
|
||||
toTriState,
|
||||
} from "../../lib/connection-defaults";
|
||||
|
||||
describe("connection defaults", () => {
|
||||
it("ignores malformed persisted defaults", () => {
|
||||
expect(parseTerminalDefaults("not-json")).toEqual({});
|
||||
expect(parseRemoteDesktopDefaults("[]")).toEqual({});
|
||||
});
|
||||
|
||||
it("lets host values override user defaults", () => {
|
||||
expect(
|
||||
resolveConnectionDefaults<{ fontSize: number; cursorBlink: boolean }>(
|
||||
{ fontSize: 14, cursorBlink: true },
|
||||
{ fontSize: 18 },
|
||||
),
|
||||
).toEqual({ fontSize: 18, cursorBlink: true });
|
||||
});
|
||||
|
||||
it("keeps unset booleans distinct from false", () => {
|
||||
expect(toTriState(undefined)).toBe("inherit");
|
||||
expect(toTriState(true)).toBe("on");
|
||||
expect(toTriState(false)).toBe("off");
|
||||
|
||||
expect(fromTriState("inherit")).toBeUndefined();
|
||||
expect(fromTriState("on")).toBe(true);
|
||||
expect(fromTriState("off")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { resolveConnectionOrigin } from "../../lib/connection-origin.js";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
buildOriginWsUrl,
|
||||
resolveConnectionOrigin,
|
||||
} from "../../lib/connection-origin.js";
|
||||
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
|
||||
@@ -86,3 +89,65 @@ describe("resolveConnectionOrigin", () => {
|
||||
).resolves.toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The embedded backend authenticates a local WebSocket from `?token=`, because
|
||||
* the browser WebSocket API cannot set an Authorization header. Electron's main
|
||||
* process does inject a JWT cookie, but only on an exact origin match, and the
|
||||
* remembered cookie belongs to the API origin (`localhost:30001`) — so nothing
|
||||
* is attached to a `127.0.0.1:30009` connection.
|
||||
*
|
||||
* The Docker console opted out of the query token and had no other credential
|
||||
* left, so its handshake was closed with 1008 while logs and stats kept working.
|
||||
*/
|
||||
describe("buildOriginWsUrl", () => {
|
||||
const store: Record<string, string> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
store.jwt = "local-jwt";
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("carries the local JWT by default", async () => {
|
||||
// Every interactive channel on the embedded backend relies on this.
|
||||
const url = await buildOriginWsUrl({
|
||||
origin: "local",
|
||||
localPort: 30009,
|
||||
localPath: "/docker/console/",
|
||||
remotePath: "/docker/console/",
|
||||
});
|
||||
|
||||
expect(url).toBe("ws://127.0.0.1:30009/docker/console/?token=local-jwt");
|
||||
});
|
||||
|
||||
it("omits it only when a caller asks", async () => {
|
||||
const url = await buildOriginWsUrl({
|
||||
origin: "local",
|
||||
localPort: 30009,
|
||||
localPath: "/docker/console/",
|
||||
remotePath: "/docker/console/",
|
||||
includeLocalJwt: false,
|
||||
});
|
||||
|
||||
expect(url).toBe("ws://127.0.0.1:30009/docker/console/");
|
||||
});
|
||||
|
||||
it("leaves the URL alone when there is no token stored", async () => {
|
||||
delete store.jwt;
|
||||
|
||||
const url = await buildOriginWsUrl({
|
||||
origin: "local",
|
||||
localPort: 30002,
|
||||
localPath: "",
|
||||
remotePath: "/ssh/websocket/",
|
||||
});
|
||||
|
||||
expect(url).toBe("ws://127.0.0.1:30002");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getCachedFileContent,
|
||||
invalidateCachedFileContent,
|
||||
shouldPrefetchFileContent,
|
||||
} from "../../lib/file-content-request-cache";
|
||||
|
||||
describe("file content request cache", () => {
|
||||
it("coalesces reads and reloads after invalidation", async () => {
|
||||
const loader = vi.fn(async () => ({ content: "hello", path: "/note" }));
|
||||
|
||||
await Promise.all([
|
||||
getCachedFileContent("session", "/note", loader),
|
||||
getCachedFileContent("session", "/note", loader),
|
||||
]);
|
||||
expect(loader).toHaveBeenCalledOnce();
|
||||
|
||||
invalidateCachedFileContent("session", "/note");
|
||||
await getCachedFileContent("session", "/note", loader);
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("only prefetches known small files on an unconstrained network", () => {
|
||||
expect(
|
||||
shouldPrefetchFileContent(
|
||||
{ name: "note", path: "/note", type: "file", size: 512 * 1024 },
|
||||
512 * 1024,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldPrefetchFileContent(
|
||||
{ name: "large", path: "/large", type: "file", size: 512 * 1024 + 1 },
|
||||
512 * 1024,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPrefetchFileContent(
|
||||
{ name: "unknown", path: "/unknown", type: "file" },
|
||||
512 * 1024,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPrefetchFileContent(
|
||||
{ name: "note", path: "/note", type: "file", size: 100 },
|
||||
0,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -103,4 +103,33 @@ describe("dispatchKeybindingAction", () => {
|
||||
await Promise.resolve();
|
||||
expect(ctx.sentData).toEqual([]);
|
||||
});
|
||||
|
||||
it("runSnippet silently resolves host variables from hostContext", async () => {
|
||||
const ctx = makeContext({
|
||||
getSnippetById: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: "ping $HOST -p $PORT" }),
|
||||
hostContext: { ip: "10.0.0.5", username: "root", port: 22 },
|
||||
});
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(ctx.sentData).toEqual(["ping 10.0.0.5 -p 22\r"]);
|
||||
});
|
||||
|
||||
it("runSnippet defers to onSnippetNeedsInputs instead of sending when $INPUT_n is present", async () => {
|
||||
const onSnippetNeedsInputs = vi.fn();
|
||||
const ctx = makeContext({
|
||||
getSnippetById: vi.fn().mockResolvedValue({ content: "echo $INPUT_1" }),
|
||||
onSnippetNeedsInputs,
|
||||
});
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(ctx.sentData).toEqual([]);
|
||||
expect(onSnippetNeedsInputs).toHaveBeenCalledWith({
|
||||
id: "1",
|
||||
content: "echo $INPUT_1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
eventMatchesCombo,
|
||||
findMatchingKeybinding,
|
||||
} from "../../lib/keybinding-match";
|
||||
import type { CustomKeybinding, KeyCombo } from "../../types/keybindings";
|
||||
import type { CustomKeybinding, KeyCombo } from "../../../types/keybindings";
|
||||
|
||||
function makeEvent(
|
||||
init: Partial<KeyboardEventInit> & { key: string; code?: string },
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createKeyedRequestCache } from "../../lib/keyed-request-cache";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("createKeyedRequestCache", () => {
|
||||
it("coalesces requests per key without mixing keys", async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
const loader = vi.fn(() => new Promise<string>((done) => (resolve = done)));
|
||||
const cache = createKeyedRequestCache<string>(5_000);
|
||||
|
||||
const first = cache.get("host:1", loader);
|
||||
const duplicate = cache.get("host:1", loader);
|
||||
const other = cache.get("host:2", async () => "other");
|
||||
resolve("shared");
|
||||
|
||||
await expect(Promise.all([first, duplicate, other])).resolves.toEqual([
|
||||
"shared",
|
||||
"shared",
|
||||
"other",
|
||||
]);
|
||||
expect(loader).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("can show stale data immediately while forcing one refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = createKeyedRequestCache<string>(1_000);
|
||||
await cache.get("directory", async () => "old");
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
expect(cache.peek("directory", 30_000)).toBe("old");
|
||||
|
||||
const loader = vi.fn(async () => "fresh");
|
||||
const [a, b] = await Promise.all([
|
||||
cache.get("directory", loader, { force: true }),
|
||||
cache.get("directory", loader, { force: true }),
|
||||
]);
|
||||
expect([a, b]).toEqual(["fresh", "fresh"]);
|
||||
expect(loader).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("evicts the least recently used entry when bounded", async () => {
|
||||
const cache = createKeyedRequestCache<number>(10_000, 2);
|
||||
await cache.get("a", async () => 1);
|
||||
await cache.get("b", async () => 2);
|
||||
expect(cache.peek("a")).toBe(1);
|
||||
await cache.get("c", async () => 3);
|
||||
|
||||
expect(cache.peek("a")).toBe(1);
|
||||
expect(cache.peek("b")).toBeNull();
|
||||
expect(cache.peek("c")).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps stale data when a refresh fails", async () => {
|
||||
const cache = createKeyedRequestCache<string>(1_000);
|
||||
await cache.get("key", async () => "safe");
|
||||
|
||||
await expect(
|
||||
cache.get(
|
||||
"key",
|
||||
async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
{ force: true },
|
||||
),
|
||||
).rejects.toThrow("offline");
|
||||
expect(cache.peek("key")).toBe("safe");
|
||||
});
|
||||
|
||||
it("does not let an invalidated request overwrite newer data", async () => {
|
||||
let resolveOld!: (value: string) => void;
|
||||
const cache = createKeyedRequestCache<string>(10_000);
|
||||
const oldRequest = cache.get(
|
||||
"key",
|
||||
() => new Promise<string>((resolve) => (resolveOld = resolve)),
|
||||
);
|
||||
|
||||
cache.set("key", "optimistic");
|
||||
resolveOld("old");
|
||||
await oldRequest;
|
||||
|
||||
expect(cache.peek("key")).toBe("optimistic");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
chooseLocalAdaptiveAction,
|
||||
clearLocalAdaptiveEngine,
|
||||
getLocalAdaptiveStats,
|
||||
LOCAL_ADAPTIVE_ENGINE_KEY,
|
||||
recordLocalAdaptiveChoice,
|
||||
recordLocalAdaptiveOutcome,
|
||||
} from "../../lib/local-adaptive-engine";
|
||||
|
||||
describe("local adaptive engine", () => {
|
||||
beforeEach(clearLocalAdaptiveEngine);
|
||||
|
||||
it("returns an explainable fallback until evidence is confident", () => {
|
||||
recordLocalAdaptiveChoice("surface", "files", 1_000);
|
||||
recordLocalAdaptiveChoice("surface", "files", 1_001);
|
||||
|
||||
expect(
|
||||
chooseLocalAdaptiveAction({
|
||||
scope: "surface",
|
||||
candidates: ["terminal", "files"],
|
||||
fallback: "terminal",
|
||||
now: 1_002,
|
||||
ttlMs: 100,
|
||||
}),
|
||||
).toEqual({
|
||||
action: "terminal",
|
||||
confidence: expect.any(Number),
|
||||
reason: "insufficient-evidence",
|
||||
expiresAt: 1_102,
|
||||
});
|
||||
|
||||
recordLocalAdaptiveChoice("surface", "files", 1_003);
|
||||
expect(
|
||||
chooseLocalAdaptiveAction({
|
||||
scope: "surface",
|
||||
candidates: ["terminal", "files"],
|
||||
fallback: "terminal",
|
||||
now: 1_004,
|
||||
}),
|
||||
).toMatchObject({ action: "files", confidence: 1, reason: "learned" });
|
||||
});
|
||||
|
||||
it("aggregates outcomes and latency with an EWMA", () => {
|
||||
recordLocalAdaptiveOutcome(
|
||||
"transfer:large",
|
||||
"direct",
|
||||
{ success: true, latencyMs: 100 },
|
||||
1_000,
|
||||
);
|
||||
recordLocalAdaptiveOutcome(
|
||||
"transfer:large",
|
||||
"direct",
|
||||
{ success: false, fallback: true, latencyMs: 200 },
|
||||
1_001,
|
||||
);
|
||||
|
||||
expect(getLocalAdaptiveStats("transfer:large").direct).toMatchObject({
|
||||
weight: 0,
|
||||
observations: 2,
|
||||
successes: 1,
|
||||
cancellations: 0,
|
||||
fallbacks: 1,
|
||||
latencyEwmaMs: 125,
|
||||
});
|
||||
});
|
||||
|
||||
it("decays old choices and excludes unavailable candidates", () => {
|
||||
const month = 30 * 24 * 60 * 60 * 1000;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
recordLocalAdaptiveChoice("surface", "docker", i);
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
recordLocalAdaptiveChoice("surface", "files", month + i);
|
||||
}
|
||||
|
||||
expect(
|
||||
chooseLocalAdaptiveAction({
|
||||
scope: "surface",
|
||||
candidates: ["terminal", "files"],
|
||||
fallback: "terminal",
|
||||
now: month + 10,
|
||||
}).action,
|
||||
).toBe("files");
|
||||
});
|
||||
|
||||
it("bounds local storage and recovers from malformed data", () => {
|
||||
for (let i = 0; i < 140; i++) {
|
||||
recordLocalAdaptiveChoice(`scope:${i}`, "action", i);
|
||||
}
|
||||
const stored = JSON.parse(
|
||||
localStorage.getItem(LOCAL_ADAPTIVE_ENGINE_KEY) ?? "{}",
|
||||
);
|
||||
expect(Object.keys(stored.scopes)).toHaveLength(128);
|
||||
|
||||
localStorage.setItem(LOCAL_ADAPTIVE_ENGINE_KEY, "broken");
|
||||
expect(
|
||||
chooseLocalAdaptiveAction({
|
||||
scope: "surface",
|
||||
candidates: ["terminal", "files"],
|
||||
fallback: "terminal",
|
||||
}),
|
||||
).toMatchObject({ action: "terminal", confidence: 0, reason: "fallback" });
|
||||
});
|
||||
|
||||
it("keeps recent outcome-only actions when a scope reaches its limit", () => {
|
||||
for (let i = 0; i < 13; i++) {
|
||||
recordLocalAdaptiveOutcome(
|
||||
"resource-budget:module",
|
||||
`module:${i}`,
|
||||
{ success: true },
|
||||
i,
|
||||
);
|
||||
}
|
||||
|
||||
const stats = getLocalAdaptiveStats("resource-budget:module");
|
||||
expect(Object.keys(stats)).toHaveLength(12);
|
||||
expect(stats["module:0"]).toBeUndefined();
|
||||
expect(stats["module:12"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearLocalAdaptivePreferences,
|
||||
getLocalPreference,
|
||||
LOCAL_ADAPTIVE_PREFERENCES_KEY,
|
||||
recordLocalPreference,
|
||||
} from "../../lib/local-adaptive-preferences";
|
||||
|
||||
describe("local adaptive preferences", () => {
|
||||
beforeEach(() => localStorage.removeItem(LOCAL_ADAPTIVE_PREFERENCES_KEY));
|
||||
|
||||
it("keeps the fallback until local evidence is strong enough", () => {
|
||||
recordLocalPreference("host-action:1", "files", 1_000);
|
||||
recordLocalPreference("host-action:1", "files", 1_001);
|
||||
|
||||
expect(
|
||||
getLocalPreference(
|
||||
"host-action:1",
|
||||
["terminal", "files"],
|
||||
"terminal",
|
||||
1_002,
|
||||
),
|
||||
).toBe("terminal");
|
||||
|
||||
recordLocalPreference("host-action:1", "files", 1_003);
|
||||
expect(
|
||||
getLocalPreference(
|
||||
"host-action:1",
|
||||
["terminal", "files"],
|
||||
"terminal",
|
||||
1_004,
|
||||
),
|
||||
).toBe("files");
|
||||
});
|
||||
|
||||
it("uses decayed local counts and ignores unavailable actions", () => {
|
||||
const month = 30 * 24 * 60 * 60 * 1000;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
recordLocalPreference("host-action:1", "docker", i);
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
recordLocalPreference("host-action:1", "files", month + i);
|
||||
}
|
||||
|
||||
expect(
|
||||
getLocalPreference(
|
||||
"host-action:1",
|
||||
["terminal", "files"],
|
||||
"terminal",
|
||||
month + 10,
|
||||
),
|
||||
).toBe("files");
|
||||
});
|
||||
|
||||
it("persists only aggregate local action statistics", () => {
|
||||
recordLocalPreference("host-action:42", "terminal", 10);
|
||||
|
||||
const stored = JSON.parse(
|
||||
localStorage.getItem(LOCAL_ADAPTIVE_PREFERENCES_KEY) ?? "{}",
|
||||
);
|
||||
expect(stored).toEqual({
|
||||
version: 1,
|
||||
scopes: {
|
||||
"host-action:42": {
|
||||
actions: {
|
||||
terminal: {
|
||||
weight: 1,
|
||||
updatedAt: 10,
|
||||
observations: 0,
|
||||
successes: 0,
|
||||
cancellations: 0,
|
||||
fallbacks: 0,
|
||||
},
|
||||
},
|
||||
updatedAt: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers from malformed local data and can be reset", () => {
|
||||
localStorage.setItem(
|
||||
LOCAL_ADAPTIVE_PREFERENCES_KEY,
|
||||
JSON.stringify({ version: 1, scopes: { broken: "raw input" } }),
|
||||
);
|
||||
|
||||
expect(
|
||||
getLocalPreference("broken", ["terminal", "files"], "terminal", 10),
|
||||
).toBe("terminal");
|
||||
recordLocalPreference("host-action:1", "files", 11);
|
||||
clearLocalAdaptivePreferences();
|
||||
expect(localStorage.getItem(LOCAL_ADAPTIVE_PREFERENCES_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn());
|
||||
const remoteApi = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
vi.mock("@/main-axios", () => ({ getRemoteStatsApi: () => remoteApi }));
|
||||
|
||||
import {
|
||||
getConnectedRemoteApi,
|
||||
markRemoteSharedHosts,
|
||||
resolveRemoteHostId,
|
||||
} from "@/lib/remote-server-api";
|
||||
|
||||
describe("remote server API", () => {
|
||||
const invoke = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
isElectron.mockReset();
|
||||
remoteApi.get.mockReset();
|
||||
invoke.mockReset();
|
||||
Object.defineProperty(window, "electronAPI", {
|
||||
configurable: true,
|
||||
value: { invoke },
|
||||
});
|
||||
});
|
||||
|
||||
it("is available only for a configured Electron sync server", async () => {
|
||||
isElectron.mockReturnValue(false);
|
||||
await expect(getConnectedRemoteApi()).resolves.toBeNull();
|
||||
|
||||
isElectron.mockReturnValue(true);
|
||||
invoke.mockResolvedValueOnce({ serverUrl: "https://termix.example" });
|
||||
await expect(getConnectedRemoteApi()).resolves.toBe(remoteApi);
|
||||
});
|
||||
|
||||
it("maps a local host to the remote numeric id by syncId", async () => {
|
||||
isElectron.mockReturnValue(true);
|
||||
invoke.mockResolvedValue({ serverUrl: "https://termix.example" });
|
||||
remoteApi.get.mockResolvedValue({
|
||||
data: { rows: [{ id: 41, syncId: "host-a" }] },
|
||||
});
|
||||
|
||||
await expect(resolveRemoteHostId("host-a")).resolves.toBe(41);
|
||||
await expect(resolveRemoteHostId("missing")).resolves.toBeNull();
|
||||
expect(remoteApi.get).toHaveBeenCalledWith("/sync/hosts");
|
||||
});
|
||||
|
||||
it("keeps only shared remote hosts and gives them collision-free ids", () => {
|
||||
const rows = markRemoteSharedHosts([
|
||||
{ id: 4, isShared: false },
|
||||
{ id: 9, isShared: true, syncId: "shared-host" },
|
||||
] as SSHHost[]);
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
id: -9,
|
||||
syncId: "shared-host",
|
||||
connectionOrigin: "remote",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
|
||||
import { requestRemoteSync } from "@/lib/remote-sync-trigger";
|
||||
|
||||
describe("requestRemoteSync", () => {
|
||||
const invoke = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
isElectron.mockReset();
|
||||
invoke.mockReset();
|
||||
Object.defineProperty(window, "electronAPI", {
|
||||
configurable: true,
|
||||
value: { invoke },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a sync when the desktop is connected to a server", async () => {
|
||||
isElectron.mockReturnValue(true);
|
||||
invoke.mockResolvedValueOnce({ serverUrl: "https://termix.example" });
|
||||
|
||||
await requestRemoteSync();
|
||||
|
||||
expect(invoke).toHaveBeenNthCalledWith(1, "get-remote-sync-config");
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "remote-sync-now");
|
||||
});
|
||||
|
||||
it("does nothing outside Electron or without a configured server", async () => {
|
||||
isElectron.mockReturnValue(false);
|
||||
await requestRemoteSync();
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
|
||||
isElectron.mockReturnValue(true);
|
||||
invoke.mockResolvedValueOnce(null);
|
||||
await requestRemoteSync();
|
||||
expect(invoke).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,15 @@ describe("ServerStatusStore", () => {
|
||||
expect(store.getStatus(5)).toBe("online");
|
||||
});
|
||||
|
||||
it("preserves reachable as distinct from authenticated online", () => {
|
||||
const store = new ServerStatusStore();
|
||||
store.setEnabledHostIds(new Set([7]));
|
||||
store.applyStatuses(
|
||||
new Map([[7, { status: "reachable", lastChecked: "t" }]]),
|
||||
);
|
||||
expect(store.getStatus(7)).toBe("reachable");
|
||||
});
|
||||
|
||||
it("emits meta listeners when initial load completes", () => {
|
||||
const store = new ServerStatusStore();
|
||||
const onMeta = vi.fn();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractSnippetInputs,
|
||||
hasSnippetInputs,
|
||||
resolveSnippetContent,
|
||||
} from "../../lib/snippet-variables";
|
||||
|
||||
describe("extractSnippetInputs", () => {
|
||||
it("extracts a single unlabeled input", () => {
|
||||
expect(extractSnippetInputs("ping $INPUT_1")).toEqual([
|
||||
{ key: "INPUT_1", label: "Input 1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts a labeled input using brace syntax", () => {
|
||||
expect(
|
||||
extractSnippetInputs("nc -zv $HOST ${INPUT_1:Port to check}"),
|
||||
).toEqual([{ key: "INPUT_1", label: "Port to check" }]);
|
||||
});
|
||||
|
||||
it("plain (non-braced) references have no label", () => {
|
||||
expect(extractSnippetInputs("echo $INPUT_2")).toEqual([
|
||||
{ key: "INPUT_2", label: "Input 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("de-duplicates repeated references and preserves first-seen order", () => {
|
||||
expect(
|
||||
extractSnippetInputs("$INPUT_2 then $INPUT_1 then $INPUT_2 again"),
|
||||
).toEqual([
|
||||
{ key: "INPUT_2", label: "Input 2" },
|
||||
{ key: "INPUT_1", label: "Input 1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array when there are no placeholders", () => {
|
||||
expect(extractSnippetInputs("uptime")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSnippetInputs", () => {
|
||||
it("returns true when a snippet has an input placeholder", () => {
|
||||
expect(hasSnippetInputs("echo $INPUT_1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when a snippet has no input placeholder", () => {
|
||||
expect(hasSnippetInputs("echo $HOST")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSnippetContent", () => {
|
||||
const host = { ip: "10.0.0.5", username: "root", port: 22, name: "web-01" };
|
||||
|
||||
it("substitutes host variables", () => {
|
||||
expect(
|
||||
resolveSnippetContent("ssh $USER@$HOST -p $PORT # $NAME", host),
|
||||
).toBe("ssh root@10.0.0.5 -p 22 # web-01");
|
||||
});
|
||||
|
||||
it("supports brace syntax for host variables", () => {
|
||||
expect(resolveSnippetContent("ping ${HOST}", host)).toBe("ping 10.0.0.5");
|
||||
});
|
||||
|
||||
it("substitutes input placeholders", () => {
|
||||
expect(
|
||||
resolveSnippetContent("nc -zv $HOST ${INPUT_1:Port}", host, {
|
||||
INPUT_1: "8080",
|
||||
}),
|
||||
).toBe("nc -zv 10.0.0.5 8080");
|
||||
});
|
||||
|
||||
it("leaves host variables literal when no host context is given", () => {
|
||||
expect(resolveSnippetContent("ping $HOST", null)).toBe("ping $HOST");
|
||||
});
|
||||
|
||||
it("leaves input placeholders literal when no value was supplied", () => {
|
||||
expect(resolveSnippetContent("echo $INPUT_1", null)).toBe("echo $INPUT_1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
parseTerminalMacros,
|
||||
runTerminalMacro,
|
||||
type MacroStep,
|
||||
type TerminalMacro,
|
||||
} from "@/lib/terminal-macros";
|
||||
|
||||
function macro(steps: MacroStep[]): TerminalMacro {
|
||||
return {
|
||||
id: "macro-1",
|
||||
name: "Test",
|
||||
steps,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("terminal macro runner", () => {
|
||||
it("runs send, delay, regex wait, condition, and repeat steps", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sent: string[] = [];
|
||||
let listener = (_data: string) => {};
|
||||
const run = runTerminalMacro(
|
||||
macro([
|
||||
{ id: "send", type: "send", text: "status", pressEnter: true },
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
pattern: "READY\\s+42",
|
||||
timeoutMs: 1000,
|
||||
onTimeout: "stop",
|
||||
},
|
||||
{
|
||||
id: "if",
|
||||
type: "if",
|
||||
pattern: "READY",
|
||||
then: [
|
||||
{
|
||||
id: "repeat",
|
||||
type: "repeat",
|
||||
count: 2,
|
||||
steps: [
|
||||
{ id: "ok", type: "send", text: "ok", pressEnter: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
else: [],
|
||||
},
|
||||
]),
|
||||
{
|
||||
send: (data) => sent.push(data),
|
||||
subscribe: (next) => {
|
||||
listener = next;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
listener("READY 42");
|
||||
await run;
|
||||
expect(sent).toEqual(["status\r", "ok", "ok"]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stops when a required regex times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const run = runTerminalMacro(
|
||||
macro([
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
pattern: "never",
|
||||
timeoutMs: 100,
|
||||
onTimeout: "stop",
|
||||
},
|
||||
]),
|
||||
{ send: () => {}, subscribe: () => () => {} },
|
||||
);
|
||||
const rejection = expect(run).rejects.toThrow("Timed out");
|
||||
await vi.advanceTimersByTimeAsync(101);
|
||||
await rejection;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sanitizes persisted macro bounds and drops unknown steps", () => {
|
||||
const parsed = parseTerminalMacros(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "m",
|
||||
name: "Macro",
|
||||
steps: [
|
||||
{ id: "d", type: "delay", milliseconds: 999_999 },
|
||||
{ id: "r", type: "repeat", count: 1000, steps: [] },
|
||||
{ id: "bad", type: "execute-javascript", code: "nope" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(parsed[0].steps).toEqual([
|
||||
{ id: "d", type: "delay", milliseconds: 300_000 },
|
||||
{ id: "r", type: "repeat", count: 100, steps: [] },
|
||||
]);
|
||||
});
|
||||
it("matches literal text without regex escaping when isRegex is false", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sent: string[] = [];
|
||||
let listener = (_data: string) => {};
|
||||
const run = runTerminalMacro(
|
||||
macro([
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
pattern: "progress (1/2)",
|
||||
isRegex: false,
|
||||
timeoutMs: 1000,
|
||||
onTimeout: "stop",
|
||||
},
|
||||
{ id: "send", type: "send", text: "done", pressEnter: false },
|
||||
]),
|
||||
{
|
||||
send: (data) => sent.push(data),
|
||||
subscribe: (next) => {
|
||||
listener = next;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
listener("progress (1/2)");
|
||||
await run;
|
||||
expect(sent).toEqual(["done"]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("falls back to literal matching when a regex is invalid", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sent: string[] = [];
|
||||
let listener = (_data: string) => {};
|
||||
const run = runTerminalMacro(
|
||||
macro([
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
pattern: "oops(",
|
||||
isRegex: true,
|
||||
timeoutMs: 1000,
|
||||
onTimeout: "stop",
|
||||
},
|
||||
{ id: "send", type: "send", text: "ok", pressEnter: false },
|
||||
]),
|
||||
{
|
||||
send: (data) => sent.push(data),
|
||||
subscribe: (next) => {
|
||||
listener = next;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
listener("oops(");
|
||||
await run;
|
||||
expect(sent).toEqual(["ok"]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("branches on literal if-step text", async () => {
|
||||
const sent: string[] = [];
|
||||
await runTerminalMacro(
|
||||
macro([
|
||||
{
|
||||
id: "if",
|
||||
type: "if",
|
||||
pattern: "a.b",
|
||||
isRegex: false,
|
||||
then: [{ id: "y", type: "send", text: "yes", pressEnter: false }],
|
||||
else: [{ id: "n", type: "send", text: "no", pressEnter: false }],
|
||||
},
|
||||
]),
|
||||
{ send: (data) => sent.push(data), subscribe: () => () => {} },
|
||||
);
|
||||
// "a.b" as a literal must not match "axb"
|
||||
expect(sent).toEqual(["no"]);
|
||||
});
|
||||
});
|
||||
@@ -419,4 +419,23 @@ describe("updateControlStringMode", () => {
|
||||
const state = updateControlStringMode(continuation, true);
|
||||
expect(state.wasActive).toBe(true);
|
||||
});
|
||||
|
||||
it("passes through SGR mouse-tracking reports untouched (issue #1023)", () => {
|
||||
// ESC[<Cb;Cx;CyM is the SGR (1006) mouse-report format. The `<` marker
|
||||
// byte was missing from the parameter-byte character class, so these
|
||||
// sequences weren't recognised as ANSI escapes and their raw digits
|
||||
// leaked onto the screen as visible text inside `screen`/tmux sessions.
|
||||
const report = `${ESC}[<35;191;1M`;
|
||||
expect(highlightTerminalOutput(report)).toBe(report);
|
||||
});
|
||||
|
||||
it("passes through a burst of SGR mouse reports untouched", () => {
|
||||
const burst = `${ESC}[<35;191;1M${ESC}[<35;186;2M${ESC}[<35;46;21M`;
|
||||
expect(highlightTerminalOutput(burst)).toBe(burst);
|
||||
});
|
||||
|
||||
it("treats a chunk ending mid SGR-mouse-report as incomplete", () => {
|
||||
const partial = `foo${ESC}[<35;191`;
|
||||
expect(highlightTerminalOutput(partial)).toBe(partial);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeReconnectDelay } from "../../lib/useConnectionRetry.ts";
|
||||
|
||||
describe("connection retry delay", () => {
|
||||
it("uses exponential backoff with bounded jitter", () => {
|
||||
expect(computeReconnectDelay(1, 2000, 8000, () => 0.5)).toBe(2000);
|
||||
expect(computeReconnectDelay(2, 2000, 8000, () => 0)).toBe(3600);
|
||||
expect(computeReconnectDelay(2, 2000, 8000, () => 1)).toBe(4400);
|
||||
});
|
||||
|
||||
it("never exceeds the configured cap", () => {
|
||||
expect(computeReconnectDelay(8, 2000, 8000, () => 1)).toBe(8000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ONBOARDING_STEPS, relevantSteps } from "@/onboarding/onboarding-steps";
|
||||
import { UI_AREA_KEYS, PRESETS } from "@/types/ui-preferences";
|
||||
import en from "@/locales/en.json";
|
||||
|
||||
function lookup(key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(acc, part) =>
|
||||
acc && typeof acc === "object"
|
||||
? (acc as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
en,
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
expect(typeof lookup(step.titleKey), step.titleKey).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
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("has no add-a-host step, which used to close onboarding mid-flow", () => {
|
||||
const ids = relevantSteps({ aiGloballyEnabled: false }).map((s) => s.id);
|
||||
expect(ids).not.toContain("first-host");
|
||||
});
|
||||
|
||||
it("ends on the done step", () => {
|
||||
const ids = relevantSteps({ aiGloballyEnabled: false }).map((s) => s.id);
|
||||
expect(ids[ids.length - 1]).toBe("done");
|
||||
});
|
||||
|
||||
it("only offers the AI step when an admin has enabled the assistant", () => {
|
||||
// A user on an instance with AI switched off should never be told the
|
||||
// feature exists, so the step has to disappear rather than render disabled.
|
||||
const off = relevantSteps({ aiGloballyEnabled: false }).map((s) => s.id);
|
||||
const on = relevantSteps({ aiGloballyEnabled: true }).map((s) => s.id);
|
||||
expect(off).not.toContain("ai");
|
||||
expect(on).toContain("ai");
|
||||
});
|
||||
|
||||
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"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboarding step body translations", () => {
|
||||
const KEYS: Record<string, string[]> = {
|
||||
welcome: ["hosts", "terminal", "files"],
|
||||
feature: [
|
||||
"files",
|
||||
"desktop",
|
||||
"tunnels",
|
||||
"docker",
|
||||
"snippets",
|
||||
"automations",
|
||||
"metrics",
|
||||
],
|
||||
workflow: ["palette", "split", "dock", "workspaces"],
|
||||
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", () => {
|
||||
for (const key of [
|
||||
"welcomeIntro",
|
||||
"presetIntro",
|
||||
"appearanceIntro",
|
||||
"featuresIntro",
|
||||
"workflowIntro",
|
||||
"securityIntro",
|
||||
"aiIntro",
|
||||
"aiEnableTitle",
|
||||
"aiEnableDesc",
|
||||
"aiSkipTitle",
|
||||
"aiSkipDesc",
|
||||
"aiNote",
|
||||
"doneDesc",
|
||||
]) {
|
||||
expect(lookup(`onboarding.${key}`), key).toBeTypeOf("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("interface settings translations", () => {
|
||||
it("has a label for every preset the picker offers", () => {
|
||||
for (const preset of ["simple", "balanced", "advanced"]) {
|
||||
expect(
|
||||
lookup(`newUi.sidebar.userProfile.preset_${preset}`),
|
||||
preset,
|
||||
).toBeTypeOf("string");
|
||||
expect(
|
||||
lookup(`newUi.sidebar.userProfile.preset_${preset}_desc`),
|
||||
preset,
|
||||
).toBeTypeOf("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("has a label for every UI area the override list can show", () => {
|
||||
for (const area of UI_AREA_KEYS) {
|
||||
expect(
|
||||
lookup(`newUi.sidebar.userProfile.uiArea_${area}`),
|
||||
area,
|
||||
).toBeTypeOf("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("covers every area key in each preset", () => {
|
||||
for (const preset of ["simple", "balanced", "advanced"] as const) {
|
||||
expect(Object.keys(PRESETS[preset]).sort()).toEqual(
|
||||
[...UI_AREA_KEYS].sort(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts ? `${key}:${JSON.stringify(opts)}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { SplitView, defaultSizes } from "../../shell/SplitView";
|
||||
|
||||
function makeTab(overrides: Partial<Tab> = {}): Tab {
|
||||
return {
|
||||
id: "tab-1",
|
||||
instanceId: "instance-1",
|
||||
type: "terminal",
|
||||
label: "web-01",
|
||||
openedAt: 0,
|
||||
...overrides,
|
||||
} as Tab;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("defaultSizes", () => {
|
||||
it("returns the expected default split for 2-way", () => {
|
||||
expect(defaultSizes("2-way")).toEqual({
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[50, 50]],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns stacked defaults for a horizontal 2-way layout", () => {
|
||||
expect(defaultSizes("2-way-horizontal")).toEqual({
|
||||
rowSizes: [50, 50],
|
||||
rowColSizes: [[100], [100]],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a single full pane for 'none'", () => {
|
||||
expect(defaultSizes("none")).toEqual({
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[100]],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SplitView - controlled rowSizes/rowColSizes", () => {
|
||||
it("renders panes using externally-supplied non-default sizes, not the built-in default", () => {
|
||||
const tabs = [makeTab({ id: "t1" }), makeTab({ id: "t2", label: "t2" })];
|
||||
|
||||
const { container } = render(
|
||||
<SplitView
|
||||
tabs={tabs}
|
||||
paneTabIds={["t1", "t2", null, null, null, null]}
|
||||
splitMode="2-way"
|
||||
rowSizes={[100]}
|
||||
rowColSizes={[[30, 70]]}
|
||||
onRowSizesChange={() => {}}
|
||||
onRowColSizesChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The first pane's flex column width should reflect the supplied 30%,
|
||||
// not the 50/50 default - this is the regression guard for the
|
||||
// rowSizes/rowColSizes lift-up out of SplitView's old internal state.
|
||||
const widthStyled = container.querySelectorAll('[style*="width: 30%"]');
|
||||
expect(widthStyled.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("calls onReset (not an internal reset) when the Reset button is clicked", () => {
|
||||
const onReset = vi.fn();
|
||||
const tabs = [makeTab({ id: "t1" })];
|
||||
|
||||
render(
|
||||
<SplitView
|
||||
tabs={tabs}
|
||||
paneTabIds={["t1", null, null, null, null, null]}
|
||||
splitMode="2-way"
|
||||
rowSizes={[100]}
|
||||
rowColSizes={[[30, 70]]}
|
||||
onRowSizesChange={() => {}}
|
||||
onRowColSizesChange={() => {}}
|
||||
onReset={onReset}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle("Reset to equal split"));
|
||||
expect(onReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("releases a session from its pane from the pane header", () => {
|
||||
const onAssignPane = vi.fn();
|
||||
render(
|
||||
<SplitView
|
||||
tabs={[makeTab({ id: "t1" })]}
|
||||
paneTabIds={["t1", null, null, null, null, null]}
|
||||
splitMode="2-way"
|
||||
rowSizes={[100]}
|
||||
rowColSizes={[[50, 50]]}
|
||||
onRowSizesChange={() => {}}
|
||||
onRowColSizesChange={() => {}}
|
||||
onAssignPane={onAssignPane}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("terminal.split.removeFromSplit"));
|
||||
expect(onAssignPane).toHaveBeenCalledWith(0, "");
|
||||
});
|
||||
|
||||
it("does not mutate rowSizes/rowColSizes props locally - reflects prop changes on rerender", () => {
|
||||
const tabs = [makeTab({ id: "t1" }), makeTab({ id: "t2", label: "t2" })];
|
||||
|
||||
const { container, rerender } = render(
|
||||
<SplitView
|
||||
tabs={tabs}
|
||||
paneTabIds={["t1", "t2", null, null, null, null]}
|
||||
splitMode="2-way"
|
||||
rowSizes={[100]}
|
||||
rowColSizes={[[50, 50]]}
|
||||
onRowSizesChange={() => {}}
|
||||
onRowColSizesChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelectorAll('[style*="width: 50%"]').length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
rerender(
|
||||
<SplitView
|
||||
tabs={tabs}
|
||||
paneTabIds={["t1", "t2", null, null, null, null]}
|
||||
splitMode="2-way"
|
||||
rowSizes={[100]}
|
||||
rowColSizes={[[20, 80]]}
|
||||
onRowSizesChange={() => {}}
|
||||
onRowColSizesChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelectorAll('[style*="width: 20%"]').length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
import {
|
||||
assignTabsToSplit,
|
||||
createSplitConfig,
|
||||
releaseSplitTabs,
|
||||
restoreSplitTabs,
|
||||
serializeSplitTabs,
|
||||
} from "@/shell/splitTabUtils";
|
||||
|
||||
const session = (id: string): Tab => ({
|
||||
id,
|
||||
instanceId: `instance-${id}`,
|
||||
type: "terminal",
|
||||
label: id,
|
||||
openedAt: 1,
|
||||
});
|
||||
|
||||
describe("split tab state", () => {
|
||||
it("moves assigned sessions under a split tab and releases removed panes", () => {
|
||||
const split: Tab = {
|
||||
id: "split-1",
|
||||
instanceId: "split-instance",
|
||||
type: "split-screen",
|
||||
label: "Split #1",
|
||||
openedAt: 1,
|
||||
splitConfig: createSplitConfig("2-way", ["a", "b"], {
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[50, 50]],
|
||||
}),
|
||||
};
|
||||
|
||||
const assigned = assignTabsToSplit(
|
||||
[session("a"), session("b"), split],
|
||||
split.id,
|
||||
["a", "b"],
|
||||
);
|
||||
expect(assigned.slice(0, 2).map((tab) => tab.parentSplitTabId)).toEqual([
|
||||
split.id,
|
||||
split.id,
|
||||
]);
|
||||
|
||||
const updated = assignTabsToSplit(assigned, split.id, ["a", null]);
|
||||
expect(updated.find((tab) => tab.id === "a")?.parentSplitTabId).toBe(
|
||||
split.id,
|
||||
);
|
||||
expect(updated.find((tab) => tab.id === "b")?.parentSplitTabId).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("releases child sessions when the split tab closes", () => {
|
||||
const tabs = assignTabsToSplit(
|
||||
[
|
||||
session("a"),
|
||||
{
|
||||
id: "split-1",
|
||||
instanceId: "split-instance",
|
||||
type: "split-screen",
|
||||
label: "Split #1",
|
||||
openedAt: 1,
|
||||
},
|
||||
],
|
||||
"split-1",
|
||||
["a"],
|
||||
);
|
||||
expect(releaseSplitTabs(tabs, "split-1")).toEqual([session("a")]);
|
||||
});
|
||||
|
||||
it("persists pane membership by stable instance id", () => {
|
||||
const members = [session("a"), session("b")];
|
||||
const split: Tab = {
|
||||
id: "split-1",
|
||||
instanceId: "workspace-1",
|
||||
type: "split-screen",
|
||||
label: "Production",
|
||||
openedAt: 1,
|
||||
splitConfig: createSplitConfig("2-way", ["a", "b"], {
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[40, 60]],
|
||||
}),
|
||||
};
|
||||
const saved = serializeSplitTabs([...members, split]);
|
||||
const restoredMembers = [
|
||||
{ ...session("new-a"), instanceId: "instance-a" },
|
||||
{ ...session("new-b"), instanceId: "instance-b" },
|
||||
];
|
||||
const restored = restoreSplitTabs(saved, restoredMembers, 2);
|
||||
const restoredSplit = restored.find((tab) => tab.type === "split-screen");
|
||||
|
||||
expect(restoredSplit?.label).toBe("Production");
|
||||
expect(restoredSplit?.splitConfig?.paneTabIds.slice(0, 2)).toEqual([
|
||||
"new-a",
|
||||
"new-b",
|
||||
]);
|
||||
expect(restoredMembers.map((tab) => tab.id)).toEqual(["new-a", "new-b"]);
|
||||
expect(
|
||||
restored
|
||||
.filter((tab) => tab.type === "terminal")
|
||||
.map((tab) => tab.parentSplitTabId),
|
||||
).toEqual(["split-workspace-1", "split-workspace-1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorkspacePayload,
|
||||
buildWorkspaceTabSnapshots,
|
||||
remapSlotIds,
|
||||
resolveWorkspaceTabTarget,
|
||||
} from "../../shell/workspaceUtils";
|
||||
import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types";
|
||||
|
||||
function makeHost(overrides: Partial<Host> = {}): Host {
|
||||
return {
|
||||
id: "1",
|
||||
name: "web-01",
|
||||
username: "root",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
folder: "",
|
||||
online: true,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: "2026-01-01T00:00:00.000Z",
|
||||
authType: "password",
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: true,
|
||||
syncId: "sync-web-01",
|
||||
...overrides,
|
||||
} as Host;
|
||||
}
|
||||
|
||||
function makeTab(overrides: Partial<Tab> = {}): Tab {
|
||||
return {
|
||||
id: "tab-1",
|
||||
instanceId: "instance-1",
|
||||
type: "terminal",
|
||||
label: "web-01",
|
||||
openedAt: 0,
|
||||
...overrides,
|
||||
} as Tab;
|
||||
}
|
||||
|
||||
describe("buildWorkspaceTabSnapshots", () => {
|
||||
it("captures host-bound and singleton tabs with generated slot ids", () => {
|
||||
let counter = 0;
|
||||
const genSlotId = () => `slot-${counter++}`;
|
||||
|
||||
const host = makeHost();
|
||||
const tabs: Tab[] = [
|
||||
makeTab({ id: "t1", type: "terminal", host, label: "web-01" }),
|
||||
makeTab({ id: "t2", type: "tunnel", label: "Tunnels" }),
|
||||
];
|
||||
|
||||
const { snapshots, slotIdByTabId } = buildWorkspaceTabSnapshots(
|
||||
tabs,
|
||||
genSlotId,
|
||||
);
|
||||
|
||||
expect(snapshots).toHaveLength(2);
|
||||
expect(snapshots[0]).toMatchObject({
|
||||
slotId: "slot-0",
|
||||
type: "terminal",
|
||||
hostSyncId: "sync-web-01",
|
||||
hostNameSnapshot: "web-01",
|
||||
});
|
||||
expect(snapshots[1]).toMatchObject({
|
||||
slotId: "slot-1",
|
||||
type: "tunnel",
|
||||
hostSyncId: null,
|
||||
});
|
||||
expect(slotIdByTabId.get("t1")).toBe("slot-0");
|
||||
expect(slotIdByTabId.get("t2")).toBe("slot-1");
|
||||
});
|
||||
|
||||
it("excludes rail-view pseudo tabs (host-manager, user-profile, admin-settings)", () => {
|
||||
const tabs: Tab[] = [
|
||||
makeTab({ id: "t1", type: "host-manager" }),
|
||||
makeTab({ id: "t2", type: "user-profile" }),
|
||||
makeTab({ id: "t3", type: "admin-settings" }),
|
||||
makeTab({ id: "t4", type: "tunnel" }),
|
||||
];
|
||||
|
||||
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
|
||||
expect(snapshots).toHaveLength(1);
|
||||
expect(snapshots[0].type).toBe("tunnel");
|
||||
});
|
||||
|
||||
it("excludes the dashboard tab - it's a permanent fallback, not a meaningful part of a saved arrangement", () => {
|
||||
const tabs: Tab[] = [
|
||||
makeTab({ id: "t1", type: "dashboard" }),
|
||||
makeTab({ id: "t2", type: "terminal", host: makeHost() }),
|
||||
];
|
||||
|
||||
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
|
||||
expect(snapshots).toHaveLength(1);
|
||||
expect(snapshots[0].type).toBe("terminal");
|
||||
});
|
||||
|
||||
it("captures serialConfig only for serial tabs", () => {
|
||||
const tabs: Tab[] = [
|
||||
makeTab({
|
||||
id: "t1",
|
||||
type: "serial",
|
||||
serialConfig: {
|
||||
path: "/dev/ttyUSB0",
|
||||
baudRate: 9600,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
},
|
||||
}),
|
||||
makeTab({ id: "t2", type: "terminal", host: makeHost() }),
|
||||
];
|
||||
|
||||
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
|
||||
expect(snapshots[0].serialConfig).toEqual({
|
||||
path: "/dev/ttyUSB0",
|
||||
baudRate: 9600,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
});
|
||||
expect(snapshots[0].hostSyncId).toBeNull();
|
||||
expect(snapshots[1].serialConfig).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remapSlotIds", () => {
|
||||
it("maps slot ids to live tab ids, preserving nulls and unresolved slots", () => {
|
||||
const map = new Map([
|
||||
["slot-a", "new-tab-1"],
|
||||
["slot-b", "new-tab-2"],
|
||||
]);
|
||||
const result = remapSlotIds(
|
||||
["slot-a", null, "slot-b", "slot-missing"],
|
||||
map,
|
||||
);
|
||||
expect(result).toEqual(["new-tab-1", null, "new-tab-2", null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceTabTarget", () => {
|
||||
const hosts: Host[] = [makeHost({ id: "1", syncId: "sync-web-01" })];
|
||||
|
||||
it("resolves a serial snapshot with a config as 'serial'", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "serial",
|
||||
label: "Serial",
|
||||
serialConfig: {
|
||||
path: "/dev/ttyUSB0",
|
||||
baudRate: 9600,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
},
|
||||
};
|
||||
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
|
||||
kind: "serial",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips a serial snapshot missing its config", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "serial",
|
||||
label: "Serial",
|
||||
};
|
||||
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
|
||||
kind: "skip",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a host-bound snapshot by matching syncId", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "terminal",
|
||||
label: "web-01",
|
||||
hostSyncId: "sync-web-01",
|
||||
};
|
||||
const result = resolveWorkspaceTabTarget(snapshot, hosts);
|
||||
expect(result.kind).toBe("host");
|
||||
expect((result as { host: Host }).host.syncId).toBe("sync-web-01");
|
||||
});
|
||||
|
||||
it("skips a host-bound snapshot whose host no longer exists", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "terminal",
|
||||
label: "deleted-host",
|
||||
hostSyncId: "sync-gone",
|
||||
hostNameSnapshot: "deleted-host",
|
||||
};
|
||||
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
|
||||
kind: "skip",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a hostless singleton type without a hostSyncId", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "tunnel",
|
||||
label: "Tunnels",
|
||||
};
|
||||
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
|
||||
kind: "singleton",
|
||||
host: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a singleton type WITH a resolved host as singleton+host (e.g. fleet-inventory)", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "fleet-inventory",
|
||||
label: "Fleet",
|
||||
hostSyncId: "sync-web-01",
|
||||
};
|
||||
const result = resolveWorkspaceTabTarget(snapshot, hosts);
|
||||
expect(result.kind).toBe("singleton");
|
||||
expect((result as { host?: Host }).host?.syncId).toBe("sync-web-01");
|
||||
});
|
||||
|
||||
it("skips a non-singleton, non-serial type with no resolvable host", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "files",
|
||||
label: "files",
|
||||
};
|
||||
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
|
||||
kind: "skip",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkspacePayload", () => {
|
||||
it("builds a full payload including pane assignment remapped to slot ids", () => {
|
||||
let counter = 0;
|
||||
const genSlotId = () => `slot-${counter++}`;
|
||||
|
||||
const host = makeHost();
|
||||
const tabs: Tab[] = [
|
||||
makeTab({ id: "t1", type: "terminal", host }),
|
||||
makeTab({ id: "t2", type: "files", host }),
|
||||
];
|
||||
|
||||
const payload = buildWorkspacePayload({
|
||||
tabs,
|
||||
activeTabId: "t1",
|
||||
splitMode: "2-way",
|
||||
paneTabIds: ["t1", "t2", null, null, null, null],
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[50, 50]],
|
||||
genSlotId,
|
||||
});
|
||||
|
||||
expect(payload.version).toBe(1);
|
||||
expect(payload.activeSlotId).toBe("slot-0");
|
||||
expect(payload.paneTabIds).toEqual([
|
||||
"slot-0",
|
||||
"slot-1",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(payload.splitMode).toBe("2-way");
|
||||
expect(payload.rowSizes).toEqual([100]);
|
||||
expect(payload.rowColSizes).toEqual([[50, 50]]);
|
||||
});
|
||||
|
||||
it("sets activeSlotId to null when the active tab is not capturable", () => {
|
||||
const payload = buildWorkspacePayload({
|
||||
tabs: [makeTab({ id: "t1", type: "host-manager" })],
|
||||
activeTabId: "t1",
|
||||
splitMode: "none",
|
||||
paneTabIds: [null, null, null, null, null, null],
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[100]],
|
||||
});
|
||||
expect(payload.activeSlotId).toBeNull();
|
||||
expect(payload.tabs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("round-trips the sidebar arrangement", () => {
|
||||
const payload = buildWorkspacePayload({
|
||||
tabs: [],
|
||||
activeTabId: "dashboard",
|
||||
splitMode: "none",
|
||||
paneTabIds: [null, null, null, null, null, null],
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[100]],
|
||||
sidebar: {
|
||||
left: { view: "hosts", open: true, width: 320 },
|
||||
right: { view: "history", open: true, width: 240 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.sidebar).toEqual({
|
||||
left: { view: "hosts", open: true, width: 320 },
|
||||
right: { view: "history", open: true, width: 240 },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits sidebar when not supplied so older payloads stay unchanged", () => {
|
||||
const payload = buildWorkspacePayload({
|
||||
tabs: [],
|
||||
activeTabId: "dashboard",
|
||||
splitMode: "none",
|
||||
paneTabIds: [null, null, null, null, null, null],
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[100]],
|
||||
});
|
||||
|
||||
expect(payload.sidebar).toBeUndefined();
|
||||
expect(payload.version).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AdminTouchInputSection } from "../../sidebar/AdminTouchInputSection";
|
||||
import { TOUCH_INPUT_DEFAULTS } from "@/types/touch-input-settings";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
describe("AdminTouchInputSection", () => {
|
||||
it("shows simple defaults, keeps tunables collapsed, and resets", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReset = vi.fn();
|
||||
render(
|
||||
<AdminTouchInputSection
|
||||
open
|
||||
onToggle={vi.fn()}
|
||||
settings={{ ...TOUCH_INPUT_DEFAULTS }}
|
||||
setSettings={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onReset={onReset}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("admin.touchEnabled")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("admin.touchDragThreshold")).toBeNull();
|
||||
await user.click(screen.getByText("admin.touchAdvanced"));
|
||||
expect(
|
||||
(screen.getByLabelText("admin.touchDragThreshold") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("6");
|
||||
expect(
|
||||
(
|
||||
screen.getByLabelText(
|
||||
"admin.touchMaximumTicksPerFrame",
|
||||
) as HTMLInputElement
|
||||
).value,
|
||||
).toBe("4");
|
||||
await user.click(screen.getByText("admin.touchResetDefaults"));
|
||||
expect(onReset).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,32 @@ describe("buildHostEditorPayload auth field isolation", () => {
|
||||
expect(payload.password).toBe("newpass");
|
||||
});
|
||||
|
||||
it("drops the vaultProfileId when switching a host away from vault auth", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "password" as const,
|
||||
password: "newpass",
|
||||
vaultProfileId: "9",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.vaultProfileId).toBeNull();
|
||||
expect(payload.password).toBe("newpass");
|
||||
});
|
||||
|
||||
it("sends vaultProfileId when authType is vault", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "vault" as const,
|
||||
vaultProfileId: "9",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.vaultProfileId).toBe(9);
|
||||
});
|
||||
|
||||
it("sends credentialId and optional password when authType is credential", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
@@ -168,7 +194,10 @@ describe("buildHostEditorPayload auth field isolation", () => {
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
const tc = payload.terminalConfig as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBe("/run/user/1000/gnupg/S.gpg-agent.ssh");
|
||||
expect(payload.password).toBeNull();
|
||||
@@ -183,7 +212,10 @@ describe("buildHostEditorPayload auth field isolation", () => {
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
const tc = payload.terminalConfig as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBeNull();
|
||||
});
|
||||
@@ -197,7 +229,10 @@ describe("buildHostEditorPayload auth field isolation", () => {
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
const tc = payload.terminalConfig as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBeNull();
|
||||
});
|
||||
@@ -210,13 +245,195 @@ describe("buildHostEditorPayload auth field isolation", () => {
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
const tc = payload.terminalConfig as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
expect(tc?.sudoPasswordAutoFill).toBe(true);
|
||||
expect(tc?.sudoPassword).toBe("sudo-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sudo password persistence indicator", () => {
|
||||
it("seeds a sentinel value when the host reports a saved sudo password", () => {
|
||||
const host = { hasSudoPassword: true } as Host;
|
||||
const form = createHostEditorForm(host);
|
||||
|
||||
expect(form.sudoPassword).toBe("existing_sudo_password");
|
||||
});
|
||||
|
||||
it("omits sudoPassword from the payload when the sentinel is unchanged, so a save doesn't wipe it", () => {
|
||||
const host = { hasSudoPassword: true } as Host;
|
||||
const form = { ...createHostEditorForm(host) };
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
expect(tc?.sudoPassword).toBeUndefined();
|
||||
expect(JSON.parse(JSON.stringify(tc))).not.toHaveProperty("sudoPassword");
|
||||
});
|
||||
|
||||
it("sends a newly typed sudo password", () => {
|
||||
const host = { hasSudoPassword: true } as Host;
|
||||
const form = {
|
||||
...createHostEditorForm(host),
|
||||
sudoPassword: "new-sudo-pass",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
expect(tc?.sudoPassword).toBe("new-sudo-pass");
|
||||
});
|
||||
|
||||
it("sends null to explicitly clear a saved sudo password", () => {
|
||||
const host = { hasSudoPassword: true } as Host;
|
||||
const form = {
|
||||
...createHostEditorForm(host),
|
||||
sudoPassword: "",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
expect(tc?.sudoPassword).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Proxmox / Proxmox Stats independent toggles", () => {
|
||||
it("defaults enableProxmoxStats to false with sane pollInterval/nodeName defaults", () => {
|
||||
const form = createHostEditorForm(null);
|
||||
|
||||
expect(form.enableProxmoxStats).toBe(false);
|
||||
expect(form.proxmoxStatsConfig).toEqual({
|
||||
pollInterval: 60,
|
||||
nodeName: null,
|
||||
});
|
||||
expect(form.enableProxmox).toBe(false);
|
||||
});
|
||||
|
||||
it("seeds both configs independently from an existing host", () => {
|
||||
const host = {
|
||||
enableProxmox: true,
|
||||
proxmoxConfig: { windowsPatterns: "win", dockerPatterns: "docker" },
|
||||
enableProxmoxStats: true,
|
||||
proxmoxStatsConfig: { pollInterval: 30, nodeName: "pve-custom" },
|
||||
} as unknown as Host;
|
||||
|
||||
const form = createHostEditorForm(host);
|
||||
|
||||
expect(form.enableProxmox).toBe(true);
|
||||
expect(form.enableProxmoxStats).toBe(true);
|
||||
expect(form.proxmoxStatsConfig).toEqual({
|
||||
pollInterval: 30,
|
||||
nodeName: "pve-custom",
|
||||
});
|
||||
});
|
||||
|
||||
it("nulls proxmoxStatsConfig in the payload when enableProxmoxStats is off, regardless of enableProxmox", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
enableProxmox: true,
|
||||
proxmoxConfig: {
|
||||
defaultCredentialId: null,
|
||||
defaultAuthType: "password",
|
||||
windowsPatterns: "win",
|
||||
dockerPatterns: "docker",
|
||||
preferredPrefixes: "",
|
||||
},
|
||||
enableProxmoxStats: false,
|
||||
proxmoxStatsConfig: { pollInterval: 45, nodeName: "leftover" },
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.enableProxmox).toBe(true);
|
||||
expect(payload.proxmoxConfig).not.toBeNull();
|
||||
expect(payload.enableProxmoxStats).toBe(false);
|
||||
expect(payload.proxmoxStatsConfig).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps proxmoxConfig null in the payload when enableProxmox is off, even though enableProxmoxStats is on", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
enableProxmox: false,
|
||||
enableProxmoxStats: true,
|
||||
proxmoxStatsConfig: { pollInterval: 90, nodeName: "pve1" },
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.enableProxmox).toBe(false);
|
||||
expect(payload.proxmoxConfig).toBeNull();
|
||||
expect(payload.enableProxmoxStats).toBe(true);
|
||||
expect(payload.proxmoxStatsConfig).toEqual({
|
||||
pollInterval: 90,
|
||||
nodeName: "pve1",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the source identity when editing an imported Proxmox guest", () => {
|
||||
const source = {
|
||||
source: "proxmox" as const,
|
||||
sourceHostId: 7,
|
||||
node: "pve1",
|
||||
vmid: 101,
|
||||
type: "qemu" as const,
|
||||
lastSeenAt: "2026-08-14T00:00:00.000Z",
|
||||
lastStatus: "running",
|
||||
missingSince: null,
|
||||
};
|
||||
const form = {
|
||||
...createHostEditorForm({
|
||||
enableProxmox: false,
|
||||
proxmoxConfig: { source },
|
||||
} as unknown as Host),
|
||||
name: "Edited guest",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.enableProxmox).toBe(false);
|
||||
expect(payload.proxmoxConfig).toEqual({ source });
|
||||
});
|
||||
|
||||
it("sends both configs when both toggles are on", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
enableProxmox: true,
|
||||
proxmoxConfig: {
|
||||
defaultCredentialId: null,
|
||||
defaultAuthType: "password",
|
||||
windowsPatterns: "win",
|
||||
dockerPatterns: "docker",
|
||||
preferredPrefixes: "",
|
||||
},
|
||||
enableProxmoxStats: true,
|
||||
proxmoxStatsConfig: { pollInterval: 60, nodeName: null },
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.enableProxmox).toBe(true);
|
||||
expect(payload.proxmoxConfig).not.toBeNull();
|
||||
expect(payload.enableProxmoxStats).toBe(true);
|
||||
expect(payload.proxmoxStatsConfig).toEqual({
|
||||
pollInterval: 60,
|
||||
nodeName: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("RDP/VNC/Telnet password persistence indicator", () => {
|
||||
it("seeds a sentinel value when the host reports a saved rdp password", () => {
|
||||
const host = {
|
||||
@@ -288,3 +505,57 @@ describe("RDP/VNC/Telnet password persistence indicator", () => {
|
||||
expect(payload.telnetPassword).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("user connection defaults", () => {
|
||||
const defaults = {
|
||||
terminal: { fontSize: 18, cursorBlink: false },
|
||||
rdp: { colorDepth: 24, disableCopy: true },
|
||||
};
|
||||
|
||||
it("shows inherited values without persisting them as host overrides", () => {
|
||||
const host = {
|
||||
enableSsh: true,
|
||||
enableRdp: true,
|
||||
terminalConfig: { autoTmux: true },
|
||||
guacamoleConfig: { enableAudioInput: true },
|
||||
} as Host;
|
||||
const form = createHostEditorForm(host, undefined, defaults);
|
||||
|
||||
expect(form).toMatchObject({
|
||||
fontSize: 18,
|
||||
cursorBlink: false,
|
||||
inheritTerminalAppearance: true,
|
||||
inheritRemoteDesktopDefaults: true,
|
||||
guacamoleConfig: {
|
||||
colorDepth: 24,
|
||||
disableCopy: true,
|
||||
enableAudioInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = buildHostEditorPayload(form, {
|
||||
...sshOnly,
|
||||
enableRdp: true,
|
||||
});
|
||||
expect(payload.terminalConfig).toMatchObject({ autoTmux: true });
|
||||
expect(payload.terminalConfig).not.toHaveProperty("fontSize");
|
||||
expect(payload.guacamoleConfig).toEqual({ enableAudioInput: true });
|
||||
});
|
||||
|
||||
it("keeps explicit host overrides above user defaults", () => {
|
||||
const host = {
|
||||
enableSsh: true,
|
||||
enableRdp: true,
|
||||
terminalConfig: { fontSize: 12 },
|
||||
guacamoleConfig: { colorDepth: 32 },
|
||||
} as Host;
|
||||
const form = createHostEditorForm(host, undefined, defaults);
|
||||
|
||||
expect(form).toMatchObject({
|
||||
fontSize: 12,
|
||||
inheritTerminalAppearance: false,
|
||||
inheritRemoteDesktopDefaults: false,
|
||||
guacamoleConfig: { colorDepth: 32, disableCopy: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,9 @@ function sshHost(
|
||||
enableFileManager: false,
|
||||
enableDocker: false,
|
||||
enableProxmox: false,
|
||||
enableProxmoxStats: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableTerminalToolbar: true,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: false,
|
||||
showTunnelInSidebar: false,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectDescendantIds } from "../../sidebar/HostParentPicker.js";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
function host(id: string, parentHostId?: string): Host {
|
||||
return { id, parentHostId: parentHostId ?? null } as Host;
|
||||
}
|
||||
|
||||
describe("collectDescendantIds", () => {
|
||||
it("returns an empty set for a host with no children", () => {
|
||||
const hosts = [host("1")];
|
||||
expect(collectDescendantIds("1", hosts)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("collects direct children", () => {
|
||||
const hosts = [host("1"), host("2", "1"), host("3", "1")];
|
||||
expect(collectDescendantIds("1", hosts)).toEqual(new Set(["2", "3"]));
|
||||
});
|
||||
|
||||
it("collects grandchildren at unlimited depth", () => {
|
||||
const hosts = [host("1"), host("2", "1"), host("3", "2"), host("4", "3")];
|
||||
expect(collectDescendantIds("1", hosts)).toEqual(new Set(["2", "3", "4"]));
|
||||
});
|
||||
|
||||
it("does not include unrelated hosts or ancestors", () => {
|
||||
const hosts = [
|
||||
host("1"),
|
||||
host("2", "1"),
|
||||
host("3"), // unrelated sibling tree
|
||||
host("4", "3"),
|
||||
];
|
||||
expect(collectDescendantIds("1", hosts)).toEqual(new Set(["2"]));
|
||||
});
|
||||
|
||||
it("does not loop forever on a cyclic parent chain", () => {
|
||||
// Defensive case: two hosts pointing at each other should not hang.
|
||||
const hosts = [host("1", "2"), host("2", "1")];
|
||||
const result = collectDescendantIds("1", hosts);
|
||||
expect(result).toEqual(new Set(["2", "1"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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,20 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
|
||||
const { createApiKeyMock } = vi.hoisted(() => ({
|
||||
createApiKeyMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/main-axios")>()),
|
||||
createApiKey: createApiKeyMock,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
@@ -22,7 +37,10 @@ vi.mock("@/components/theme-provider", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron: () => false }));
|
||||
|
||||
import { AccordionSection } from "../../sidebar/UserProfilePanel";
|
||||
import {
|
||||
AccordionSection,
|
||||
NewApiKeyDialog,
|
||||
} from "../../sidebar/UserProfilePanel";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -99,3 +117,54 @@ describe("AccordionSection", () => {
|
||||
expect(onToggle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NewApiKeyDialog", () => {
|
||||
it("keeps the one-time token visible until Done is clicked", async () => {
|
||||
createApiKeyMock.mockResolvedValue({
|
||||
id: "key-1",
|
||||
name: "deploy",
|
||||
userId: "user-1",
|
||||
username: "user",
|
||||
tokenPrefix: "tmx_test",
|
||||
token: "tmx_test_secret",
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
isActive: true,
|
||||
});
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
render(
|
||||
<NewApiKeyDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onAdd={vi.fn()}
|
||||
userId="user-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(
|
||||
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
|
||||
),
|
||||
{ target: { value: "deploy" } },
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: /newUi.sidebar.userProfile.createKey/,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("tmx_test_secret")).toBeTruthy();
|
||||
});
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "newUi.sidebar.userProfile.done",
|
||||
}),
|
||||
);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultTrigger } from "@/sidebar/automations/TriggerCard";
|
||||
import { emptyDraft } from "@/sidebar/automations/AutomationEditor";
|
||||
import { newStepId } from "@/sidebar/automations/editor-types";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations";
|
||||
|
||||
describe("emptyDraft", () => {
|
||||
it("starts on a metric threshold with no steps", () => {
|
||||
const draft = emptyDraft();
|
||||
expect(draft.name).toBe("");
|
||||
expect(draft.enabled).toBe(true);
|
||||
expect(draft.definition.version).toBe(AUTOMATION_DEFINITION_VERSION);
|
||||
expect(draft.definition.trigger.kind).toBe("metric_threshold");
|
||||
expect(draft.definition.steps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultTrigger", () => {
|
||||
it("gives every trigger kind a usable starting shape", () => {
|
||||
expect(defaultTrigger("metric_threshold")).toMatchObject({
|
||||
kind: "metric_threshold",
|
||||
operator: ">",
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
expect(defaultTrigger("host_status")).toMatchObject({
|
||||
kind: "host_status",
|
||||
to: "offline",
|
||||
});
|
||||
expect(defaultTrigger("health_check")).toMatchObject({
|
||||
kind: "health_check",
|
||||
to: "failing",
|
||||
});
|
||||
expect(defaultTrigger("schedule")).toMatchObject({ kind: "schedule" });
|
||||
expect(defaultTrigger("docker_event")).toMatchObject({
|
||||
kind: "docker_event",
|
||||
event: "exited",
|
||||
});
|
||||
expect(defaultTrigger("webhook")).toMatchObject({ kind: "webhook" });
|
||||
});
|
||||
|
||||
it("defaults a schedule to a valid cron expression", () => {
|
||||
const trigger = defaultTrigger("schedule");
|
||||
expect(trigger).toHaveProperty("cron");
|
||||
// Five fields, which is what the backend parser requires.
|
||||
expect(
|
||||
String((trigger as { cron: string }).cron).split(/\s+/),
|
||||
).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("newStepId", () => {
|
||||
it("produces distinct ids", () => {
|
||||
const ids = new Set(Array.from({ length: 100 }, () => newStepId()));
|
||||
expect(ids.size).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHostTree } from "../../sidebar/build-host-tree.js";
|
||||
import { isFolder } from "../../sidebar/tree/visible-rows.js";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
|
||||
type RawHostOverrides = {
|
||||
id: number;
|
||||
name?: string;
|
||||
folder?: string;
|
||||
/** Arrives as a number over the wire; sshHostToHost stringifies it. */
|
||||
parentHostId?: number;
|
||||
};
|
||||
|
||||
function rawHost(overrides: RawHostOverrides): SSHHostWithStatus {
|
||||
return {
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
status: "unknown",
|
||||
name: `host-${overrides.id}`,
|
||||
...overrides,
|
||||
} as unknown as SSHHostWithStatus;
|
||||
}
|
||||
|
||||
describe("buildHostTree", () => {
|
||||
it("places hosts with no folder or parent at root", () => {
|
||||
const tree = buildHostTree([rawHost({ id: 1 }), rawHost({ id: 2 })]);
|
||||
expect(tree.children).toHaveLength(2);
|
||||
expect(tree.children.every((c) => !isFolder(c))).toBe(true);
|
||||
});
|
||||
|
||||
it("nests a host under its parent via parentHostId, not as a folder wrapper", () => {
|
||||
const tree = buildHostTree([
|
||||
rawHost({ id: 1, name: "Zeus" }),
|
||||
rawHost({ id: 2, name: "vm1", parentHostId: 1 }),
|
||||
]);
|
||||
|
||||
expect(tree.children).toHaveLength(1);
|
||||
const zeus = tree.children[0];
|
||||
expect(isFolder(zeus)).toBe(false);
|
||||
if (isFolder(zeus)) throw new Error("unreachable");
|
||||
expect(zeus.name).toBe("Zeus");
|
||||
expect(zeus.childHosts).toHaveLength(1);
|
||||
expect(zeus.childHosts?.[0].name).toBe("vm1");
|
||||
});
|
||||
|
||||
it("supports unlimited nesting depth", () => {
|
||||
const tree = buildHostTree([
|
||||
rawHost({ id: 1, name: "root-host" }),
|
||||
rawHost({ id: 2, name: "child", parentHostId: 1 }),
|
||||
rawHost({ id: 3, name: "grandchild", parentHostId: 2 }),
|
||||
]);
|
||||
|
||||
const root = tree.children[0];
|
||||
if (isFolder(root)) throw new Error("unreachable");
|
||||
const child = root.childHosts?.[0];
|
||||
expect(child?.name).toBe("child");
|
||||
expect(child?.childHosts?.[0].name).toBe("grandchild");
|
||||
});
|
||||
|
||||
it("falls back to root placement when the parent host is missing", () => {
|
||||
const tree = buildHostTree([
|
||||
rawHost({ id: 2, name: "orphan", parentHostId: 999 }),
|
||||
]);
|
||||
expect(tree.children).toHaveLength(1);
|
||||
expect(tree.children[0].name).toBe("orphan");
|
||||
});
|
||||
|
||||
it("falls back to root placement rather than looping on a cyclic parent chain", () => {
|
||||
// Defensive case: stale/synced data where two hosts point at each other.
|
||||
const tree = buildHostTree([
|
||||
rawHost({ id: 1, name: "a", parentHostId: 2 }),
|
||||
rawHost({ id: 2, name: "b", parentHostId: 1 }),
|
||||
]);
|
||||
// Neither host should vanish or cause an infinite loop -- both land at root.
|
||||
expect(tree.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("prefers parentHostId nesting over folder placement", () => {
|
||||
const tree = buildHostTree([
|
||||
rawHost({ id: 1, name: "Zeus" }),
|
||||
rawHost({
|
||||
id: 2,
|
||||
name: "vm1",
|
||||
parentHostId: 1,
|
||||
folder: "Production",
|
||||
}),
|
||||
]);
|
||||
|
||||
// The sub-host does not also appear under a "Production" folder node.
|
||||
expect(tree.children).toHaveLength(1);
|
||||
const zeus = tree.children[0];
|
||||
if (isFolder(zeus)) throw new Error("unreachable");
|
||||
expect(zeus.childHosts?.[0].name).toBe("vm1");
|
||||
});
|
||||
|
||||
it("still places hosts by folder path when no parentHostId is set", () => {
|
||||
const tree = buildHostTree([
|
||||
rawHost({ id: 1, name: "web1", folder: "Production / Web" }),
|
||||
]);
|
||||
expect(tree.children).toHaveLength(1);
|
||||
expect(isFolder(tree.children[0])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHostTree folder metadata", () => {
|
||||
it("copies sortOrder onto the folder so manual sort can order folders", () => {
|
||||
const tree = buildHostTree(
|
||||
[rawHost({ id: 1, folder: "Prod" }), rawHost({ id: 2, folder: "Dev" })],
|
||||
new Map([
|
||||
["Prod", { sortOrder: 2000 }],
|
||||
["Dev", { sortOrder: 1000 }],
|
||||
]),
|
||||
);
|
||||
const folders = tree.children.filter(isFolder);
|
||||
expect(folders.map((f) => [f.name, f.sortOrder])).toEqual(
|
||||
expect.arrayContaining([
|
||||
["Prod", 2000],
|
||||
["Dev", 1000],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves sortOrder null when the folder has no metadata row", () => {
|
||||
const tree = buildHostTree([rawHost({ id: 1, folder: "Prod" })]);
|
||||
expect(tree.children.filter(isFolder)[0].sortOrder).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
import {
|
||||
credentialPassesFilters,
|
||||
sortCredentials,
|
||||
} from "../../sidebar/credential-sort";
|
||||
|
||||
function cred(name: string, opts: Partial<Credential> = {}): Credential {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
username: `user-${name}`,
|
||||
type: "password",
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sortCredentials", () => {
|
||||
it("returns the input order unchanged for the default key", () => {
|
||||
const creds = [cred("gamma"), cred("alpha"), cred("beta")];
|
||||
expect(sortCredentials(creds, "default").map((c) => c.name)).toEqual([
|
||||
"gamma",
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts by name ascending and descending", () => {
|
||||
const creds = [cred("gamma"), cred("alpha"), cred("beta")];
|
||||
expect(sortCredentials(creds, "name-asc").map((c) => c.name)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
"gamma",
|
||||
]);
|
||||
expect(sortCredentials(creds, "name-desc").map((c) => c.name)).toEqual([
|
||||
"gamma",
|
||||
"beta",
|
||||
"alpha",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts by username ascending and descending", () => {
|
||||
const creds = [
|
||||
cred("a", { username: "zeta" }),
|
||||
cred("b", { username: "alpha" }),
|
||||
];
|
||||
expect(
|
||||
sortCredentials(creds, "username-asc").map((c) => c.username),
|
||||
).toEqual(["alpha", "zeta"]);
|
||||
expect(
|
||||
sortCredentials(creds, "username-desc").map((c) => c.username),
|
||||
).toEqual(["zeta", "alpha"]);
|
||||
});
|
||||
|
||||
it("sorts manually by sortOrder, nulls last, name tie-break", () => {
|
||||
const creds = [
|
||||
cred("gamma", { sortOrder: null }),
|
||||
cred("alpha", { sortOrder: 2000 }),
|
||||
cred("beta", { sortOrder: 1000 }),
|
||||
cred("delta", { sortOrder: null }),
|
||||
];
|
||||
expect(sortCredentials(creds, "manual").map((c) => c.name)).toEqual([
|
||||
"beta",
|
||||
"alpha",
|
||||
"delta",
|
||||
"gamma",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const creds = [cred("gamma"), cred("alpha")];
|
||||
sortCredentials(creds, "name-asc");
|
||||
expect(creds.map((c) => c.name)).toEqual(["gamma", "alpha"]);
|
||||
});
|
||||
|
||||
it("keeps pinned-first independent from the selected base sort", () => {
|
||||
const creds = [cred("alpha"), cred("zeta", { pin: true }), cred("beta")];
|
||||
expect(sortCredentials(creds, "name-asc", true).map((c) => c.name)).toEqual(
|
||||
["zeta", "alpha", "beta"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("credentialPassesFilters", () => {
|
||||
it("passes everything when no filters are active", () => {
|
||||
expect(credentialPassesFilters(cred("a"), { type: [], tags: [] })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("filters by type", () => {
|
||||
expect(
|
||||
credentialPassesFilters(cred("a", { type: "key" }), {
|
||||
type: ["password"],
|
||||
tags: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
credentialPassesFilters(cred("a", { type: "key" }), {
|
||||
type: ["key"],
|
||||
tags: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by tags (any match)", () => {
|
||||
expect(
|
||||
credentialPassesFilters(cred("a", { tags: ["prod"] }), {
|
||||
type: [],
|
||||
tags: ["staging"],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
credentialPassesFilters(cred("a", { tags: ["prod", "linux"] }), {
|
||||
type: [],
|
||||
tags: ["staging", "linux"],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCredentialSidebarPreferences: vi.fn(),
|
||||
saveCredentialSidebarPreferences: vi.fn(async () => {}),
|
||||
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => api);
|
||||
|
||||
import { useCredentialSidebarPreferences } from "../../../../sidebar/credential-tree/hooks/useCredentialSidebarPreferences";
|
||||
import { defaultCredentialSidebarPreferences } from "@/types/credential-sidebar-preferences";
|
||||
|
||||
describe("useCredentialSidebarPreferences", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
api.getCredentialSidebarPreferences.mockReset();
|
||||
api.getCredentialSidebarPreferences.mockResolvedValue(
|
||||
defaultCredentialSidebarPreferences(),
|
||||
);
|
||||
api.saveCredentialSidebarPreferences.mockReset();
|
||||
api.saveCredentialSidebarPreferences.mockResolvedValue(undefined);
|
||||
api.getUserPreferences.mockReset();
|
||||
api.getUserPreferences.mockResolvedValue({ storageMode: "local" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts from defaults when there is no cache", () => {
|
||||
const { result } = renderHook(() => useCredentialSidebarPreferences());
|
||||
expect(result.current.preferences).toEqual(
|
||||
defaultCredentialSidebarPreferences(),
|
||||
);
|
||||
expect(result.current.loaded).toBe(false);
|
||||
});
|
||||
|
||||
it("starts from the cached value for instant paint", () => {
|
||||
const cached = {
|
||||
...defaultCredentialSidebarPreferences(),
|
||||
sort: { key: "manual" as const, pinnedFirst: false },
|
||||
};
|
||||
localStorage.setItem(
|
||||
"credentialSidebarPreferences",
|
||||
JSON.stringify(cached),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCredentialSidebarPreferences());
|
||||
expect(result.current.preferences.sort.key).toBe("manual");
|
||||
});
|
||||
|
||||
it("loads the authoritative copy from the server and marks loaded", async () => {
|
||||
const remote = {
|
||||
...defaultCredentialSidebarPreferences(),
|
||||
sort: { key: "name-asc" as const, pinnedFirst: false },
|
||||
};
|
||||
api.getCredentialSidebarPreferences.mockResolvedValue(remote);
|
||||
|
||||
const { result } = renderHook(() => useCredentialSidebarPreferences());
|
||||
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.preferences.sort.key).toBe("name-asc");
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("credentialSidebarPreferences")!),
|
||||
).toEqual(remote);
|
||||
});
|
||||
|
||||
it("keeps the cached value and still marks loaded when the fetch fails", async () => {
|
||||
api.getCredentialSidebarPreferences.mockRejectedValue(new Error("offline"));
|
||||
|
||||
const { result } = renderHook(() => useCredentialSidebarPreferences());
|
||||
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.preferences).toEqual(
|
||||
defaultCredentialSidebarPreferences(),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes updates to the cache immediately", async () => {
|
||||
const { result } = renderHook(() => useCredentialSidebarPreferences());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, density: "compact" },
|
||||
}));
|
||||
});
|
||||
|
||||
expect(result.current.preferences.display.density).toBe("compact");
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("credentialSidebarPreferences")!).display
|
||||
.density,
|
||||
).toBe("compact");
|
||||
});
|
||||
|
||||
it("debounces the save and only pushes to the server in cloud storage mode", async () => {
|
||||
api.getUserPreferences.mockResolvedValue({ storageMode: "cloud" });
|
||||
const { result } = renderHook(() => useCredentialSidebarPreferences());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, density: "compact" },
|
||||
}));
|
||||
});
|
||||
expect(api.saveCredentialSidebarPreferences).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.saveCredentialSidebarPreferences).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
display: expect.objectContaining({ density: "compact" }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not push to the server in local storage mode", async () => {
|
||||
const { result } = renderHook(() => useCredentialSidebarPreferences());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, density: "compact" },
|
||||
}));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(api.getUserPreferences).toHaveBeenCalled());
|
||||
expect(api.saveCredentialSidebarPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs an update made by one hook instance into a second mounted instance", async () => {
|
||||
const first = renderHook(() => useCredentialSidebarPreferences());
|
||||
const second = renderHook(() => useCredentialSidebarPreferences());
|
||||
await waitFor(() => expect(first.result.current.loaded).toBe(true));
|
||||
await waitFor(() => expect(second.result.current.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
first.result.current.update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, density: "compact" },
|
||||
}));
|
||||
});
|
||||
|
||||
expect(first.result.current.preferences.display.density).toBe("compact");
|
||||
await waitFor(() =>
|
||||
expect(second.result.current.preferences.display.density).toBe("compact"),
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes patches through the shared sanitizer", async () => {
|
||||
const { result } = renderHook(() => useCredentialSidebarPreferences());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.update({
|
||||
sort: {
|
||||
key: "not-a-real-key" as unknown as "default",
|
||||
pinnedFirst: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.preferences.sort.key).toBe("default");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
import {
|
||||
collectVisibleRows,
|
||||
isFolder,
|
||||
type CredentialFolder,
|
||||
} from "../../../sidebar/credential-tree/visible-rows";
|
||||
|
||||
function cred(name: string, opts: Partial<Credential> = {}): Credential {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
username: `user-${name}`,
|
||||
type: "password",
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isFolder", () => {
|
||||
it("distinguishes folders from credentials", () => {
|
||||
const folder: CredentialFolder = { name: "prod", children: [] };
|
||||
expect(isFolder(folder)).toBe(true);
|
||||
expect(isFolder(cred("a"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectVisibleRows", () => {
|
||||
it("only shows folder headers, never recursing into a nested folder shape", () => {
|
||||
const folders: CredentialFolder[] = [
|
||||
{ name: "prod", children: [cred("a"), cred("b")] },
|
||||
];
|
||||
const rows = collectVisibleRows(folders, "", new Set(["prod"]));
|
||||
expect(rows.every((r) => r.depth <= 1)).toBe(true);
|
||||
expect(rows[0]).toMatchObject({ depth: 0 });
|
||||
expect(rows.slice(1).every((r) => r.depth === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides a folder's credentials when the folder is closed", () => {
|
||||
const folders: CredentialFolder[] = [
|
||||
{ name: "prod", children: [cred("a"), cred("b")] },
|
||||
];
|
||||
const rows = collectVisibleRows(folders, "", new Set());
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].item).toMatchObject({ name: "prod" });
|
||||
});
|
||||
|
||||
it("skips folders with no matches under an active query", () => {
|
||||
const folders: CredentialFolder[] = [
|
||||
{ name: "prod", children: [cred("apollo")] },
|
||||
{ name: "staging", children: [cred("zeus")] },
|
||||
];
|
||||
const rows = collectVisibleRows(folders, "apo", new Set());
|
||||
expect(
|
||||
rows.map((r) => (isFolder(r.item) ? r.item.name : r.item.name)),
|
||||
).toEqual(["prod", "apollo"]);
|
||||
});
|
||||
|
||||
it("auto-expands folders while a query is active regardless of open state", () => {
|
||||
const folders: CredentialFolder[] = [
|
||||
{ name: "prod", children: [cred("apollo")] },
|
||||
];
|
||||
const rows = collectVisibleRows(folders, "apollo", new Set());
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("matches by name, username, or tags case-insensitively", () => {
|
||||
const folders: CredentialFolder[] = [
|
||||
{
|
||||
name: "prod",
|
||||
children: [
|
||||
cred("zeus", { username: "root" }),
|
||||
cred("hera", { tags: ["Linux"] }),
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(
|
||||
collectVisibleRows(folders, "ROOT", new Set()).map((r) => r.item.name),
|
||||
).toEqual(["prod", "zeus"]);
|
||||
expect(
|
||||
collectVisibleRows(folders, "linux", new Set()).map((r) => r.item.name),
|
||||
).toEqual(["prod", "hera"]);
|
||||
});
|
||||
|
||||
it("omits empty folders entirely", () => {
|
||||
const folders: CredentialFolder[] = [{ name: "empty", children: [] }];
|
||||
expect(collectVisibleRows(folders, "", new Set(["empty"]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,14 @@ import {
|
||||
sortHostTree,
|
||||
} from "../../sidebar/host-sort";
|
||||
|
||||
function host(name: string, pin = false): Host {
|
||||
function host(name: string, pin = false, sortOrder?: number | null): Host {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
ip: `10.0.0.${name.length}`,
|
||||
online: true,
|
||||
pin,
|
||||
sortOrder,
|
||||
} as Host;
|
||||
}
|
||||
|
||||
@@ -74,6 +75,56 @@ describe("sortHostTree", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortHostTree manual mode", () => {
|
||||
it("sorts hosts by sortOrder, nulls last, name tie-break", () => {
|
||||
const tree: HostFolder = {
|
||||
name: "root",
|
||||
children: [
|
||||
host("gamma", false, null),
|
||||
host("alpha", false, 2000),
|
||||
host("beta", false, 1000),
|
||||
host("delta", false, null),
|
||||
],
|
||||
};
|
||||
|
||||
expect(names(sortHostTree(tree, "manual"))).toEqual([
|
||||
"beta",
|
||||
"alpha",
|
||||
"delta",
|
||||
"gamma",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts sibling folders by their own sortOrder in manual mode", () => {
|
||||
const tree: HostFolder = {
|
||||
name: "root",
|
||||
children: [
|
||||
{ name: "zeta", sortOrder: 1000, children: [] },
|
||||
{ name: "alpha", sortOrder: null, children: [] },
|
||||
{ name: "beta", sortOrder: 500, children: [] },
|
||||
],
|
||||
};
|
||||
|
||||
expect(names(sortHostTree(tree, "manual"))).toEqual([
|
||||
"beta",
|
||||
"zeta",
|
||||
"alpha",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still lists folders before hosts in manual mode", () => {
|
||||
const tree: HostFolder = {
|
||||
name: "root",
|
||||
children: [
|
||||
host("alpha", false, 100),
|
||||
{ name: "sub", sortOrder: 9000, children: [] },
|
||||
],
|
||||
};
|
||||
|
||||
expect(names(sortHostTree(tree, "manual"))).toEqual(["sub", "alpha"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHostSortPreferences", () => {
|
||||
it("migrates the legacy pinned sort without losing the preference", () => {
|
||||
expect(resolveHostSortPreferences("pinned", null)).toEqual({
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toCredentialOption } from "@/sidebar/quick-created-credential";
|
||||
|
||||
describe("toCredentialOption", () => {
|
||||
it("normalizes a newly created credential for immediate selection", () => {
|
||||
expect(
|
||||
toCredentialOption({ id: 42, name: "Production", username: "root" }),
|
||||
).toEqual({ id: "42", name: "Production", username: "root" });
|
||||
});
|
||||
|
||||
it("rejects a malformed create response", () => {
|
||||
expect(toCredentialOption({ name: "Missing ID" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
HIDEABLE_RAIL_IDS,
|
||||
PROMOTABLE_IDS,
|
||||
RAIL_ITEMS,
|
||||
RAIL_UTILITY_ITEMS,
|
||||
RIGHT_DOCKABLE_IDS,
|
||||
railItemLabel,
|
||||
visibleRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { WORKSPACE_CAPTURABLE_TYPES } from "@/shell/workspaceUtils";
|
||||
import type { TabType } from "@/types/ui-types";
|
||||
import en from "@/locales/en.json";
|
||||
|
||||
function lookup(key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(acc, part) =>
|
||||
acc && typeof acc === "object"
|
||||
? (acc as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
en,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RAIL_ITEMS", () => {
|
||||
it("has no duplicate ids", () => {
|
||||
const ids = [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].map((item) => item.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("gives every destination a real translation key", () => {
|
||||
for (const item of [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]) {
|
||||
expect(
|
||||
typeof lookup(item.labelKey),
|
||||
`missing translation for ${item.id} (${item.labelKey})`,
|
||||
).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the destinations that shipped before the lists were merged", () => {
|
||||
// Guards against a destination silently disappearing now that the rail,
|
||||
// the settings toggles, the sidebar titles and the mobile bar all read
|
||||
// from this one list.
|
||||
expect(RAIL_ITEMS.map((item) => item.id)).toEqual([
|
||||
"hosts",
|
||||
"credentials",
|
||||
"termix-id",
|
||||
"connections",
|
||||
"quick-connect",
|
||||
"serial",
|
||||
"ssh-tools",
|
||||
"snippets",
|
||||
"macros",
|
||||
"fleets",
|
||||
"automations",
|
||||
"ai",
|
||||
"history",
|
||||
"session-logs",
|
||||
"split-screen",
|
||||
"workspaces",
|
||||
"local-terminal",
|
||||
"network_graph",
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes every rail item as hideable", () => {
|
||||
expect(HIDEABLE_RAIL_IDS).toEqual(RAIL_ITEMS.map((item) => item.id));
|
||||
});
|
||||
|
||||
it("marks exactly the four mobile primary slots", () => {
|
||||
expect(
|
||||
RAIL_ITEMS.filter((item) => item.mobilePrimary).map((item) => item.id),
|
||||
).toEqual(["hosts", "quick-connect", "ssh-tools", "snippets"]);
|
||||
});
|
||||
|
||||
it("marks the panels that can open as a tab", () => {
|
||||
expect(
|
||||
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
|
||||
.filter((item) => item.promotable)
|
||||
.map((item) => item.id),
|
||||
).toEqual([
|
||||
"termix-id",
|
||||
"ssh-tools",
|
||||
"snippets",
|
||||
"macros",
|
||||
"automations",
|
||||
"ai",
|
||||
"history",
|
||||
"session-logs",
|
||||
"alerts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives PROMOTABLE_IDS from the promotable flag", () => {
|
||||
// The header button and the hint both gate on this list, so a drift here
|
||||
// silently hides the feature for that panel.
|
||||
expect(PROMOTABLE_IDS).toEqual(
|
||||
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
|
||||
.filter((item) => item.promotable)
|
||||
.map((item) => item.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("every promotable id is also a captured workspace tab type", () => {
|
||||
// The id doubles as the TabType, so a promoted panel that isn't capturable
|
||||
// would silently vanish from saved workspaces.
|
||||
for (const item of [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]) {
|
||||
if (!item.promotable) continue;
|
||||
expect(
|
||||
WORKSPACE_CAPTURABLE_TYPES,
|
||||
`${item.id} is promotable but not workspace-capturable`,
|
||||
).toContain(item.id as TabType);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the mounted-but-hidden panels out of the right dock", () => {
|
||||
// Hosts, credentials and fleets stay mounted while hidden and share editing
|
||||
// state, so a second live instance in the right dock would fight the first.
|
||||
for (const id of ["hosts", "credentials", "fleets"]) {
|
||||
expect(
|
||||
RIGHT_DOCKABLE_IDS,
|
||||
`${id} must not be right-dockable`,
|
||||
).not.toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("only offers reference panels in the right dock", () => {
|
||||
expect(RIGHT_DOCKABLE_IDS).toEqual([
|
||||
"connections",
|
||||
"ssh-tools",
|
||||
"snippets",
|
||||
"macros",
|
||||
"ai",
|
||||
"history",
|
||||
"session-logs",
|
||||
"alerts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("railItemLabel", () => {
|
||||
it("translates known destinations", () => {
|
||||
expect(railItemLabel("hosts", () => "Hosts")).toBe("Hosts");
|
||||
});
|
||||
|
||||
it("falls back to the id for anything unknown", () => {
|
||||
expect(railItemLabel("not-a-view", (k) => k)).toBe("not-a-view");
|
||||
});
|
||||
|
||||
describe("electron-only items", () => {
|
||||
afterEach(() => {
|
||||
delete (window as { IS_ELECTRON?: boolean }).IS_ELECTRON;
|
||||
});
|
||||
|
||||
it("hides electron-only destinations in the browser build", () => {
|
||||
const ids = visibleRailItems().map((item) => item.id);
|
||||
expect(ids).not.toContain("local-terminal");
|
||||
});
|
||||
|
||||
it("shows electron-only destinations in the desktop app", () => {
|
||||
(window as { IS_ELECTRON?: boolean }).IS_ELECTRON = true;
|
||||
const ids = visibleRailItems().map((item) => item.id);
|
||||
expect(ids).toContain("local-terminal");
|
||||
});
|
||||
|
||||
it("keeps every non-electron item in both builds", () => {
|
||||
const browser = visibleRailItems().map((item) => item.id);
|
||||
(window as { IS_ELECTRON?: boolean }).IS_ELECTRON = true;
|
||||
const desktop = visibleRailItems().map((item) => item.id);
|
||||
const electronOnly = RAIL_ITEMS.filter((item) => item.electronOnly).map(
|
||||
(item) => item.id,
|
||||
);
|
||||
expect(desktop.filter((id) => !electronOnly.includes(id))).toEqual(
|
||||
browser,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeDropSortOrder,
|
||||
planReorder,
|
||||
renumberSiblings,
|
||||
type ReorderRow,
|
||||
} from "../../sidebar/reorder-utils";
|
||||
|
||||
describe("computeDropSortOrder", () => {
|
||||
it("returns the gap value when the list is empty", () => {
|
||||
expect(computeDropSortOrder(null, null)).toBe(1000);
|
||||
});
|
||||
|
||||
it("returns before + gap when dropped at the end", () => {
|
||||
expect(computeDropSortOrder({ sortOrder: 1000 }, null)).toBe(2000);
|
||||
});
|
||||
|
||||
it("returns after - gap when dropped at the start", () => {
|
||||
expect(computeDropSortOrder(null, { sortOrder: 1000 })).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the midpoint when dropped between two neighbors", () => {
|
||||
expect(computeDropSortOrder({ sortOrder: 1000 }, { sortOrder: 2000 })).toBe(
|
||||
1500,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when the gap between neighbors is exhausted", () => {
|
||||
expect(
|
||||
computeDropSortOrder({ sortOrder: 1000 }, { sortOrder: 1001 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a missing sortOrder on a neighbor as null", () => {
|
||||
expect(computeDropSortOrder({ sortOrder: null }, { sortOrder: 1000 })).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renumberSiblings", () => {
|
||||
it("assigns evenly-spaced sortOrder values in list order", () => {
|
||||
expect(renumberSiblings([{ id: "a" }, { id: "b" }, { id: "c" }])).toEqual([
|
||||
{ id: "a", sortOrder: 0 },
|
||||
{ id: "b", sortOrder: 1000 },
|
||||
{ id: "c", sortOrder: 2000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array for an empty list", () => {
|
||||
expect(renumberSiblings([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planReorder", () => {
|
||||
const rows: ReorderRow[] = [
|
||||
{ key: "host:1", parentKey: "folder:A", sortOrder: 0 },
|
||||
{ key: "host:2", parentKey: "folder:A", sortOrder: 1000 },
|
||||
{ key: "host:3", parentKey: "folder:A", sortOrder: 2000 },
|
||||
{ key: "host:9", parentKey: "folder:B", sortOrder: 0 },
|
||||
{ key: "host:10", parentKey: "folder:B", sortOrder: 1000 },
|
||||
];
|
||||
|
||||
it("returns null when dropped on itself", () => {
|
||||
expect(planReorder(rows, "host:1", "host:1", "before")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an unknown dragged or target row", () => {
|
||||
expect(planReorder(rows, "host:99", "host:1", "before")).toBeNull();
|
||||
expect(planReorder(rows, "host:1", "host:99", "before")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the drop lands on the slot it already occupies", () => {
|
||||
expect(planReorder(rows, "host:1", "host:2", "before")).toBeNull();
|
||||
expect(planReorder(rows, "host:2", "host:1", "after")).toBeNull();
|
||||
});
|
||||
|
||||
it("computes a midpoint within the same folder", () => {
|
||||
const plan = planReorder(rows, "host:1", "host:2", "after");
|
||||
expect(plan).toEqual({
|
||||
positions: [{ key: "host:1", sortOrder: 1500 }],
|
||||
movedTo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes neighbours to the target's folder, not every row", () => {
|
||||
// host:1 dropped after host:9 must compare against folder B's rows, and
|
||||
// a cross-folder drop renumbers the destination so the arriving row's
|
||||
// old-folder sortOrder can't decide its new position.
|
||||
const plan = planReorder(rows, "host:1", "host:9", "after");
|
||||
expect(plan).toEqual({
|
||||
positions: [
|
||||
{ key: "host:9", sortOrder: 0 },
|
||||
{ key: "host:1", sortOrder: 1000 },
|
||||
{ key: "host:10", sortOrder: 2000 },
|
||||
],
|
||||
movedTo: "folder:B",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the new parent when the drop crosses folders", () => {
|
||||
const plan = planReorder(rows, "host:10", "host:2", "before");
|
||||
expect(plan?.movedTo).toBe("folder:A");
|
||||
});
|
||||
|
||||
it("keeps movedTo null for a same-folder drop", () => {
|
||||
expect(planReorder(rows, "host:3", "host:1", "before")?.movedTo).toBeNull();
|
||||
});
|
||||
|
||||
it("drops at the end of the target folder", () => {
|
||||
const plan = planReorder(rows, "host:1", "host:3", "after");
|
||||
expect(plan?.positions).toEqual([{ key: "host:1", sortOrder: 3000 }]);
|
||||
});
|
||||
|
||||
it("renumbers the destination group when the gap is exhausted", () => {
|
||||
const tight: ReorderRow[] = [
|
||||
{ key: "host:1", parentKey: "folder:A", sortOrder: 1000 },
|
||||
{ key: "host:2", parentKey: "folder:A", sortOrder: 1001 },
|
||||
{ key: "host:3", parentKey: "folder:A", sortOrder: 5000 },
|
||||
];
|
||||
const plan = planReorder(tight, "host:3", "host:2", "before");
|
||||
expect(plan).toEqual({
|
||||
positions: [
|
||||
{ key: "host:1", sortOrder: 0 },
|
||||
{ key: "host:3", sortOrder: 1000 },
|
||||
{ key: "host:2", sortOrder: 2000 },
|
||||
],
|
||||
movedTo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("numbers the whole group when siblings have no sortOrder yet", () => {
|
||||
// Hosts start at null, and null sorts last under manual sort, so a lone
|
||||
// value for the moved row would say nothing about where it landed.
|
||||
const sparse: ReorderRow[] = [
|
||||
{ key: "host:1", parentKey: "folder:A" },
|
||||
{ key: "host:2", parentKey: "folder:A" },
|
||||
];
|
||||
expect(
|
||||
planReorder(sparse, "host:2", "host:1", "before")?.positions,
|
||||
).toEqual([
|
||||
{ key: "host:2", sortOrder: 0 },
|
||||
{ key: "host:1", sortOrder: 1000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("numbers the whole group when only some siblings are ordered", () => {
|
||||
const partial: ReorderRow[] = [
|
||||
{ key: "host:1", parentKey: "folder:A", sortOrder: 0 },
|
||||
{ key: "host:2", parentKey: "folder:A" },
|
||||
{ key: "host:3", parentKey: "folder:A", sortOrder: 1000 },
|
||||
];
|
||||
expect(
|
||||
planReorder(partial, "host:3", "host:1", "before")?.positions,
|
||||
).toEqual([
|
||||
{ key: "host:3", sortOrder: 0 },
|
||||
{ key: "host:1", sortOrder: 1000 },
|
||||
{ key: "host:2", sortOrder: 2000 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planReorder cross-folder placement", () => {
|
||||
it("numbers the destination so the drop lands where it was aimed", () => {
|
||||
// The arriving row carries sortOrder 5 from its old folder, which would
|
||||
// otherwise sort it to the front of the destination regardless of aim.
|
||||
const rows: ReorderRow[] = [
|
||||
{ key: "host:1", parentKey: "folder:A", sortOrder: 5 },
|
||||
{ key: "host:8", parentKey: "folder:B", sortOrder: 1000 },
|
||||
{ key: "host:9", parentKey: "folder:B", sortOrder: 2000 },
|
||||
];
|
||||
const plan = planReorder(rows, "host:1", "host:9", "before");
|
||||
expect(plan?.movedTo).toBe("folder:B");
|
||||
expect(plan?.positions).toEqual([
|
||||
{ key: "host:8", sortOrder: 0 },
|
||||
{ key: "host:1", sortOrder: 1000 },
|
||||
{ key: "host:9", sortOrder: 2000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still writes only the moved row for a same-folder drop", () => {
|
||||
const rows: ReorderRow[] = [
|
||||
{ key: "host:1", parentKey: "folder:A", sortOrder: 0 },
|
||||
{ key: "host:2", parentKey: "folder:A", sortOrder: 1000 },
|
||||
{ key: "host:3", parentKey: "folder:A", sortOrder: 2000 },
|
||||
];
|
||||
expect(planReorder(rows, "host:1", "host:2", "after")?.positions).toEqual([
|
||||
{ key: "host:1", sortOrder: 1500 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Host, HostFolder } from "@/types/ui-types";
|
||||
|
||||
// Mirror of SidebarTree collectVisibleRows for unit coverage without exporting
|
||||
// the full React module graph.
|
||||
function isFolder(item: Host | HostFolder): item is HostFolder {
|
||||
return "children" in item;
|
||||
}
|
||||
|
||||
function hostMatchesQuery(host: Host, query: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
host.name.toLowerCase().includes(q) ||
|
||||
host.ip.toLowerCase().includes(q) ||
|
||||
host.username.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
function folderHasMatch(folder: HostFolder, query: string): boolean {
|
||||
if (folder.name.toLowerCase().includes(query.toLowerCase())) return true;
|
||||
for (const child of folder.children) {
|
||||
if (isFolder(child)) {
|
||||
if (folderHasMatch(child, query)) return true;
|
||||
} else if (hostMatchesQuery(child, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type VirtualRow = { item: Host | HostFolder; depth: number };
|
||||
|
||||
function collectVisibleRows(
|
||||
children: (Host | HostFolder)[],
|
||||
query: string,
|
||||
openSet: Set<string>,
|
||||
out: VirtualRow[] = [],
|
||||
depth = 0,
|
||||
): VirtualRow[] {
|
||||
for (const child of children) {
|
||||
if (isFolder(child)) {
|
||||
const visible = query ? folderHasMatch(child, query) : true;
|
||||
if (!visible) continue;
|
||||
out.push({ item: child, depth });
|
||||
const childOpen = query ? true : openSet.has(child.path ?? child.name);
|
||||
if (childOpen)
|
||||
collectVisibleRows(child.children, query, openSet, out, depth + 1);
|
||||
} else {
|
||||
if (!query || hostMatchesQuery(child, query))
|
||||
out.push({ item: child, depth });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
import {
|
||||
isFolder,
|
||||
collectVisibleRows,
|
||||
buildReorderRows,
|
||||
rowKey,
|
||||
rowKind,
|
||||
ROOT_PARENT,
|
||||
} from "../../sidebar/tree/visible-rows";
|
||||
|
||||
function host(id: string, name: string): Host {
|
||||
return {
|
||||
@@ -127,3 +83,104 @@ describe("collectVisibleRows", () => {
|
||||
).toEqual(["prod", "db", "postgres"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectVisibleRows with sub-host nesting", () => {
|
||||
function hostWithChildren(
|
||||
id: string,
|
||||
name: string,
|
||||
childHosts?: Host[],
|
||||
): Host {
|
||||
return { ...host(id, name), childHosts } as Host;
|
||||
}
|
||||
|
||||
it("shows a parent host's children by default, unlike folders", () => {
|
||||
const tree: (Host | HostFolder)[] = [
|
||||
hostWithChildren("1", "Zeus", [host("2", "vm1")]),
|
||||
];
|
||||
const rows = collectVisibleRows(tree, "", new Set());
|
||||
expect(rows.map((r) => r.item.name)).toEqual(["Zeus", "vm1"]);
|
||||
expect(rows[1].depth).toBe(1);
|
||||
});
|
||||
|
||||
it("hides children once the parent is explicitly collapsed", () => {
|
||||
const tree: (Host | HostFolder)[] = [
|
||||
hostWithChildren("1", "Zeus", [host("2", "vm1")]),
|
||||
];
|
||||
const rows = collectVisibleRows(
|
||||
tree,
|
||||
"",
|
||||
new Set(),
|
||||
[],
|
||||
0,
|
||||
new Set(["host:1"]),
|
||||
);
|
||||
expect(rows.map((r) => r.item.name)).toEqual(["Zeus"]);
|
||||
});
|
||||
|
||||
it("always shows children while searching, regardless of collapsed state", () => {
|
||||
const tree: (Host | HostFolder)[] = [
|
||||
hostWithChildren("1", "Zeus", [host("2", "vm1")]),
|
||||
];
|
||||
const rows = collectVisibleRows(
|
||||
tree,
|
||||
"vm1",
|
||||
new Set(),
|
||||
[],
|
||||
0,
|
||||
new Set(["host:1"]),
|
||||
);
|
||||
expect(rows.map((r) => r.item.name)).toEqual(["Zeus", "vm1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReorderRows", () => {
|
||||
it("keys folders by path and hosts by id", () => {
|
||||
const folder: HostFolder = {
|
||||
name: "Web",
|
||||
path: "Prod / Web",
|
||||
children: [],
|
||||
};
|
||||
expect(rowKey(folder)).toBe("folder:Prod / Web");
|
||||
expect(rowKey(host("7", "box"))).toBe("host:7");
|
||||
});
|
||||
|
||||
it("reads the kind back off a key", () => {
|
||||
expect(rowKind("folder:Prod / Web")).toBe("folder");
|
||||
expect(rowKind("host:7")).toBe("host");
|
||||
});
|
||||
|
||||
it("parents a nested folder to its path prefix", () => {
|
||||
const nested: HostFolder = {
|
||||
name: "Web",
|
||||
path: "Prod / Web",
|
||||
children: [],
|
||||
sortOrder: 500,
|
||||
};
|
||||
expect(buildReorderRows([{ item: nested }])).toEqual([
|
||||
{ key: "folder:Prod / Web", parentKey: "folder:Prod", sortOrder: 500 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parents a top-level folder to the root sentinel", () => {
|
||||
const top: HostFolder = { name: "Prod", path: "Prod", children: [] };
|
||||
expect(buildReorderRows([{ item: top }])[0].parentKey).toBe(ROOT_PARENT);
|
||||
});
|
||||
|
||||
it("parents a host to its folder", () => {
|
||||
const h = { ...host("3", "db"), folder: "Prod / Web", sortOrder: 2000 };
|
||||
expect(buildReorderRows([{ item: h }])).toEqual([
|
||||
{ key: "host:3", parentKey: "folder:Prod / Web", sortOrder: 2000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parents a sub-host to its parent host, not its folder", () => {
|
||||
const child = { ...host("4", "child"), folder: "Prod", parentHostId: "3" };
|
||||
expect(buildReorderRows([{ item: child }])[0].parentKey).toBe("host:3");
|
||||
});
|
||||
|
||||
it("parents a folderless host to the root sentinel", () => {
|
||||
expect(buildReorderRows([{ item: host("5", "loose") }])[0].parentKey).toBe(
|
||||
ROOT_PARENT,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { LOCAL_ADAPTIVE_PREFERENCES_KEY } from "@/lib/local-adaptive-preferences";
|
||||
|
||||
const { markTabSurfaceUsedMock, preloadTabSurfaceMock } = vi.hoisted(() => ({
|
||||
markTabSurfaceUsedMock: vi.fn(),
|
||||
preloadTabSurfaceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/shell/tabUtils", () => ({
|
||||
markTabSurfaceUsed: markTabSurfaceUsedMock,
|
||||
preloadTabSurface: preloadTabSurfaceMock,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ServerStatusContext", () => ({
|
||||
useHostStatus: () => null,
|
||||
useServerStatus: () => ({
|
||||
getStatus: () => "online",
|
||||
initialLoadComplete: true,
|
||||
}),
|
||||
useServerStatusMeta: () => ({ initialLoadComplete: true, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-status-color-scheme", () => ({
|
||||
useStatusColorScheme: () => "accent",
|
||||
getStatusClasses: () => "",
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
getHostPassword: vi.fn(),
|
||||
wakeOnLan: vi.fn(),
|
||||
}));
|
||||
|
||||
import { HostItem } from "../../../../sidebar/tree/HostItem/HostItem";
|
||||
|
||||
const baseHost: Host = {
|
||||
id: "1",
|
||||
name: "web-01",
|
||||
username: "root",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
folder: "",
|
||||
online: true,
|
||||
cpu: 42,
|
||||
ram: 60,
|
||||
lastAccess: "",
|
||||
tags: ["prod", "web"],
|
||||
authType: "password",
|
||||
hasPassword: true,
|
||||
pin: true,
|
||||
enableSsh: true,
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: true,
|
||||
enableTunnel: true,
|
||||
enableFileManager: true,
|
||||
enableDocker: true,
|
||||
enableRdp: true,
|
||||
enableVnc: true,
|
||||
enableTelnet: true,
|
||||
macAddress: "aa:bb:cc:dd:ee:ff",
|
||||
quickActions: [],
|
||||
} as unknown as Host;
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function renderHostItem(
|
||||
density: "comfortable" | "compact",
|
||||
opts: { menuOpen?: boolean } = {},
|
||||
) {
|
||||
return render(
|
||||
<HostItem
|
||||
host={baseHost}
|
||||
onOpenTab={noop}
|
||||
onEditHost={noop}
|
||||
onShareHost={noop}
|
||||
onDelete={noop}
|
||||
onDuplicate={noop}
|
||||
density={density}
|
||||
isMenuOpen={opts.menuOpen ?? false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
markTabSurfaceUsedMock.mockClear();
|
||||
preloadTabSurfaceMock.mockClear();
|
||||
localStorage.removeItem(LOCAL_ADAPTIVE_PREFERENCES_KEY);
|
||||
});
|
||||
|
||||
describe("HostItem density parity", () => {
|
||||
it.each(["comfortable", "compact"] as const)(
|
||||
"exposes the pin indicator in %s density",
|
||||
(density) => {
|
||||
renderHostItem(density);
|
||||
expect(document.querySelector(".lucide-pin")).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["comfortable", "compact"] as const)(
|
||||
"exposes the Copy Link submenu trigger in %s density",
|
||||
(density) => {
|
||||
renderHostItem(density, { menuOpen: true });
|
||||
expect(screen.getByText("hosts.copyLink")).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["comfortable", "compact"] as const)(
|
||||
"exposes edit, share, and more-options actions in %s density",
|
||||
(density) => {
|
||||
renderHostItem(density);
|
||||
expect(screen.getByTitle("hosts.editHostAction")).toBeTruthy();
|
||||
expect(screen.getByTitle("hosts.shareHost")).toBeTruthy();
|
||||
expect(screen.getByTitle("hosts.moreOptions")).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["comfortable", "compact"] as const)(
|
||||
"exposes RDP, VNC, and Telnet quick-launch buttons in %s density",
|
||||
(density) => {
|
||||
renderHostItem(density);
|
||||
expect(screen.getByTitle("hosts.connectRdp")).toBeTruthy();
|
||||
expect(screen.getByTitle("hosts.connectVnc")).toBeTruthy();
|
||||
expect(screen.getByTitle("hosts.connectTelnet")).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["comfortable", "compact"] as const)(
|
||||
"exposes the wake-on-LAN button when a MAC address is set, in %s density",
|
||||
(density) => {
|
||||
renderHostItem(density);
|
||||
expect(screen.getByTitle("hosts.wakeOnLanAction")).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["comfortable", "compact"] as const)(
|
||||
"exposes the copy-password action in %s density",
|
||||
(density) => {
|
||||
renderHostItem(density);
|
||||
expect(screen.getByTitle("nav.copyPassword")).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows tags in both densities when showTags is true", () => {
|
||||
renderHostItem("comfortable");
|
||||
expect(screen.getByText("prod")).toBeTruthy();
|
||||
cleanup();
|
||||
renderHostItem("compact");
|
||||
expect(screen.getByText("prod")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides tags in both densities when showTags is false", () => {
|
||||
render(
|
||||
<HostItem
|
||||
host={baseHost}
|
||||
onOpenTab={noop}
|
||||
onEditHost={noop}
|
||||
onDelete={noop}
|
||||
onDuplicate={noop}
|
||||
density="comfortable"
|
||||
showTags={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("prod")).toBeNull();
|
||||
});
|
||||
|
||||
it("learns repeated local actions and preloads the preferred host tool", () => {
|
||||
renderHostItem("comfortable");
|
||||
const filesButton = screen.getByTitle("Files");
|
||||
fireEvent.click(filesButton);
|
||||
fireEvent.click(filesButton);
|
||||
fireEvent.click(filesButton);
|
||||
|
||||
const hostRow = screen.getByText("web-01").closest(".cursor-pointer");
|
||||
expect(hostRow).toBeTruthy();
|
||||
fireEvent.pointerEnter(hostRow!);
|
||||
|
||||
expect(preloadTabSurfaceMock).toHaveBeenCalledWith("terminal");
|
||||
expect(preloadTabSurfaceMock).toHaveBeenCalledWith("files");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getHostSidebarPreferences: vi.fn(),
|
||||
saveHostSidebarPreferences: vi.fn(async () => {}),
|
||||
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => api);
|
||||
|
||||
import { useHostSidebarPreferences } from "../../../../sidebar/tree/hooks/useHostSidebarPreferences";
|
||||
import { defaultHostSidebarPreferences } from "@/types/host-sidebar-preferences";
|
||||
|
||||
describe("useHostSidebarPreferences", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
api.getHostSidebarPreferences.mockReset();
|
||||
api.getHostSidebarPreferences.mockResolvedValue(
|
||||
defaultHostSidebarPreferences(),
|
||||
);
|
||||
api.saveHostSidebarPreferences.mockReset();
|
||||
api.saveHostSidebarPreferences.mockResolvedValue(undefined);
|
||||
api.getUserPreferences.mockReset();
|
||||
api.getUserPreferences.mockResolvedValue({ storageMode: "local" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts from defaults when there is no cache", () => {
|
||||
const { result } = renderHook(() => useHostSidebarPreferences());
|
||||
expect(result.current.preferences).toEqual(defaultHostSidebarPreferences());
|
||||
expect(result.current.loaded).toBe(false);
|
||||
});
|
||||
|
||||
it("starts from the cached value for instant paint", () => {
|
||||
const cached = {
|
||||
...defaultHostSidebarPreferences(),
|
||||
groupKey: "tag" as const,
|
||||
};
|
||||
localStorage.setItem("hostSidebarPreferences", JSON.stringify(cached));
|
||||
|
||||
const { result } = renderHook(() => useHostSidebarPreferences());
|
||||
expect(result.current.preferences.groupKey).toBe("tag");
|
||||
});
|
||||
|
||||
it("loads the authoritative copy from the server and marks loaded", async () => {
|
||||
const remote = {
|
||||
...defaultHostSidebarPreferences(),
|
||||
groupKey: "status" as const,
|
||||
};
|
||||
api.getHostSidebarPreferences.mockResolvedValue(remote);
|
||||
|
||||
const { result } = renderHook(() => useHostSidebarPreferences());
|
||||
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.preferences.groupKey).toBe("status");
|
||||
expect(JSON.parse(localStorage.getItem("hostSidebarPreferences")!)).toEqual(
|
||||
remote,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the cached value and still marks loaded when the fetch fails", async () => {
|
||||
api.getHostSidebarPreferences.mockRejectedValue(new Error("offline"));
|
||||
|
||||
const { result } = renderHook(() => useHostSidebarPreferences());
|
||||
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.preferences).toEqual(defaultHostSidebarPreferences());
|
||||
});
|
||||
|
||||
it("writes updates to the cache immediately", async () => {
|
||||
const { result } = renderHook(() => useHostSidebarPreferences());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.update({ groupKey: "protocol" });
|
||||
});
|
||||
|
||||
expect(result.current.preferences.groupKey).toBe("protocol");
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("hostSidebarPreferences")!).groupKey,
|
||||
).toBe("protocol");
|
||||
});
|
||||
|
||||
it("debounces the save and only pushes to the server in cloud storage mode", async () => {
|
||||
api.getUserPreferences.mockResolvedValue({ storageMode: "cloud" });
|
||||
const { result } = renderHook(() => useHostSidebarPreferences());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.update({ groupKey: "auth" });
|
||||
});
|
||||
expect(api.saveHostSidebarPreferences).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.saveHostSidebarPreferences).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ groupKey: "auth" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not push to the server in local storage mode", async () => {
|
||||
const { result } = renderHook(() => useHostSidebarPreferences());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.update({ groupKey: "auth" });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(api.getUserPreferences).toHaveBeenCalled());
|
||||
expect(api.saveHostSidebarPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs an update made by one hook instance into a second mounted instance", async () => {
|
||||
const first = renderHook(() => useHostSidebarPreferences());
|
||||
const second = renderHook(() => useHostSidebarPreferences());
|
||||
await waitFor(() => expect(first.result.current.loaded).toBe(true));
|
||||
await waitFor(() => expect(second.result.current.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
first.result.current.update({ groupKey: "protocol" });
|
||||
});
|
||||
|
||||
expect(first.result.current.preferences.groupKey).toBe("protocol");
|
||||
await waitFor(() =>
|
||||
expect(second.result.current.preferences.groupKey).toBe("protocol"),
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes patches through the shared sanitizer", async () => {
|
||||
const { result } = renderHook(() => useHostSidebarPreferences());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.update({
|
||||
groupKey: "not-a-real-key" as unknown as "folder",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.preferences.groupKey).toBe("folder");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CREDENTIAL_SIDEBAR_PREFS_VERSION,
|
||||
defaultCredentialSidebarPreferences,
|
||||
sanitizeCredentialSidebarPreferences,
|
||||
} from "@/types/credential-sidebar-preferences";
|
||||
|
||||
describe("defaultCredentialSidebarPreferences", () => {
|
||||
it("returns a fully populated default shape", () => {
|
||||
const defaults = defaultCredentialSidebarPreferences();
|
||||
expect(defaults).toEqual({
|
||||
version: CREDENTIAL_SIDEBAR_PREFS_VERSION,
|
||||
sort: { key: "default", pinnedFirst: false },
|
||||
filters: {
|
||||
type: [],
|
||||
tags: [],
|
||||
},
|
||||
openFolders: [],
|
||||
display: {
|
||||
density: "comfortable",
|
||||
showTags: true,
|
||||
trayTrigger: "always",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeCredentialSidebarPreferences", () => {
|
||||
it("returns defaults for non-object input", () => {
|
||||
expect(sanitizeCredentialSidebarPreferences(null)).toEqual(
|
||||
defaultCredentialSidebarPreferences(),
|
||||
);
|
||||
expect(sanitizeCredentialSidebarPreferences(undefined)).toEqual(
|
||||
defaultCredentialSidebarPreferences(),
|
||||
);
|
||||
expect(sanitizeCredentialSidebarPreferences("nope")).toEqual(
|
||||
defaultCredentialSidebarPreferences(),
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips a fully valid preferences object", () => {
|
||||
const valid = {
|
||||
version: 1,
|
||||
sort: { key: "manual", pinnedFirst: true },
|
||||
filters: {
|
||||
type: ["key"],
|
||||
tags: ["prod"],
|
||||
},
|
||||
openFolders: ["Production", "Staging"],
|
||||
display: {
|
||||
density: "compact",
|
||||
showTags: false,
|
||||
trayTrigger: "click",
|
||||
},
|
||||
};
|
||||
expect(sanitizeCredentialSidebarPreferences(valid)).toEqual(valid);
|
||||
});
|
||||
|
||||
it("falls back to defaults for invalid enum values", () => {
|
||||
const result = sanitizeCredentialSidebarPreferences({
|
||||
sort: { key: "not-a-real-key", pinnedFirst: "yes" },
|
||||
display: { density: "huge", trayTrigger: "double-click" },
|
||||
});
|
||||
expect(result.sort).toEqual({ key: "default", pinnedFirst: false });
|
||||
expect(result.display.density).toBe("comfortable");
|
||||
expect(result.display.trayTrigger).toBe("always");
|
||||
});
|
||||
|
||||
it("filters out invalid entries from filter arrays instead of rejecting them", () => {
|
||||
const result = sanitizeCredentialSidebarPreferences({
|
||||
filters: {
|
||||
type: ["key", "bogus"],
|
||||
tags: ["prod", 42, null],
|
||||
},
|
||||
});
|
||||
expect(result.filters.type).toEqual(["key"]);
|
||||
expect(result.filters.tags).toEqual(["prod"]);
|
||||
});
|
||||
|
||||
it("drops non-string entries from openFolders", () => {
|
||||
const result = sanitizeCredentialSidebarPreferences({
|
||||
openFolders: ["Production", 123, null, "Staging"],
|
||||
});
|
||||
expect(result.openFolders).toEqual(["Production", "Staging"]);
|
||||
});
|
||||
|
||||
it("always stamps the current version regardless of input", () => {
|
||||
const result = sanitizeCredentialSidebarPreferences({ version: 999 });
|
||||
expect(result.version).toBe(CREDENTIAL_SIDEBAR_PREFS_VERSION);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HOST_SIDEBAR_PREFS_VERSION,
|
||||
defaultHostSidebarPreferences,
|
||||
sanitizeHostSidebarPreferences,
|
||||
} from "@/types/host-sidebar-preferences";
|
||||
|
||||
describe("defaultHostSidebarPreferences", () => {
|
||||
it("returns a fully populated default shape", () => {
|
||||
const defaults = defaultHostSidebarPreferences();
|
||||
expect(defaults).toEqual({
|
||||
version: HOST_SIDEBAR_PREFS_VERSION,
|
||||
sort: { key: "default", pinnedFirst: false },
|
||||
groupKey: "folder",
|
||||
filters: {
|
||||
status: [],
|
||||
authType: [],
|
||||
protocol: [],
|
||||
features: [],
|
||||
tags: [],
|
||||
},
|
||||
openFolders: [],
|
||||
display: {
|
||||
density: "comfortable",
|
||||
showTags: true,
|
||||
trayTrigger: "always",
|
||||
statusColorScheme: "accent",
|
||||
openOnDoubleClick: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeHostSidebarPreferences", () => {
|
||||
it("returns defaults for non-object input", () => {
|
||||
expect(sanitizeHostSidebarPreferences(null)).toEqual(
|
||||
defaultHostSidebarPreferences(),
|
||||
);
|
||||
expect(sanitizeHostSidebarPreferences(undefined)).toEqual(
|
||||
defaultHostSidebarPreferences(),
|
||||
);
|
||||
expect(sanitizeHostSidebarPreferences("nope")).toEqual(
|
||||
defaultHostSidebarPreferences(),
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips a fully valid preferences object", () => {
|
||||
const valid = {
|
||||
version: 1,
|
||||
sort: { key: "manual", pinnedFirst: true },
|
||||
groupKey: "tag",
|
||||
filters: {
|
||||
status: ["online"],
|
||||
authType: ["key"],
|
||||
protocol: ["ssh"],
|
||||
features: ["terminal"],
|
||||
tags: ["prod"],
|
||||
},
|
||||
openFolders: ["Production", "Production / Web"],
|
||||
display: {
|
||||
density: "compact",
|
||||
showTags: false,
|
||||
trayTrigger: "click",
|
||||
statusColorScheme: "status",
|
||||
openOnDoubleClick: true,
|
||||
},
|
||||
};
|
||||
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
|
||||
});
|
||||
|
||||
it("defaults openOnDoubleClick to false when missing or not a boolean", () => {
|
||||
expect(
|
||||
sanitizeHostSidebarPreferences({ display: {} }).display.openOnDoubleClick,
|
||||
).toBe(false);
|
||||
expect(
|
||||
sanitizeHostSidebarPreferences({ display: { openOnDoubleClick: "yes" } })
|
||||
.display.openOnDoubleClick,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to defaults for invalid enum values", () => {
|
||||
const result = sanitizeHostSidebarPreferences({
|
||||
sort: { key: "not-a-real-key", pinnedFirst: "yes" },
|
||||
groupKey: "bogus",
|
||||
display: { density: "huge", trayTrigger: "double-click" },
|
||||
});
|
||||
expect(result.sort).toEqual({ key: "default", pinnedFirst: false });
|
||||
expect(result.groupKey).toBe("folder");
|
||||
expect(result.display.density).toBe("comfortable");
|
||||
expect(result.display.trayTrigger).toBe("always");
|
||||
});
|
||||
|
||||
it("filters out invalid entries from filter arrays instead of rejecting them", () => {
|
||||
const result = sanitizeHostSidebarPreferences({
|
||||
filters: {
|
||||
status: ["online", "bogus", "pinned"],
|
||||
authType: ["key", "nope"],
|
||||
tags: ["prod", 42, null],
|
||||
},
|
||||
});
|
||||
expect(result.filters.status).toEqual(["online", "pinned"]);
|
||||
expect(result.filters.authType).toEqual(["key"]);
|
||||
expect(result.filters.tags).toEqual(["prod"]);
|
||||
});
|
||||
|
||||
it("drops non-string entries from openFolders", () => {
|
||||
const result = sanitizeHostSidebarPreferences({
|
||||
openFolders: ["Production", 123, null, "Staging"],
|
||||
});
|
||||
expect(result.openFolders).toEqual(["Production", "Staging"]);
|
||||
});
|
||||
|
||||
it("always stamps the current version regardless of input", () => {
|
||||
const result = sanitizeHostSidebarPreferences({ version: 999 });
|
||||
expect(result.version).toBe(HOST_SIDEBAR_PREFS_VERSION);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultUiPreferences,
|
||||
hasUiOverrides,
|
||||
PRESETS,
|
||||
resolveArea,
|
||||
sanitizeUiOverrides,
|
||||
sanitizeUiPreferences,
|
||||
UI_PREFERENCES_VERSION,
|
||||
} from "@/types/ui-preferences";
|
||||
|
||||
describe("defaultUiPreferences", () => {
|
||||
it("starts every user on balanced with no overrides", () => {
|
||||
const defaults = defaultUiPreferences();
|
||||
expect(defaults.preset).toBe("balanced");
|
||||
expect(defaults.overrides).toEqual({});
|
||||
expect(defaults.onboarding).toEqual({
|
||||
completedVersion: 0,
|
||||
completedAt: null,
|
||||
skipped: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeUiPreferences", () => {
|
||||
it("returns defaults for non-object input", () => {
|
||||
expect(sanitizeUiPreferences(null)).toEqual(defaultUiPreferences());
|
||||
expect(sanitizeUiPreferences(undefined)).toEqual(defaultUiPreferences());
|
||||
expect(sanitizeUiPreferences("nope")).toEqual(defaultUiPreferences());
|
||||
});
|
||||
|
||||
it("round-trips a fully valid preferences object", () => {
|
||||
const valid = {
|
||||
version: UI_PREFERENCES_VERSION,
|
||||
preset: "simple" as const,
|
||||
overrides: { hostList: { density: "compact" as const, showTags: true } },
|
||||
onboarding: {
|
||||
completedVersion: 1,
|
||||
completedAt: "2026-01-01T00:00:00.000Z",
|
||||
skipped: false,
|
||||
},
|
||||
};
|
||||
expect(sanitizeUiPreferences(valid)).toEqual(valid);
|
||||
});
|
||||
|
||||
it("falls back to the default preset for an unknown value", () => {
|
||||
expect(sanitizeUiPreferences({ preset: "ultra" }).preset).toBe("balanced");
|
||||
});
|
||||
|
||||
it("always stamps the current version regardless of input", () => {
|
||||
expect(sanitizeUiPreferences({ version: 999 }).version).toBe(
|
||||
UI_PREFERENCES_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a malformed onboarding block", () => {
|
||||
expect(sanitizeUiPreferences({ onboarding: "done" }).onboarding).toEqual({
|
||||
completedVersion: 0,
|
||||
completedAt: null,
|
||||
skipped: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeUiOverrides", () => {
|
||||
it("drops unknown areas and unknown keys", () => {
|
||||
const result = sanitizeUiOverrides({
|
||||
notAnArea: { density: "compact" },
|
||||
hostList: { density: "compact", notAKnob: 42 },
|
||||
});
|
||||
expect(result).toEqual({ hostList: { density: "compact" } });
|
||||
});
|
||||
|
||||
it("drops invalid values but keeps the valid siblings", () => {
|
||||
const result = sanitizeUiOverrides({
|
||||
hostList: {
|
||||
density: "enormous",
|
||||
showTags: "yes",
|
||||
showResourceBars: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ hostList: { showResourceBars: true } });
|
||||
});
|
||||
|
||||
it("prunes areas that end up empty so override checks stay honest", () => {
|
||||
const result = sanitizeUiOverrides({
|
||||
hostList: { density: "enormous" },
|
||||
terminal: {},
|
||||
});
|
||||
expect(result).toEqual({});
|
||||
expect(
|
||||
hasUiOverrides({ ...defaultUiPreferences(), overrides: result }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("filters non-strings out of string arrays instead of rejecting them", () => {
|
||||
const result = sanitizeUiOverrides({
|
||||
rail: { hiddenTabs: ["serial", 7, null, "history"] },
|
||||
});
|
||||
expect(result.rail?.hiddenTabs).toEqual(["serial", "history"]);
|
||||
});
|
||||
|
||||
it("clamps and rounds integer knobs, dropping out-of-range values", () => {
|
||||
expect(sanitizeUiOverrides({ hostMetrics: { columns: 2.4 } })).toEqual({
|
||||
hostMetrics: { columns: 2 },
|
||||
});
|
||||
expect(sanitizeUiOverrides({ hostMetrics: { columns: 9 } })).toEqual({});
|
||||
expect(sanitizeUiOverrides({ hostMetrics: { columns: 0 } })).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps an explicit null for nullable array knobs", () => {
|
||||
expect(sanitizeUiOverrides({ homepage: { enabledWidgets: null } })).toEqual(
|
||||
{
|
||||
homepage: { enabledWidgets: null },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveArea", () => {
|
||||
it("returns preset values when there are no overrides", () => {
|
||||
const prefs = { ...defaultUiPreferences(), preset: "simple" as const };
|
||||
expect(resolveArea(prefs, "hostList")).toEqual(PRESETS.simple.hostList);
|
||||
});
|
||||
|
||||
it("layers overrides on top of the preset", () => {
|
||||
const prefs = {
|
||||
...defaultUiPreferences(),
|
||||
preset: "simple" as const,
|
||||
overrides: { hostList: { showTags: true } },
|
||||
};
|
||||
const resolved = resolveArea(prefs, "hostList");
|
||||
expect(resolved.showTags).toBe(true);
|
||||
expect(resolved.trayTrigger).toBe(PRESETS.simple.hostList.trayTrigger);
|
||||
});
|
||||
|
||||
it("re-bases custom on balanced", () => {
|
||||
const prefs = {
|
||||
...defaultUiPreferences(),
|
||||
preset: "custom" as const,
|
||||
overrides: { hostList: { density: "compact" as const } },
|
||||
};
|
||||
const resolved = resolveArea(prefs, "hostList");
|
||||
expect(resolved.density).toBe("compact");
|
||||
expect(resolved.trayTrigger).toBe(PRESETS.balanced.hostList.trayTrigger);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PRESETS.balanced", () => {
|
||||
// Balanced is the compatibility contract: it must equal the behavior that
|
||||
// shipped before presets existed, or every existing user sees a changed UI.
|
||||
it("matches the pre-preset defaults", () => {
|
||||
expect(PRESETS.balanced.hostList).toEqual({
|
||||
density: "comfortable",
|
||||
showTags: true,
|
||||
showResourceBars: true,
|
||||
showStatusStripes: true,
|
||||
trayTrigger: "always",
|
||||
rowActions: "full",
|
||||
});
|
||||
expect(PRESETS.balanced.rail.hiddenTabs).toEqual([]);
|
||||
expect(PRESETS.balanced.terminal.toolbarDensity).toBe("labeled");
|
||||
expect(PRESETS.balanced.fileManager.viewMode).toBe("grid");
|
||||
expect(PRESETS.balanced.docker.viewMode).toBe("list");
|
||||
expect(PRESETS.balanced.hostEditor.mode).toBe("full");
|
||||
expect(PRESETS.balanced.homepage.enabledWidgets).toBeNull();
|
||||
});
|
||||
|
||||
it("never lets a preset drive the homepage canvas", () => {
|
||||
expect(PRESETS.simple.homepage.enabledWidgets).toBeNull();
|
||||
expect(PRESETS.advanced.homepage.enabledWidgets).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves the wide dashboard cards off by default in every preset", () => {
|
||||
// network_graph and homepage_preview overflow the dashboard width when
|
||||
// enabled up front. They stay in the Add card tray instead.
|
||||
for (const preset of ["simple", "balanced", "advanced"] as const) {
|
||||
expect(PRESETS[preset].dashboard.enabledCards).not.toContain(
|
||||
"network_graph",
|
||||
);
|
||||
expect(PRESETS[preset].dashboard.enabledCards).not.toContain(
|
||||
"homepage_preview",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
|
||||
const mainAxios = vi.hoisted(() => ({
|
||||
checkElectronUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios.ts", () => mainAxios);
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts ? `${key}:${JSON.stringify(opts)}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The whole component is a no-op outside Electron, so every test here needs the
|
||||
// desktop build.
|
||||
vi.mock("@/lib/electron", () => ({ isElectron: () => true }));
|
||||
|
||||
import { ElectronVersionCheck } from "../../user/ElectronVersionCheck";
|
||||
|
||||
const DISMISS_KEY = "electron-version-check-dismissed";
|
||||
|
||||
type ElectronTestWindow = Window &
|
||||
typeof globalThis & {
|
||||
electronAPI?: { getAppVersion?: () => Promise<string | undefined> };
|
||||
};
|
||||
|
||||
function updateAvailable(localVersion: string, remoteVersion: string) {
|
||||
return {
|
||||
success: true,
|
||||
status: "requires_update" as const,
|
||||
localVersion,
|
||||
remoteVersion,
|
||||
latest_release: {
|
||||
tag_name: `v${remoteVersion}`,
|
||||
name: `Termix ${remoteVersion}`,
|
||||
published_at: "2026-07-01T00:00:00Z",
|
||||
html_url: `https://github.com/Termix-SSH/Termix/releases/tag/v${remoteVersion}`,
|
||||
body: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function upToDate(version: string) {
|
||||
return {
|
||||
success: true,
|
||||
status: "up_to_date" as const,
|
||||
localVersion: version,
|
||||
remoteVersion: version,
|
||||
};
|
||||
}
|
||||
|
||||
// Renders and waits until the check has resolved and the modal body is on
|
||||
// screen, so that a later `not.toHaveBeenCalled()` is not vacuously true.
|
||||
async function renderAndSettle(onContinue: () => void) {
|
||||
render(<ElectronVersionCheck onContinue={onContinue} />);
|
||||
await waitFor(() => {
|
||||
expect(mainAxios.checkElectronUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.continue")).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mainAxios.checkElectronUpdate.mockReset();
|
||||
(window as ElectronTestWindow).electronAPI = {
|
||||
getAppVersion: vi.fn(async () => "2.6.0"),
|
||||
} as unknown as ElectronTestWindow["electronAPI"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
delete (window as ElectronTestWindow).electronAPI;
|
||||
});
|
||||
|
||||
describe("ElectronVersionCheck - dismissal is keyed on the offered version", () => {
|
||||
it("still prompts when the stored dismissal is the local version and a newer release exists", async () => {
|
||||
// Regression: the dismissal used to store the LOCAL version, and the
|
||||
// up-to-date branch wrote it with no user action at all. A user who ran
|
||||
// 2.6.0 while it was current had "2.6.0" stored, so once 2.6.1 shipped the
|
||||
// modal was skipped on every launch - suppressed for exactly the users who
|
||||
// had not updated yet.
|
||||
localStorage.setItem(DISMISS_KEY, "2.6.0");
|
||||
mainAxios.checkElectronUpdate.mockResolvedValue(
|
||||
updateAvailable("2.6.0", "2.6.1"),
|
||||
);
|
||||
const onContinue = vi.fn();
|
||||
|
||||
await renderAndSettle(onContinue);
|
||||
|
||||
expect(onContinue).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("versionCheck.updateRequired")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stores the remote version when the user dismisses the prompt", async () => {
|
||||
mainAxios.checkElectronUpdate.mockResolvedValue(
|
||||
updateAvailable("2.6.0", "2.6.1"),
|
||||
);
|
||||
const onContinue = vi.fn();
|
||||
|
||||
await renderAndSettle(onContinue);
|
||||
fireEvent.click(screen.getByText("common.continue"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.1");
|
||||
});
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips the prompt on a later launch offering the same remote version", async () => {
|
||||
localStorage.setItem(DISMISS_KEY, "2.6.1");
|
||||
mainAxios.checkElectronUpdate.mockResolvedValue(
|
||||
updateAvailable("2.6.0", "2.6.1"),
|
||||
);
|
||||
const onContinue = vi.fn();
|
||||
|
||||
render(<ElectronVersionCheck onContinue={onContinue} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("prompts again once a newer release than the dismissed one appears", async () => {
|
||||
localStorage.setItem(DISMISS_KEY, "2.6.1");
|
||||
mainAxios.checkElectronUpdate.mockResolvedValue(
|
||||
updateAvailable("2.6.0", "2.7.0"),
|
||||
);
|
||||
const onContinue = vi.fn();
|
||||
|
||||
await renderAndSettle(onContinue);
|
||||
|
||||
expect(onContinue).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("versionCheck.updateRequired")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keys a dismissed beta prompt on the remote version too", async () => {
|
||||
// Local ahead of the published release: dismissing must still record what
|
||||
// was offered, so the next release re-prompts.
|
||||
mainAxios.checkElectronUpdate.mockResolvedValue({
|
||||
success: true,
|
||||
status: "beta" as const,
|
||||
localVersion: "2.7.0-beta",
|
||||
remoteVersion: "2.6.1",
|
||||
});
|
||||
const onContinue = vi.fn();
|
||||
|
||||
await renderAndSettle(onContinue);
|
||||
// Both the modal title and the alert heading use this key.
|
||||
expect(screen.getAllByText("versionCheck.betaVersion").length).toBe(2);
|
||||
fireEvent.click(screen.getByText("common.continue"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.1");
|
||||
});
|
||||
});
|
||||
|
||||
it("continues without prompting when up to date, recording the offered version", async () => {
|
||||
mainAxios.checkElectronUpdate.mockResolvedValue(upToDate("2.6.1"));
|
||||
const onContinue = vi.fn();
|
||||
|
||||
render(<ElectronVersionCheck onContinue={onContinue} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.1");
|
||||
});
|
||||
|
||||
it("does not record a dismissal when the check itself fails", async () => {
|
||||
mainAxios.checkElectronUpdate.mockResolvedValue({
|
||||
success: false,
|
||||
error: "Check failed",
|
||||
});
|
||||
const onContinue = vi.fn();
|
||||
|
||||
await renderAndSettle(onContinue);
|
||||
|
||||
// No offered version exists, so nothing may be written automatically and
|
||||
// nothing may be suppressed.
|
||||
expect(localStorage.getItem(DISMISS_KEY)).toBeNull();
|
||||
expect(onContinue).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByText("common.continue"));
|
||||
|
||||
// Dismissing falls back to the local version, which cannot match a later
|
||||
// release and so cannot suppress its prompt.
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.0");
|
||||
});
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user