mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
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-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
dependency-version: 0.5.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
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): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [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.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
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>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapAlertFiring } from "../../api/alerts-api";
|
||||
|
||||
describe("mapAlertFiring", () => {
|
||||
it("normalizes sqlite snake_case rows for the alerts UI", () => {
|
||||
expect(
|
||||
mapAlertFiring({
|
||||
id: 9,
|
||||
user_id: "u1",
|
||||
rule_id: 4,
|
||||
host_id: 12,
|
||||
host_name: "db-01",
|
||||
fired_at: "2026-06-30 12:00:00",
|
||||
resolved_at: null,
|
||||
value: 91,
|
||||
message: "CPU threshold exceeded",
|
||||
severity: "critical",
|
||||
acknowledged: 0,
|
||||
rule_name: "CPU",
|
||||
}),
|
||||
).toEqual({
|
||||
id: 9,
|
||||
userId: "u1",
|
||||
ruleId: 4,
|
||||
hostId: 12,
|
||||
hostName: "db-01",
|
||||
firedAt: "2026-06-30 12:00:00",
|
||||
resolvedAt: null,
|
||||
value: 91,
|
||||
message: "CPU threshold exceeded",
|
||||
severity: "critical",
|
||||
acknowledged: false,
|
||||
ruleName: "CPU",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses safe defaults for malformed rows", () => {
|
||||
const firing = mapAlertFiring({ acknowledged: 1, severity: "bad" });
|
||||
|
||||
expect(firing.id).toBe(0);
|
||||
expect(firing.hostName).toBe("");
|
||||
expect(firing.severity).toBe("warning");
|
||||
expect(firing.acknowledged).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const authApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ data: { guacd: { status: "disconnected" } } })),
|
||||
post: vi.fn(async () => ({ data: { token: "local-token" } })),
|
||||
}));
|
||||
const remoteApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ data: { guacd: { status: "connected" } } })),
|
||||
post: vi.fn(async () => ({ data: { token: "remote-token" } })),
|
||||
}));
|
||||
const isElectronMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const resolveRemoteHostIdMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: authApiMock,
|
||||
getRemoteGuacamoleApi: () => remoteApiMock,
|
||||
isElectron: isElectronMock,
|
||||
handleApiError: (error: unknown) => error,
|
||||
}));
|
||||
vi.mock("@/lib/remote-server-api", () => ({
|
||||
resolveRemoteHostId: resolveRemoteHostIdMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getGuacdStatus,
|
||||
getGuacamoleConnectionId,
|
||||
getGuacamoleTokenFromHost,
|
||||
} from "../../api/guacamole-api";
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
authApiMock.post.mockClear();
|
||||
remoteApiMock.get.mockClear();
|
||||
remoteApiMock.post.mockClear();
|
||||
resolveRemoteHostIdMock.mockReset();
|
||||
});
|
||||
|
||||
describe("guacamole API origin", () => {
|
||||
it("uses the shared instance in the browser", async () => {
|
||||
isElectronMock.mockReturnValue(false);
|
||||
|
||||
await getGuacdStatus("remote");
|
||||
await getGuacamoleTokenFromHost(9, "remote", "vnc");
|
||||
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
||||
expect(authApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the connected remote server in the desktop app", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
// The embedded backend has no guacd, so asking it reports "disconnected"
|
||||
// even when the connected server can serve the session.
|
||||
const status = await getGuacdStatus("remote");
|
||||
const token = await getGuacamoleTokenFromHost(9, "remote", "vnc");
|
||||
|
||||
expect(status.guacd.status).toBe("connected");
|
||||
expect(token.token).toBe("remote-token");
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
||||
expect(remoteApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||
expect(authApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the connect-host payload unchanged to the remote server", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
resolveRemoteHostIdMock.mockResolvedValue(41);
|
||||
|
||||
await getGuacamoleTokenFromHost(
|
||||
9,
|
||||
"remote",
|
||||
"rdp",
|
||||
{
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
domain: "EXAMPLE",
|
||||
},
|
||||
"host-sync-id",
|
||||
);
|
||||
|
||||
expect(remoteApiMock.post).toHaveBeenCalledWith(
|
||||
"/guacamole/connect-host/41",
|
||||
{
|
||||
protocol: "rdp",
|
||||
promptedUsername: "admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "EXAMPLE",
|
||||
},
|
||||
);
|
||||
expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("host-sync-id");
|
||||
});
|
||||
|
||||
it("sends an empty prompted domain for local RDP accounts", async () => {
|
||||
isElectronMock.mockReturnValue(false);
|
||||
|
||||
await getGuacamoleTokenFromHost(9, "local", "rdp", {
|
||||
username: "local-admin",
|
||||
password: "secret",
|
||||
domain: "",
|
||||
});
|
||||
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
|
||||
protocol: "rdp",
|
||||
promptedUsername: "local-admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "",
|
||||
});
|
||||
});
|
||||
|
||||
// Support#1240: a host set to "This Device" is served by the embedded
|
||||
// backend, so its token has to come from there too -- and its id must not
|
||||
// be remapped onto a remote server that may not even be configured.
|
||||
it("asks the embedded backend when the host originates locally", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
|
||||
|
||||
expect(token.token).toBe("local-token");
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports the embedded backend's own guacd for a local origin", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
const status = await getGuacdStatus("local");
|
||||
|
||||
expect(status.guacd.status).toBe("disconnected");
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the remote server for an explicitly remote origin", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
|
||||
await getGuacamoleTokenFromHost(9, "remote", "rdp");
|
||||
|
||||
expect(remoteApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(authApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to a colliding local id when sync resolution fails", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
resolveRemoteHostIdMock.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
getGuacamoleTokenFromHost(
|
||||
9,
|
||||
"remote",
|
||||
"rdp",
|
||||
undefined,
|
||||
"missing-sync-id",
|
||||
),
|
||||
).rejects.toThrow("The synced host does not exist on the remote server");
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("looks up the session on the backend that issued its token", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
const signal = new AbortController().signal;
|
||||
await getGuacamoleConnectionId("connect/id", "remote", signal);
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith(
|
||||
"/guacamole/connection/connect%2Fid",
|
||||
{ signal },
|
||||
);
|
||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const statsApiMock = vi.hoisted(() => ({ get: vi.fn(), post: 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,
|
||||
sendMetricsHeartbeat,
|
||||
} 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("metrics viewer heartbeat", () => {
|
||||
it("reports a swept viewer session without throwing or logging", async () => {
|
||||
statsApiMock.post.mockResolvedValueOnce({ status: 404 });
|
||||
|
||||
await expect(sendMetricsHeartbeat("expired-viewer")).resolves.toBe(false);
|
||||
expect(statsApiMock.post).toHaveBeenCalledWith(
|
||||
"/metrics/heartbeat",
|
||||
{ viewerSessionId: "expired-viewer" },
|
||||
{ validateStatus: expect.any(Function) },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a live viewer session", async () => {
|
||||
statsApiMock.post.mockResolvedValueOnce({ status: 200 });
|
||||
await expect(sendMetricsHeartbeat("live-viewer")).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const remoteCoreApiMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const sshHostApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
}));
|
||||
const resolveConnectionOriginMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
sshHostApi: sshHostApiMock,
|
||||
getRemoteCoreApi: () => remoteCoreApiMock,
|
||||
isElectron: () => true,
|
||||
handleApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/hosts-request-cache", () => ({
|
||||
getCachedServerStatuses: (loader: () => Promise<unknown>) => loader(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/connection-origin", () => ({
|
||||
resolveConnectionOrigin: resolveConnectionOriginMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/linked-server", () => ({
|
||||
getLinkedSession: async () => ({
|
||||
serverUrl: "https://termix.example.test",
|
||||
token: "t",
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
getAllServerStatuses,
|
||||
refreshServerPolling,
|
||||
updateStatusCheckSettings,
|
||||
} from "@/api/host-status-api";
|
||||
|
||||
/** The host list for "/db/host", core's statuses for "/status". */
|
||||
function hostsAndStatuses(hosts: unknown[]) {
|
||||
sshHostApiMock.get.mockImplementation(async (path: string) =>
|
||||
path === "/status"
|
||||
? { data: { 1: { status: "online" } } }
|
||||
: { data: hosts },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// The server keys statuses by sync id; its own ids mean nothing here.
|
||||
remoteCoreApiMock.get.mockResolvedValue({
|
||||
data: { "sync-2": { status: "reachable" }, "sync-9": { status: "x" } },
|
||||
});
|
||||
});
|
||||
|
||||
describe("status check origin routing", () => {
|
||||
it("only asks the embedded backend to poll local-origin hosts", async () => {
|
||||
hostsAndStatuses([
|
||||
{ id: 1, connectionOrigin: "local", syncId: "sync-1" },
|
||||
{ id: 2, connectionOrigin: "remote", syncId: "sync-2" },
|
||||
]);
|
||||
resolveConnectionOriginMock.mockImplementation(async (host) =>
|
||||
host.connectionOrigin === "local" ? "local" : "remote",
|
||||
);
|
||||
|
||||
await expect(getAllServerStatuses()).resolves.toEqual({
|
||||
1: { status: "online" },
|
||||
2: { status: "reachable" },
|
||||
});
|
||||
|
||||
expect(sshHostApiMock.get).toHaveBeenCalledWith("/status", {
|
||||
timeout: 2000,
|
||||
params: { hostIds: "1" },
|
||||
__silentRetry: true,
|
||||
});
|
||||
expect(remoteCoreApiMock.get).toHaveBeenCalledWith(
|
||||
"/sync/v2/host-status",
|
||||
expect.objectContaining({ __silentRetry: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends an empty allowlist when every host uses the remote server", async () => {
|
||||
hostsAndStatuses([{ id: 2, connectionOrigin: "remote" }]);
|
||||
resolveConnectionOriginMock.mockResolvedValue("remote");
|
||||
|
||||
await getAllServerStatuses();
|
||||
|
||||
expect(sshHostApiMock.get).toHaveBeenCalledWith("/status", {
|
||||
timeout: 2000,
|
||||
params: { hostIds: "" },
|
||||
__silentRetry: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("status check routes", () => {
|
||||
it("restarts checks through core", async () => {
|
||||
sshHostApiMock.post.mockResolvedValue({ data: {} });
|
||||
await refreshServerPolling();
|
||||
expect(sshHostApiMock.post).toHaveBeenCalledWith("/status/refresh");
|
||||
});
|
||||
|
||||
it("saves the default interval", async () => {
|
||||
sshHostApiMock.put.mockResolvedValue({ data: {} });
|
||||
await updateStatusCheckSettings({ statusCheckInterval: 45 });
|
||||
expect(sshHostApiMock.put).toHaveBeenCalledWith("/status/settings", {
|
||||
statusCheckInterval: 45,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseCustomKeybindings,
|
||||
parseCustomThemes,
|
||||
} from "../../api/open-tabs-api";
|
||||
import { parseCustomKeybindings } from "../../api/open-tabs-api";
|
||||
|
||||
describe("parseCustomKeybindings", () => {
|
||||
it("parses a valid JSON array", () => {
|
||||
@@ -41,10 +38,3 @@ describe("parseCustomKeybindings", () => {
|
||||
expect(parseCustomKeybindings(JSON.stringify({ foo: "bar" }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCustomThemes", () => {
|
||||
it("still parses a valid JSON array (regression check)", () => {
|
||||
const raw = JSON.stringify([{ id: "t1", name: "My Theme", colors: {} }]);
|
||||
expect(parseCustomThemes(raw)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const isElectronMock = vi.hoisted(() => vi.fn(() => true));
|
||||
|
||||
vi.mock("@/lib/electron", () => ({ isElectron: isElectronMock }));
|
||||
vi.mock("@/lib/base-path", () => ({ getBasePath: () => "" }));
|
||||
vi.mock("@/shell/TabContext", () => ({ clearTermixSessionStorage: vi.fn() }));
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
const { getRemoteSyncUserInfo } = await import("../../main-axios");
|
||||
|
||||
const invoke = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
invoke.mockReset();
|
||||
(window as unknown as { electronAPI?: unknown }).electronAPI = { invoke };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
|
||||
describe("getRemoteSyncUserInfo", () => {
|
||||
it("returns the identity reported by the main process", async () => {
|
||||
const identity = {
|
||||
userId: "u-1",
|
||||
username: "alice",
|
||||
is_admin: true,
|
||||
is_oidc: false,
|
||||
totp_enabled: false,
|
||||
roles: [{ roleId: 1, roleDisplayName: "Admin" }],
|
||||
};
|
||||
invoke.mockResolvedValueOnce(identity);
|
||||
|
||||
await expect(getRemoteSyncUserInfo()).resolves.toEqual(identity);
|
||||
expect(invoke).toHaveBeenCalledWith("get-remote-sync-user-info");
|
||||
});
|
||||
|
||||
it("returns null in the browser without calling the bridge", async () => {
|
||||
isElectronMock.mockReturnValue(false);
|
||||
|
||||
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when no remote sync identity is available", async () => {
|
||||
// The main process answers null when the server is unconfigured, the JWT is
|
||||
// missing, or it has expired.
|
||||
invoke.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null instead of propagating a bridge failure", async () => {
|
||||
invoke.mockRejectedValueOnce(new Error("IPC channel closed"));
|
||||
|
||||
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the preload bridge is absent", async () => {
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
|
||||
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const axiosGetMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
get: axiosGetMock,
|
||||
isAxiosError: (err: unknown): err is { response?: { status?: number } } =>
|
||||
typeof err === "object" && err !== null && "response" in err,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/base-path", () => ({
|
||||
getBasePath: () => "",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/electron", () => ({
|
||||
isElectron: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
getServerConfig: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
import {
|
||||
resolveShareLink,
|
||||
ShareLinkError,
|
||||
} from "../../api/session-sharing-api";
|
||||
|
||||
beforeEach(() => {
|
||||
axiosGetMock.mockReset();
|
||||
});
|
||||
|
||||
describe("resolveShareLink", () => {
|
||||
it("returns the resolved share data on success", async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
protocol: "ssh",
|
||||
permissionLevel: "read-write",
|
||||
wsPath: "/terminal/ws?shareToken=abc123",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveShareLink("abc123");
|
||||
|
||||
expect(result).toEqual({
|
||||
protocol: "ssh",
|
||||
permissionLevel: "read-write",
|
||||
wsPath: "/terminal/ws?shareToken=abc123",
|
||||
});
|
||||
expect(axiosGetMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/session-sharing/resolve/abc123"),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws a not-found ShareLinkError on 404", async () => {
|
||||
axiosGetMock.mockRejectedValueOnce({ response: { status: 404 } });
|
||||
|
||||
await expect(resolveShareLink("bad-token")).rejects.toMatchObject({
|
||||
kind: "not-found",
|
||||
});
|
||||
await expect(resolveShareLink("bad-token")).rejects.toBeInstanceOf(
|
||||
ShareLinkError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws a rate-limited ShareLinkError on 429", async () => {
|
||||
axiosGetMock.mockRejectedValueOnce({ response: { status: 429 } });
|
||||
|
||||
await expect(resolveShareLink("token")).rejects.toMatchObject({
|
||||
kind: "rate-limited",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws a generic ShareLinkError on unexpected failures", async () => {
|
||||
axiosGetMock.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
await expect(resolveShareLink("token")).rejects.toMatchObject({
|
||||
kind: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("URL-encodes the link token", async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { protocol: "ssh", permissionLevel: "read-only", wsPath: "" },
|
||||
});
|
||||
|
||||
await resolveShareLink("a/b c");
|
||||
|
||||
expect(axiosGetMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining(encodeURIComponent("a/b c")),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fileManagerApiMock = vi.hoisted(() => ({
|
||||
post: vi.fn(async () => ({ data: { complete: false } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: { defaults: {} },
|
||||
fileManagerApi: { defaults: {} },
|
||||
getFileManagerApiForSession: () => fileManagerApiMock,
|
||||
handleApiError: (error: unknown) => {
|
||||
throw error;
|
||||
},
|
||||
setSessionOrigin: vi.fn(),
|
||||
clearSessionOrigin: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/connection-origin", () => ({
|
||||
resolveConnectionOrigin: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/frontend-logger", () => ({
|
||||
fileLogger: {
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/file-list-request-cache", () => ({
|
||||
getCachedFileList: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/file-content-request-cache", () => ({
|
||||
getCachedFileContent: vi.fn(),
|
||||
invalidateCachedFileContent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { uploadSSHFile } from "../../api/ssh-file-operations-api";
|
||||
|
||||
describe("chunked SSH file uploads", () => {
|
||||
beforeEach(() => {
|
||||
fileManagerApiMock.post.mockClear();
|
||||
});
|
||||
|
||||
it("sends raw chunks with the byte offset expected by the server", async () => {
|
||||
const fileSize = 1.5 * 1024 * 1024 * 1024 + 1;
|
||||
const file = {
|
||||
size: fileSize,
|
||||
slice: vi.fn(() => new Blob(["chunk"])),
|
||||
} as unknown as File;
|
||||
|
||||
await uploadSSHFile("session-1", "/uploads", "archive.img", file);
|
||||
|
||||
expect(fileManagerApiMock.post).toHaveBeenCalledTimes(193);
|
||||
expect(fileManagerApiMock.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/ssh/uploadFileChunk",
|
||||
expect.any(Blob),
|
||||
{
|
||||
params: {
|
||||
sessionId: "session-1",
|
||||
path: "/uploads",
|
||||
fileName: "archive.img",
|
||||
offset: 0,
|
||||
totalSize: fileSize,
|
||||
},
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
timeout: 0,
|
||||
},
|
||||
);
|
||||
expect(fileManagerApiMock.post).toHaveBeenLastCalledWith(
|
||||
"/ssh/uploadFileChunk",
|
||||
expect.any(Blob),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ offset: 1.5 * 1024 * 1024 * 1024 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
local: vi.fn(),
|
||||
remote: vi.fn(),
|
||||
stream: vi.fn(),
|
||||
poll: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: {},
|
||||
handleApiError: (error: unknown) => {
|
||||
throw error;
|
||||
},
|
||||
tunnelApi: {
|
||||
get: mocks.local,
|
||||
defaults: { baseURL: "http://localhost/ssh" },
|
||||
},
|
||||
getRemoteTunnelApi: () => ({ get: mocks.remote }),
|
||||
isElectron: () => true,
|
||||
}));
|
||||
vi.mock("../../api/sse-stream", () => ({
|
||||
streamServerSentEvents: mocks.stream,
|
||||
}));
|
||||
vi.mock("@/lib/adaptive-polling", () => ({ runAdaptivePolling: mocks.poll }));
|
||||
import {
|
||||
getTunnelStatuses,
|
||||
subscribeTunnelStatuses,
|
||||
} from "../../api/tunnel-api";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, "electronAPI", {
|
||||
configurable: true,
|
||||
value: {
|
||||
invoke: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ serverUrl: "https://remote.example" }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps local connected state when a remote tunnel with the same name is disconnected", async () => {
|
||||
mocks.local.mockResolvedValue({ data: { shared: { status: "connected" } } });
|
||||
mocks.remote.mockResolvedValue({
|
||||
data: {
|
||||
shared: { status: "disconnected" },
|
||||
remoteOnly: { status: "connected" },
|
||||
},
|
||||
});
|
||||
expect(await getTunnelStatuses()).toEqual({
|
||||
shared: { status: "connected" },
|
||||
remoteOnly: { status: "connected" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps local state through remote polling and subsequent local SSE updates", async () => {
|
||||
let deliver: (event: { event: string; data: string }) => void = () => {};
|
||||
mocks.stream.mockImplementation((_url, _options, callback) => {
|
||||
deliver = callback;
|
||||
return new Promise(() => {});
|
||||
});
|
||||
let poll: () => Promise<boolean> = async () => false;
|
||||
mocks.poll.mockImplementation((callback) => {
|
||||
poll = callback;
|
||||
return vi.fn();
|
||||
});
|
||||
mocks.remote.mockResolvedValue({
|
||||
data: { shared: { status: "disconnected" } },
|
||||
});
|
||||
const onStatuses = vi.fn();
|
||||
const stop = subscribeTunnelStatuses(onStatuses);
|
||||
await vi.waitFor(() => expect(mocks.poll).toHaveBeenCalled());
|
||||
deliver({
|
||||
event: "statuses",
|
||||
data: JSON.stringify({ shared: { status: "connected" } }),
|
||||
});
|
||||
await poll();
|
||||
expect(onStatuses).toHaveBeenLastCalledWith({
|
||||
shared: { status: "connected" },
|
||||
});
|
||||
deliver({
|
||||
event: "statuses",
|
||||
data: JSON.stringify({ shared: { status: "disconnected" } }),
|
||||
});
|
||||
expect(onStatuses).toHaveBeenLastCalledWith({
|
||||
shared: { status: "disconnected" },
|
||||
});
|
||||
stop();
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn(() => false));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
|
||||
const tunnelPost = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/main-axios", () => ({
|
||||
tunnelApi: { post: tunnelPost },
|
||||
handleApiError: (error: unknown) => {
|
||||
throw new Error(`generic: ${String(error)}`);
|
||||
},
|
||||
}));
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "tunnel",
|
||||
render: "external",
|
||||
bindHost: "0.0.0.0",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const host = { id: "7", ip: "192.168.1.10" };
|
||||
|
||||
let pageHostname = "localhost";
|
||||
const realLocation = window.location;
|
||||
const windowOpen = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
pageHostname = "localhost";
|
||||
isElectron.mockReturnValue(false);
|
||||
tunnelPost.mockReset();
|
||||
tunnelPost.mockResolvedValue({ data: { port: 41234 } });
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({ ...realLocation, hostname: pageHostname }),
|
||||
});
|
||||
vi.stubGlobal("open", windowOpen);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: realLocation,
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
windowOpen.mockReset();
|
||||
});
|
||||
|
||||
describe("openWebEndpointTunnel", () => {
|
||||
it("posts a path relative to the tunnel base, never one starting /ssh", async () => {
|
||||
// tunnelApi's baseURL already includes /ssh; a leading "/ssh" here would
|
||||
// resolve to /ssh/ssh/... and 404 on every call. Asserted on the captured
|
||||
// runtime argument rather than by scanning source, so quoting style and
|
||||
// indirection cannot fool it.
|
||||
const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api");
|
||||
await openWebEndpointTunnel(7, "e1");
|
||||
|
||||
expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//);
|
||||
expect(tunnelPost).toHaveBeenCalledWith("/tunnel/web-endpoint/open", {
|
||||
hostId: 7,
|
||||
endpointId: "e1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the port", async () => {
|
||||
const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api");
|
||||
await expect(openWebEndpointTunnel(7, "e1")).resolves.toBe(41234);
|
||||
});
|
||||
|
||||
it("preserves the backend's reason instead of the generic message", async () => {
|
||||
// 502 is this route's likeliest real failure and carries the actionable
|
||||
// cause. Collapsing it into "Server error occurred" defeats the
|
||||
// per-failure-mode messaging entirely.
|
||||
const axiosError = Object.assign(new Error("Request failed"), {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 502,
|
||||
data: { error: "Timed out reaching the endpoint port" },
|
||||
},
|
||||
});
|
||||
tunnelPost.mockRejectedValue(axiosError);
|
||||
const { openWebEndpointTunnel, WebEndpointTunnelError } =
|
||||
await import("@/api/web-endpoint-api");
|
||||
|
||||
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(
|
||||
/Timed out reaching the endpoint port/,
|
||||
);
|
||||
await expect(openWebEndpointTunnel(7, "e1")).rejects.toBeInstanceOf(
|
||||
WebEndpointTunnelError,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the shared handler when the body carries no string reason", async () => {
|
||||
// A body shaped { error: <object> } would otherwise render as
|
||||
// "[object Object]" to the user.
|
||||
tunnelPost.mockRejectedValue(
|
||||
Object.assign(new Error("boom"), {
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { error: { nested: true } } },
|
||||
}),
|
||||
);
|
||||
const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api");
|
||||
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/generic:/);
|
||||
});
|
||||
|
||||
it("treats a missing port as a failure rather than returning undefined", async () => {
|
||||
tunnelPost.mockResolvedValue({ data: {} });
|
||||
const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api");
|
||||
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireNumericHostId", () => {
|
||||
it("accepts a saved host id and rejects a quick-connect one", async () => {
|
||||
const { requireNumericHostId } = await import("@/api/web-endpoint-api");
|
||||
expect(requireNumericHostId("7")).toBe(7);
|
||||
for (const bad of ["quick-connect-1", "", "0", "-3", "abc"]) {
|
||||
expect(() => requireNumericHostId(bad)).toThrow(/saved host/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* "Open externally" is not the safer path. The cookie jar belongs to the
|
||||
* browser either way, so a tunnel URL on the host string serving Termix hands
|
||||
* the tunnelled service this session exactly as an embedded frame would.
|
||||
*/
|
||||
describe("openWebEndpointExternally", () => {
|
||||
it("refuses shared-cookie browser windows before opening a tunnel", async () => {
|
||||
const { openWebEndpointExternally } =
|
||||
await import("@/api/web-endpoint-api");
|
||||
await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow(
|
||||
/desktop app/,
|
||||
);
|
||||
expect(windowOpen).not.toHaveBeenCalled();
|
||||
expect(tunnelPost).not.toHaveBeenCalled();
|
||||
});
|
||||
it("opens desktop endpoints through the isolated-window bridge", async () => {
|
||||
isElectron.mockReturnValue(true);
|
||||
const invoke = vi.fn().mockResolvedValue({ success: true });
|
||||
Object.defineProperty(window, "electronAPI", {
|
||||
configurable: true,
|
||||
value: { invoke },
|
||||
});
|
||||
const { openWebEndpointExternally } =
|
||||
await import("@/api/web-endpoint-api");
|
||||
await openWebEndpointExternally(host, endpoint({ ignoreCert: true }));
|
||||
expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", {
|
||||
url: "https://127.0.0.1:41234/",
|
||||
ignoreCert: true,
|
||||
});
|
||||
expect(windowOpen).not.toHaveBeenCalled();
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const authApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ data: {} })),
|
||||
post: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: { success: true } })),
|
||||
}));
|
||||
|
||||
const browserMock = vi.hoisted(() => ({
|
||||
startAuthentication: vi.fn(async () => ({ id: "cred-1" })),
|
||||
startRegistration: vi.fn(async () => ({ id: "cred-1" })),
|
||||
browserSupportsWebAuthn: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: authApiMock,
|
||||
handleApiError: (error: unknown) => error,
|
||||
}));
|
||||
|
||||
vi.mock("@simplewebauthn/browser", () => browserMock);
|
||||
|
||||
import { isPasskeySupported, loginWithPasskey } from "../../api/webauthn-api";
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
authApiMock.post.mockClear();
|
||||
authApiMock.delete.mockClear();
|
||||
browserMock.startAuthentication.mockClear();
|
||||
browserMock.browserSupportsWebAuthn.mockClear();
|
||||
});
|
||||
|
||||
describe("isPasskeySupported", () => {
|
||||
it("reports what the browser helper returns", () => {
|
||||
browserMock.browserSupportsWebAuthn.mockReturnValueOnce(false);
|
||||
expect(isPasskeySupported()).toBe(false);
|
||||
browserMock.browserSupportsWebAuthn.mockReturnValueOnce(true);
|
||||
expect(isPasskeySupported()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loginWithPasskey", () => {
|
||||
it("passes the challenge from options through to verify", async () => {
|
||||
authApiMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: { options: { challenge: "abc" }, challengeId: "chal-1" },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { success: true, username: "luke" } });
|
||||
|
||||
const result = await loginWithPasskey("luke", true);
|
||||
|
||||
expect(authApiMock.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/users/webauthn/authenticate/options",
|
||||
{ username: "luke" },
|
||||
);
|
||||
expect(browserMock.startAuthentication).toHaveBeenCalledWith({
|
||||
optionsJSON: { challenge: "abc" },
|
||||
});
|
||||
expect(authApiMock.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/users/webauthn/authenticate/verify",
|
||||
{
|
||||
challengeId: "chal-1",
|
||||
response: { id: "cred-1" },
|
||||
rememberMe: true,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ success: true, username: "luke" });
|
||||
});
|
||||
|
||||
it("omits the username so discoverable passkeys work", async () => {
|
||||
authApiMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: { options: { challenge: "abc" }, challengeId: "chal-2" },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { success: true } });
|
||||
|
||||
await loginWithPasskey();
|
||||
|
||||
expect(authApiMock.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/users/webauthn/authenticate/options",
|
||||
{},
|
||||
);
|
||||
expect(authApiMock.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/users/webauthn/authenticate/verify",
|
||||
expect.objectContaining({ rememberMe: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the totp challenge instead of a session", async () => {
|
||||
authApiMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: { options: { challenge: "abc" }, challengeId: "chal-3" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { success: true, requires_totp: true, temp_token: "tmp" },
|
||||
});
|
||||
|
||||
const result = await loginWithPasskey("luke");
|
||||
|
||||
expect(result.requires_totp).toBe(true);
|
||||
expect(result.temp_token).toBe("tmp");
|
||||
});
|
||||
|
||||
it("surfaces a cancelled prompt to the caller", async () => {
|
||||
authApiMock.post.mockResolvedValueOnce({
|
||||
data: { options: { challenge: "abc" }, challengeId: "chal-4" },
|
||||
});
|
||||
const cancelled = Object.assign(new Error("cancelled"), {
|
||||
name: "NotAllowedError",
|
||||
});
|
||||
browserMock.startAuthentication.mockRejectedValueOnce(cancelled);
|
||||
|
||||
await expect(loginWithPasskey("luke")).rejects.toMatchObject({
|
||||
name: "NotAllowedError",
|
||||
});
|
||||
expect(authApiMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,6 @@ const mainAxios = vi.hoisted(() => ({
|
||||
initiatePasswordReset: vi.fn(),
|
||||
verifyPasswordResetCode: vi.fn(),
|
||||
completePasswordReset: vi.fn(),
|
||||
getOIDCAuthorizeUrl: vi.fn(),
|
||||
verifyTOTPLogin: vi.fn(),
|
||||
isElectron: vi.fn(),
|
||||
getCurrentToken: vi.fn(),
|
||||
getOidcSilentLoginDefault: vi.fn(),
|
||||
@@ -30,13 +28,7 @@ const mainAxios = vi.hoisted(() => ({
|
||||
requestTrustedProxyLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
const ssoProviderApi = vi.hoisted(() => ({
|
||||
getSSOProviders: vi.fn(),
|
||||
ldapLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => mainAxios);
|
||||
vi.mock("@/api/sso-provider-api", () => ssoProviderApi);
|
||||
vi.mock("@/i18n/i18n", () => ({
|
||||
changeAppLanguage: vi.fn(async (code: string) => code),
|
||||
normalizeLanguageCode: vi.fn((code: string | null) => code || "en"),
|
||||
@@ -103,7 +95,6 @@ beforeEach(() => {
|
||||
enabled: false,
|
||||
success: false,
|
||||
});
|
||||
ssoProviderApi.getSSOProviders.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -31,22 +31,10 @@ vi.mock("@/main-axios", () => ({
|
||||
initiatePasswordReset: vi.fn(),
|
||||
verifyPasswordResetCode: vi.fn(),
|
||||
completePasswordReset: vi.fn(),
|
||||
getOIDCAuthorizeUrl: vi.fn(),
|
||||
verifyTOTPLogin: vi.fn(),
|
||||
getCurrentToken: vi.fn().mockReturnValue(null),
|
||||
requestTrustedProxyLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sso-provider-api", () => ({
|
||||
getSSOProviders: vi.fn().mockResolvedValue([]),
|
||||
ldapLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/webauthn-api", () => ({
|
||||
isPasskeySupported: () => false,
|
||||
loginWithPasskey: vi.fn(),
|
||||
}));
|
||||
|
||||
import { Auth } from "@/auth/Auth";
|
||||
|
||||
function setEmbeddedServerStatus(failure: unknown) {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import type {
|
||||
LoginMethodUIProps,
|
||||
SecondFactorUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
const mainAxios = vi.hoisted(() => ({
|
||||
loginUser: vi.fn(),
|
||||
registerUser: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
getRegistrationAllowed: vi.fn(),
|
||||
getPasswordLoginAllowed: vi.fn(),
|
||||
getPasswordResetAllowed: vi.fn(),
|
||||
getSetupRequired: vi.fn(),
|
||||
initiatePasswordReset: vi.fn(),
|
||||
verifyPasswordResetCode: vi.fn(),
|
||||
completePasswordReset: vi.fn(),
|
||||
isElectron: vi.fn(),
|
||||
getCurrentToken: vi.fn(),
|
||||
getOidcSilentLoginDefault: vi.fn(),
|
||||
requestDesktopAutoSession: vi.fn(),
|
||||
requestTrustedProxyLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
const authMethodsApi = vi.hoisted(() => ({
|
||||
getLoginMethods: vi.fn(),
|
||||
submitLoginMethod: vi.fn(),
|
||||
startLoginRedirect: vi.fn(),
|
||||
verifySecondFactor: vi.fn(),
|
||||
challengeSecondFactor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => mainAxios);
|
||||
vi.mock("@/api/auth-methods-api", () => authMethodsApi);
|
||||
vi.mock("@/plugin-host/loader", () => ({
|
||||
startPreLoginPlugins: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/i18n/i18n", () => ({
|
||||
changeAppLanguage: vi.fn(async (code: string) => code),
|
||||
normalizeLanguageCode: vi.fn((code: string | null) => code || "en"),
|
||||
rememberLoginLanguage: vi.fn((code: string) => code),
|
||||
}));
|
||||
vi.mock("../../auth/silent-signin", () => ({
|
||||
removeSilentSigninFromSearch: vi.fn(),
|
||||
shouldTriggerSilentSignin: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && typeof opts.name === "string" ? `${key}:${opts.name}` : key,
|
||||
}),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
import { Auth } from "../../auth/Auth";
|
||||
import {
|
||||
registerLoginMethod,
|
||||
registerSecondFactor,
|
||||
} from "@/plugin-host/auth-registry";
|
||||
|
||||
const OriginalResizeObserver = globalThis.ResizeObserver;
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.ResizeObserver = OriginalResizeObserver;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
window.history.replaceState({}, "", "/");
|
||||
mainAxios.isElectron.mockReturnValue(false);
|
||||
mainAxios.getRegistrationAllowed.mockResolvedValue({ allowed: true });
|
||||
mainAxios.getPasswordLoginAllowed.mockResolvedValue({ allowed: true });
|
||||
mainAxios.getPasswordResetAllowed.mockResolvedValue(true);
|
||||
mainAxios.getSetupRequired.mockResolvedValue({ setup_required: false });
|
||||
mainAxios.getOidcSilentLoginDefault.mockResolvedValue({ enabled: false });
|
||||
mainAxios.requestTrustedProxyLogin.mockResolvedValue({ enabled: false });
|
||||
mainAxios.getUserInfo.mockResolvedValue({
|
||||
username: "alice",
|
||||
userId: "u1",
|
||||
is_admin: false,
|
||||
});
|
||||
authMethodsApi.getLoginMethods.mockResolvedValue([
|
||||
{
|
||||
id: "password",
|
||||
pluginId: "core",
|
||||
kind: "form",
|
||||
labelKey: "auth.password",
|
||||
instances: [{ id: "password", label: "Password" }],
|
||||
},
|
||||
{
|
||||
id: "oidc",
|
||||
pluginId: "sso",
|
||||
kind: "redirect",
|
||||
labelKey: "loginWithSso",
|
||||
instances: [{ id: "3", label: "Keycloak" }],
|
||||
},
|
||||
{
|
||||
id: "corp-sso",
|
||||
pluginId: "corp",
|
||||
kind: "redirect",
|
||||
labelKey: "corp.sso",
|
||||
instances: [{ id: "a", label: "Corp" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
async function openExternalTab() {
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText("auth.external").length).toBeGreaterThan(0),
|
||||
);
|
||||
fireEvent.click(screen.getAllByText("auth.external")[0]);
|
||||
}
|
||||
|
||||
describe("login methods on the login screen", () => {
|
||||
it("lists every enabled method", async () => {
|
||||
await openExternalTab();
|
||||
// A redirect method whose plugin UI is not loaded gets a plain button.
|
||||
expect(screen.getByText("auth.loginWithProvider:Keycloak")).toBeTruthy();
|
||||
expect(screen.getByText("auth.loginWithProvider:Corp")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts a plugin redirect login through the generic endpoint", async () => {
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...window.location, replace, search: "" },
|
||||
});
|
||||
authMethodsApi.startLoginRedirect.mockResolvedValue("https://corp/auth");
|
||||
await openExternalTab();
|
||||
fireEvent.click(screen.getByText("auth.loginWithProvider:Corp"));
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.startLoginRedirect).toHaveBeenCalledWith(
|
||||
"corp-sso",
|
||||
expect.objectContaining({ instanceId: "a" }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(replace).toHaveBeenCalledWith("https://corp/auth"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a plugin's form UI and runs its second factor", async () => {
|
||||
authMethodsApi.getLoginMethods.mockResolvedValue([
|
||||
{
|
||||
id: "corp-form",
|
||||
pluginId: "corp",
|
||||
kind: "form",
|
||||
labelKey: "corp.form",
|
||||
instances: [],
|
||||
},
|
||||
]);
|
||||
const disposeMethod = registerLoginMethod({
|
||||
id: "corp-form",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp.form",
|
||||
component: ({ submit }: LoginMethodUIProps) => (
|
||||
<button onClick={() => void submit({ token: "abc" })}>
|
||||
corp sign in
|
||||
</button>
|
||||
),
|
||||
});
|
||||
const disposeFactor = registerSecondFactor({
|
||||
id: "pin",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp.pin",
|
||||
component: ({ verify }: SecondFactorUIProps) => (
|
||||
<button onClick={() => void verify({ pin: "1234" })}>send pin</button>
|
||||
),
|
||||
});
|
||||
authMethodsApi.submitLoginMethod.mockResolvedValue({
|
||||
success: true,
|
||||
requires_totp: true,
|
||||
temp_token: "pending-1",
|
||||
second_factors: [{ id: "pin", pluginId: "corp", labelKey: "corp.pin" }],
|
||||
});
|
||||
authMethodsApi.verifySecondFactor.mockResolvedValue({
|
||||
success: true,
|
||||
username: "alice",
|
||||
});
|
||||
const onLogin = vi.fn();
|
||||
try {
|
||||
render(<Auth onLogin={onLogin} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText("auth.external").length).toBeGreaterThan(0),
|
||||
);
|
||||
fireEvent.click(screen.getAllByText("auth.external")[0]);
|
||||
fireEvent.click(await screen.findByText("corp sign in"));
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.submitLoginMethod).toHaveBeenCalledWith(
|
||||
"corp-form",
|
||||
expect.objectContaining({ token: "abc" }),
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("send pin"));
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith(
|
||||
"pin",
|
||||
expect.objectContaining({ pin: "1234", temp_token: "pending-1" }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onLogin).toHaveBeenCalledWith("alice", "u1", false),
|
||||
);
|
||||
} finally {
|
||||
disposeMethod();
|
||||
disposeFactor();
|
||||
}
|
||||
});
|
||||
|
||||
it("picks up the second factor step after a redirect login", async () => {
|
||||
const dispose = registerSecondFactor({
|
||||
id: "pin",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp.pin",
|
||||
component: ({ verify }: SecondFactorUIProps) => (
|
||||
<button onClick={() => void verify({ pin: "1234" })}>send pin</button>
|
||||
),
|
||||
});
|
||||
window.history.replaceState({}, "", "/?second_factor=1&second_factors=pin");
|
||||
authMethodsApi.verifySecondFactor.mockResolvedValue({ success: true });
|
||||
try {
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByText("send pin"));
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith("pin", {
|
||||
pin: "1234",
|
||||
rememberMe: false,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers every registered factor when the server lists none", async () => {
|
||||
const dispose = registerSecondFactor({
|
||||
id: "pin",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp.pin",
|
||||
component: () => <span>pin challenge</span>,
|
||||
});
|
||||
window.history.replaceState({}, "", "/?second_factor=1");
|
||||
try {
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
expect(await screen.findByText("pin challenge")).toBeTruthy();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("draws an inline method under the password form, not in the external list", async () => {
|
||||
authMethodsApi.getLoginMethods.mockResolvedValue([
|
||||
{
|
||||
id: "quick-key",
|
||||
pluginId: "keys",
|
||||
kind: "form",
|
||||
labelKey: "keys.title",
|
||||
instances: [],
|
||||
},
|
||||
]);
|
||||
const dispose = registerLoginMethod({
|
||||
id: "quick-key",
|
||||
pluginId: "keys",
|
||||
titleKey: "keys.title",
|
||||
placement: "inline",
|
||||
component: () => <button>quick key</button>,
|
||||
});
|
||||
try {
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
expect(await screen.findByText("quick key")).toBeTruthy();
|
||||
expect(screen.queryByText("auth.external")).toBeNull();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ElectronLoginForm } from "../../auth/ElectronLoginForm";
|
||||
|
||||
@@ -17,10 +17,40 @@ describe("ElectronLoginForm", () => {
|
||||
);
|
||||
|
||||
const permissions = screen
|
||||
.getByTitle("Server Authentication")
|
||||
.getByTitle("auth.serverAuthentication")
|
||||
.getAttribute("allow");
|
||||
|
||||
expect(permissions).toContain("publickey-credentials-get");
|
||||
expect(permissions).toContain("publickey-credentials-create");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"auth_component",
|
||||
"second_factor_auth_component",
|
||||
"method_auth_component",
|
||||
])("accepts a login hand-off from %s", async (source) => {
|
||||
const onAuthSuccess = vi.fn();
|
||||
render(
|
||||
<ElectronLoginForm
|
||||
serverUrl="https://termix.example.com"
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
onChangeServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const frame = screen.getByTitle(
|
||||
"auth.serverAuthentication",
|
||||
) as HTMLIFrameElement;
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
data: {
|
||||
type: "AUTH_SUCCESS",
|
||||
platform: "desktop",
|
||||
source,
|
||||
token: "jwt-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(onAuthSuccess).toHaveBeenCalledWith("jwt-1"));
|
||||
});
|
||||
});
|
||||
@@ -29,12 +29,12 @@ describe("ConnectionScreen", () => {
|
||||
render(
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message="guacamole.hostNotFound"
|
||||
message="remoteDesktop.hostNotFound"
|
||||
/>,
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(screen.getByText("guacamole.hostNotFound")).toBeTruthy();
|
||||
expect(screen.getByText("remoteDesktop.hostNotFound")).toBeTruthy();
|
||||
expect(screen.getByRole("status").getAttribute("data-status")).toBe(
|
||||
"disconnected",
|
||||
);
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProxmoxImportAuth } from "../../../components/proxmox/proxmox-import-auth";
|
||||
|
||||
describe("resolveProxmoxImportAuth", () => {
|
||||
it("inherits an available credential even with the default 'password' authType", () => {
|
||||
// Regression guard: a Proxmox host authenticating via a Termix credential
|
||||
// used to yield authType 'none' for imported guests because the default
|
||||
// authType is 'password', not 'credential'.
|
||||
expect(resolveProxmoxImportAuth("password", 42)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 42,
|
||||
overrideCredentialUsername: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the credential when authType is explicitly 'credential'", () => {
|
||||
expect(resolveProxmoxImportAuth("credential", 7)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 7,
|
||||
overrideCredentialUsername: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the credential when no authType is given", () => {
|
||||
expect(resolveProxmoxImportAuth(undefined, 5)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 5,
|
||||
overrideCredentialUsername: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets an explicit secretless authType win over an available credential", () => {
|
||||
expect(resolveProxmoxImportAuth("none", 42)).toEqual({ authType: "none" });
|
||||
expect(resolveProxmoxImportAuth("tailscale", 42)).toEqual({
|
||||
authType: "tailscale",
|
||||
});
|
||||
expect(resolveProxmoxImportAuth("agent", 42)).toEqual({
|
||||
authType: "agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'none' when no credential is available", () => {
|
||||
expect(resolveProxmoxImportAuth("password", null)).toEqual({
|
||||
authType: "none",
|
||||
});
|
||||
expect(resolveProxmoxImportAuth("key", undefined)).toEqual({
|
||||
authType: "none",
|
||||
});
|
||||
expect(resolveProxmoxImportAuth(undefined, undefined)).toEqual({
|
||||
authType: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through a non secret-backed special type without a credential", () => {
|
||||
expect(resolveProxmoxImportAuth("opkssh", null)).toEqual({
|
||||
authType: "opkssh",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
@@ -7,6 +7,10 @@ vi.mock("react-i18next", () => ({
|
||||
|
||||
import { HostStatusCard } from "@/dashboard/DashboardTab";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import {
|
||||
registerHostAction,
|
||||
resetHostContributions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -19,13 +23,7 @@ describe("HostStatusCard", () => {
|
||||
online: false,
|
||||
} as Host;
|
||||
|
||||
render(
|
||||
<HostStatusCard
|
||||
hosts={[host]}
|
||||
hostMetrics={new Map()}
|
||||
onOpenTab={() => {}}
|
||||
/>,
|
||||
);
|
||||
render(<HostStatusCard hosts={[host]} onOpenTab={() => {}} />);
|
||||
|
||||
const name = screen.getByText(host.name);
|
||||
const ip = screen.getByText(host.ip);
|
||||
@@ -42,28 +40,51 @@ describe("HostStatusCard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard protocol routing", () => {
|
||||
describe("dashboard host routing", () => {
|
||||
const Icon = (() => null) as never;
|
||||
beforeEach(() => {
|
||||
registerHostAction({
|
||||
id: "desk-connect",
|
||||
titleKey: "x",
|
||||
icon: Icon,
|
||||
kind: "connect",
|
||||
priority: 50,
|
||||
tabType: "desk",
|
||||
when: (host) => (host as Record<string, unknown>).enableDesk === true,
|
||||
});
|
||||
registerHostAction({
|
||||
id: "shell-connect",
|
||||
titleKey: "x",
|
||||
icon: Icon,
|
||||
kind: "connect",
|
||||
priority: 100,
|
||||
tabType: "shell",
|
||||
when: (host) => (host as Record<string, unknown>).enableShell === true,
|
||||
});
|
||||
registerHostAction({
|
||||
id: "stats",
|
||||
titleKey: "x",
|
||||
icon: Icon,
|
||||
kind: "open",
|
||||
overview: true,
|
||||
tabType: "stats",
|
||||
when: (host) => (host as Record<string, unknown>).enableShell === true,
|
||||
});
|
||||
});
|
||||
afterEach(resetHostContributions);
|
||||
|
||||
it.each([
|
||||
[{ enableRdp: true }, "rdp"],
|
||||
[{ enableVnc: true }, "vnc"],
|
||||
[{ enableTelnet: true }, "telnet"],
|
||||
[{ enableSsh: true, enableRdp: true }, "host-metrics"],
|
||||
] as const)("opens the enabled protocol for %j", (protocols, expected) => {
|
||||
[{ enableDesk: true }, "desk"],
|
||||
[{ enableShell: true, enableDesk: true }, "stats"],
|
||||
] as const)("opens the right view for %j", (flags, expected) => {
|
||||
const host = {
|
||||
id: "1",
|
||||
name: "Remote host",
|
||||
ip: "192.0.2.1",
|
||||
enableSsh: false,
|
||||
...protocols,
|
||||
} as Host;
|
||||
...flags,
|
||||
} as unknown as Host;
|
||||
const onOpenTab = vi.fn();
|
||||
render(
|
||||
<HostStatusCard
|
||||
hosts={[host]}
|
||||
hostMetrics={new Map()}
|
||||
onOpenTab={onOpenTab}
|
||||
/>,
|
||||
);
|
||||
render(<HostStatusCard hosts={[host]} onOpenTab={onOpenTab} />);
|
||||
fireEvent.click(screen.getByText("Remote host"));
|
||||
expect(onOpenTab).toHaveBeenCalledWith(host, expected);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
registerDashboardCard,
|
||||
registeredDashboardCardList,
|
||||
unregisterDashboardCard,
|
||||
} from "@/dashboard/dashboard-cards-registry";
|
||||
|
||||
const FAKE_CARD_ID = "__test_plugin_dashboard_card__";
|
||||
|
||||
describe("registerDashboardCard seam", () => {
|
||||
afterEach(() => {
|
||||
unregisterDashboardCard(FAKE_CARD_ID);
|
||||
});
|
||||
|
||||
it("is not registered by default", () => {
|
||||
expect(registeredDashboardCardList()).toEqual([]);
|
||||
});
|
||||
|
||||
it("registers and unregisters a plugin dashboard card", () => {
|
||||
registerDashboardCard({
|
||||
id: FAKE_CARD_ID,
|
||||
titleKey: "nav.fakePluginItem",
|
||||
component: () => null,
|
||||
});
|
||||
|
||||
expect(registeredDashboardCardList().map((c) => c.id)).toContain(
|
||||
FAKE_CARD_ID,
|
||||
);
|
||||
|
||||
unregisterDashboardCard(FAKE_CARD_ID);
|
||||
|
||||
expect(registeredDashboardCardList().map((c) => c.id)).not.toContain(
|
||||
FAKE_CARD_ID,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import Module from "node:module";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
// remote-sync.cjs is CommonJS and pulls in `electron` at load time, which does
|
||||
// not exist outside the Electron runtime. vi.mock cannot reach a require() made
|
||||
// through createRequire, so the stub is injected into the CJS loader itself.
|
||||
const electronStub = {
|
||||
app: { getPath: () => os.tmpdir() },
|
||||
safeStorage: { isEncryptionAvailable: () => false },
|
||||
ipcMain: { handle: () => {} },
|
||||
};
|
||||
const loader = Module as unknown as {
|
||||
_load: (request: string, parent: unknown, isMain: boolean) => unknown;
|
||||
};
|
||||
const originalLoad = loader._load;
|
||||
loader._load = (request, parent, isMain) =>
|
||||
request === "electron" ? electronStub : originalLoad(request, parent, isMain);
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
type Engine = {
|
||||
stop: () => void;
|
||||
pullSide: (
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
entityType: string,
|
||||
since: string | null,
|
||||
) => Promise<unknown[]>;
|
||||
pullTombstones: (
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
entityType: string,
|
||||
since: string | null,
|
||||
) => Promise<unknown[]>;
|
||||
pushRow: (...args: unknown[]) => Promise<void>;
|
||||
pushTombstone: (
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
entityType: string,
|
||||
syncId: string,
|
||||
) => Promise<void>;
|
||||
syncEntity: (args: {
|
||||
entityType: string;
|
||||
remoteBaseUrl: string;
|
||||
remoteJwt: string;
|
||||
since: string | null;
|
||||
}) => Promise<{ syncedAt: string }>;
|
||||
};
|
||||
|
||||
const REMOTE = "https://server.example";
|
||||
const CURSOR = "2026-07-29T10:24:44.501Z";
|
||||
|
||||
describe("remote sync tombstone application", () => {
|
||||
let engine: Engine;
|
||||
let pushed: Array<{ baseUrl: string; syncId: string }>;
|
||||
let fullPulls: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
const { initRemoteSync } = require("../../../../electron/remote-sync.cjs");
|
||||
engine = initRemoteSync(() => null) as Engine;
|
||||
engine.stop();
|
||||
pushed = [];
|
||||
fullPulls = [];
|
||||
engine.pushRow = async () => {};
|
||||
engine.pushTombstone = async (baseUrl, _token, _entityType, syncId) => {
|
||||
pushed.push({ baseUrl, syncId });
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The row deleted on the remote is untouched locally, so it is absent from
|
||||
* the incremental window on both sides -- the shape every ordinary deletion
|
||||
* takes once the two sides have converged.
|
||||
*/
|
||||
function stubSides(localHolds: string[], remoteTombstoneIds: string[]) {
|
||||
engine.pullSide = async (baseUrl, _token, _entityType, since) => {
|
||||
if (since) return [];
|
||||
fullPulls.push(baseUrl);
|
||||
return baseUrl === REMOTE
|
||||
? []
|
||||
: localHolds.map((syncId) => ({ syncId, updatedAt: CURSOR }));
|
||||
};
|
||||
engine.pullTombstones = async (baseUrl) =>
|
||||
baseUrl === REMOTE
|
||||
? remoteTombstoneIds.map((syncId) => ({ syncId }))
|
||||
: [];
|
||||
}
|
||||
|
||||
it("applies a remote deletion to a local row that is outside the incremental window", async () => {
|
||||
stubSides(["cred-1"], ["cred-1"]);
|
||||
|
||||
await engine.syncEntity({
|
||||
entityType: "sshCredentials",
|
||||
remoteBaseUrl: REMOTE,
|
||||
remoteJwt: "remote-jwt",
|
||||
since: CURSOR,
|
||||
});
|
||||
|
||||
expect(pushed).toEqual([
|
||||
{ baseUrl: "http://127.0.0.1:30001", syncId: "cred-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not re-push a deletion once the row is gone, so tombstones cannot ping-pong", async () => {
|
||||
stubSides([], ["cred-1"]);
|
||||
|
||||
await engine.syncEntity({
|
||||
entityType: "sshCredentials",
|
||||
remoteBaseUrl: REMOTE,
|
||||
remoteJwt: "remote-jwt",
|
||||
since: CURSOR,
|
||||
});
|
||||
|
||||
expect(pushed).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not pay for a presence check when nothing was deleted", async () => {
|
||||
stubSides(["cred-1"], []);
|
||||
|
||||
await engine.syncEntity({
|
||||
entityType: "sshCredentials",
|
||||
remoteBaseUrl: REMOTE,
|
||||
remoteJwt: "remote-jwt",
|
||||
since: CURSOR,
|
||||
});
|
||||
|
||||
expect(pushed).toEqual([]);
|
||||
expect(fullPulls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { SYNCED_ENTITY_TYPES } =
|
||||
require("../../../../electron/remote-sync-entities.cjs") as {
|
||||
SYNCED_ENTITY_TYPES: readonly string[];
|
||||
};
|
||||
|
||||
describe("desktop remote sync entities", () => {
|
||||
it("retains user preferences in the current sync protocol", () => {
|
||||
expect(SYNCED_ENTITY_TYPES).toContain("userPreferences");
|
||||
});
|
||||
});
|
||||
@@ -1,452 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
|
||||
const allowInvalidCertificateForOrigin = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/web-endpoint-api", async (importOriginal) => {
|
||||
// requireNumericHostId is left as the real implementation -- the "no saved
|
||||
// host" case exercises the actual validation, not a re-implementation of it
|
||||
// that could drift.
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/api/web-endpoint-api")>();
|
||||
return {
|
||||
...actual,
|
||||
openWebEndpointTunnel: (...args: unknown[]) =>
|
||||
openWebEndpointTunnel(...args),
|
||||
allowInvalidCertificateForOrigin: (...args: unknown[]) =>
|
||||
allowInvalidCertificateForOrigin(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const electron = vi.hoisted(() => ({ isElectron: vi.fn(() => true) }));
|
||||
vi.mock("@/lib/electron", () => electron);
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function host(
|
||||
overrides: Partial<Host> = {},
|
||||
endpoints: WebEndpoint[] = [],
|
||||
): Host {
|
||||
return {
|
||||
id: "7",
|
||||
ip: "192.168.1.10",
|
||||
webUiConfig: { endpoints },
|
||||
...overrides,
|
||||
} as unknown as Host;
|
||||
}
|
||||
|
||||
let pageHostname = "localhost";
|
||||
const realLocation = window.location;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLIFrameElement.prototype, "credentialless", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: false,
|
||||
});
|
||||
pageHostname = "localhost";
|
||||
electron.isElectron.mockReturnValue(true);
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({ ...realLocation, hostname: pageHostname }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
|
||||
.credentialless;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: realLocation,
|
||||
});
|
||||
openWebEndpointTunnel.mockReset();
|
||||
allowInvalidCertificateForOrigin.mockReset();
|
||||
});
|
||||
|
||||
describe("WebEndpointTab", () => {
|
||||
it("refuses unsupported environments before creating a tunnel or iframe", async () => {
|
||||
delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
|
||||
.credentialless;
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("webEndpoint.isolationUnavailable");
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
it("isolates cookie state and denies parent access, popups and top navigation", async () => {
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
|
||||
const frame = (await screen.findByTitle("Proxmox")) as HTMLIFrameElement;
|
||||
expect(
|
||||
(frame as HTMLIFrameElement & { credentialless: boolean }).credentialless,
|
||||
).toBe(true);
|
||||
expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
|
||||
expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer");
|
||||
expect(screen.queryByText("webEndpoint.openExternally")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an iframe at the direct URL", async () => {
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://192.168.1.10:8006/",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the tunnel through the numeric host id and frames the loopback URL", async () => {
|
||||
openWebEndpointTunnel.mockResolvedValue(41234);
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(openWebEndpointTunnel).toHaveBeenCalledWith(7, "e1");
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://127.0.0.1:41234/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the certificate allowance for a direct endpoint with ignoreCert", async () => {
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ ignoreCert: true })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(allowInvalidCertificateForOrigin).toHaveBeenCalledWith(
|
||||
"https://192.168.1.10:8006",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("reframes at the new port when a reopened tunnel binds a different one", async () => {
|
||||
openWebEndpointTunnel
|
||||
.mockResolvedValueOnce(41234)
|
||||
.mockResolvedValueOnce(51234);
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://127.0.0.1:41234/",
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle("webEndpoint.reload"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://127.0.0.1:51234/",
|
||||
),
|
||||
);
|
||||
expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* The case the port-changed test above CANNOT exercise, and the one that
|
||||
* matters most: a live tunnel returns the SAME port on every open, and a
|
||||
* direct endpoint's URL never changes at all. If reload just did
|
||||
* setUrl(resolved), React would bail out of the same-value setState and the
|
||||
* frame would never remount -- so the button would do nothing in the two
|
||||
* most common cases. Capturing the DOM node before and after is what catches
|
||||
* that: a same-value setState never touches the DOM.
|
||||
*/
|
||||
it("remounts the frame on reload even when the resolved URL is unchanged", async () => {
|
||||
openWebEndpointTunnel.mockResolvedValue(41234);
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
const firstFrame = await screen.findByTitle("Proxmox");
|
||||
expect(firstFrame).toHaveAttribute("src", "https://127.0.0.1:41234/");
|
||||
|
||||
fireEvent.click(screen.getByTitle("webEndpoint.reload"));
|
||||
await waitFor(() => expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2));
|
||||
|
||||
// Polled rather than asserted once: against the defective implementation
|
||||
// this never becomes true and the test times out -- a real failure, not a
|
||||
// race.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Proxmox")).not.toBe(firstFrame);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the failure reason when the tunnel cannot open", async () => {
|
||||
openWebEndpointTunnel.mockRejectedValue(
|
||||
new Error("Timed out reaching the endpoint port"),
|
||||
);
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/Timed out reaching the endpoint port/),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a plain message when the endpoint id matches nothing on the host", async () => {
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(<WebEndpointTab host={host({}, [])} endpointId="deleted" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("webEndpoint.notFound")).toBeInTheDocument(),
|
||||
);
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains that a tunnel needs a saved host, instead of calling the route", async () => {
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({ id: "quick-connect-1" }, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/requires a saved host/)).toBeInTheDocument(),
|
||||
);
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves a direct endpoint normally on the same quick-connect host", async () => {
|
||||
// The positive case: requireNumericHostId must be consulted only on the
|
||||
// tunnel branch, or direct endpoints would break on unsaved hosts too.
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({ id: "quick-connect-1" }, [endpoint({ access: "direct" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://192.168.1.10:8006/",
|
||||
),
|
||||
);
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("frames a tunnel at the OTHER loopback spelling in a web deployment", async () => {
|
||||
// jsdom serves the page at "localhost". The forward binds where the
|
||||
// backend runs, so the browser must reach it on this same machine -- but
|
||||
// NOT at the same host string, or the tunnelled service receives Termix's
|
||||
// session cookie.
|
||||
electron.isElectron.mockReturnValue(false);
|
||||
openWebEndpointTunnel.mockResolvedValue(41234);
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://127.0.0.1:41234/",
|
||||
),
|
||||
);
|
||||
// The assertion carrying the security property: whatever host the frame
|
||||
// lands on, it must not be the one serving Termix.
|
||||
const framed = new URL(
|
||||
screen.getByTitle("Proxmox").getAttribute("src") as string,
|
||||
).hostname;
|
||||
expect(framed).not.toBe(window.location.hostname);
|
||||
});
|
||||
|
||||
it("refuses a loopback-bound tunnel in a browser instead of framing a dead port", async () => {
|
||||
electron.isElectron.mockReturnValue(false);
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("webEndpoint.tunnelUnreachableFromBrowser"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses when the page host has no separate loopback spelling", async () => {
|
||||
electron.isElectron.mockReturnValue(false);
|
||||
pageHostname = "termix.example.com";
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("webEndpoint.tunnelSharesSessionCookie"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
// Refusing after opening would still have bound the port and still have
|
||||
// leaked on the first frame load.
|
||||
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The leak the tunnel guard did not cover: a DIRECT endpoint points at the
|
||||
* host's own address, so when that host is the one serving Termix the frame
|
||||
* is same-site with Termix and the browser attaches the `jwt` cookie. A
|
||||
* different port is not a different cookie key.
|
||||
*/
|
||||
it("refuses a direct endpoint on the same host that serves Termix", async () => {
|
||||
electron.isElectron.mockReturnValue(false);
|
||||
pageHostname = "termix.example";
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({ ip: "termix.example" }, [
|
||||
endpoint({ access: "direct", port: 8443 }),
|
||||
])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("webEndpoint.directSharesSessionCookie"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
// Refusing after framing would have leaked on the very first request.
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a direct endpoint on a sub domain of the page host", async () => {
|
||||
electron.isElectron.mockReturnValue(false);
|
||||
pageHostname = "termix.example.com";
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({ ip: "ui.termix.example.com" }, [
|
||||
endpoint({ access: "direct" }),
|
||||
])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("webEndpoint.directSharesSessionCookie"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("still frames a direct same-host endpoint on the desktop, whose jar has no jwt", async () => {
|
||||
electron.isElectron.mockReturnValue(true);
|
||||
pageHostname = "termix.example";
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(
|
||||
<WebEndpointTab
|
||||
host={host({ ip: "termix.example" }, [
|
||||
endpoint({ access: "direct", port: 8443 }),
|
||||
])}
|
||||
endpointId="e1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://termix.example:8443/",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("still frames a DIRECT endpoint on a hostname that refuses tunnels", async () => {
|
||||
electron.isElectron.mockReturnValue(false);
|
||||
pageHostname = "termix.example.com";
|
||||
const { WebEndpointTab } =
|
||||
await import("@/features/web-endpoint/WebEndpointTab");
|
||||
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://192.168.1.10:8006/",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { AiProvider } from "@/api/ai-api";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
createAiProvider: vi.fn(),
|
||||
deleteAiProvider: vi.fn(),
|
||||
getAiProviderModels: vi.fn(),
|
||||
probeAiModels: vi.fn(),
|
||||
updateAiProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/ai-api", () => api);
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { AiProviderSettings } from "@/features/ai/AiProviderSettings";
|
||||
|
||||
const provider: AiProvider = {
|
||||
id: 7,
|
||||
providerType: "ollama",
|
||||
label: "Local Ollama",
|
||||
baseUrl: "http://localhost:11434",
|
||||
apiKeyPrefix: null,
|
||||
defaultModel: "llama3.1",
|
||||
enabled: true,
|
||||
createdAt: "2026-08-21T00:00:00Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
api.getAiProviderModels.mockReset();
|
||||
api.getAiProviderModels.mockResolvedValue([]);
|
||||
api.updateAiProvider.mockReset();
|
||||
api.updateAiProvider.mockResolvedValue(provider);
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AiProviderSettings", () => {
|
||||
it("edits an existing provider name and configured model", async () => {
|
||||
const onChanged = vi.fn();
|
||||
render(<AiProviderSettings providers={[provider]} onChanged={onChanged} />);
|
||||
|
||||
expect(screen.getByText(/llama3\.1/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
|
||||
|
||||
const label = screen.getByLabelText("ai.providerLabel");
|
||||
const model = screen.getByLabelText("ai.defaultModel");
|
||||
fireEvent.change(label, { target: { value: "Production Ollama" } });
|
||||
fireEvent.change(model, { target: { value: "qwen3:32b" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateAiProvider).toHaveBeenCalledWith(7, {
|
||||
label: "Production Ollama",
|
||||
defaultModel: "qwen3:32b",
|
||||
});
|
||||
});
|
||||
expect(onChanged).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("loads the saved provider model list when editing starts", async () => {
|
||||
api.getAiProviderModels.mockResolvedValue(["llama3.1", "qwen3:32b"]);
|
||||
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.getAiProviderModels).toHaveBeenCalledWith(7);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "ai.defaultModel" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the provider error when refreshing models fails", async () => {
|
||||
api.getAiProviderModels.mockRejectedValue(
|
||||
new Error("Add llm.internal to the AI endpoint allowlist"),
|
||||
);
|
||||
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Add llm.internal to the AI endpoint allowlist"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,502 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { LocalFilePane } from "@/features/file-manager/LocalFilePane";
|
||||
import {
|
||||
LOCAL_FILES_DRAG_MIME,
|
||||
REMOTE_FILES_DRAG_MIME,
|
||||
} from "@/features/file-manager/local-transfer-utils";
|
||||
import type { LocalFileEntry } from "@/types/electron";
|
||||
|
||||
// jsdom has no layout, so render every row instead of a virtual window.
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
getTotalSize: () => count * 34,
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
key: index,
|
||||
start: index * 34,
|
||||
size: 34,
|
||||
})),
|
||||
measureElement: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const sonnerToast = vi.hoisted(() => {
|
||||
const fn = vi.fn() as ReturnType<typeof vi.fn> & Record<string, unknown>;
|
||||
fn.success = vi.fn();
|
||||
fn.error = vi.fn();
|
||||
fn.info = vi.fn();
|
||||
fn.warning = vi.fn();
|
||||
fn.loading = vi.fn();
|
||||
fn.dismiss = vi.fn();
|
||||
return fn;
|
||||
});
|
||||
vi.mock("sonner", () => ({ toast: sonnerToast }));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.count !== undefined ? `${key}:${opts.count}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const HOME = "/Users/max";
|
||||
|
||||
const entries: LocalFileEntry[] = [
|
||||
{
|
||||
name: "projects",
|
||||
path: `${HOME}/projects`,
|
||||
type: "directory",
|
||||
size: 0,
|
||||
modifiedTimestamp: 1_700_000_000_000,
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
name: "notes.txt",
|
||||
path: `${HOME}/notes.txt`,
|
||||
type: "file",
|
||||
size: 1234,
|
||||
modifiedTimestamp: 1_700_000_000_000,
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
name: ".zshrc",
|
||||
path: `${HOME}/.zshrc`,
|
||||
type: "file",
|
||||
size: 42,
|
||||
modifiedTimestamp: 1_700_000_000_000,
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
function installElectronApi() {
|
||||
const list = vi.fn(async (dirPath: string) => ({
|
||||
success: true as const,
|
||||
path: dirPath,
|
||||
parent:
|
||||
dirPath === "/" ? null : dirPath.split("/").slice(0, -1).join("/") || "/",
|
||||
entries: dirPath === HOME ? entries : [],
|
||||
}));
|
||||
const api = {
|
||||
isElectron: true,
|
||||
localFs: {
|
||||
home: vi.fn(async () => ({
|
||||
success: true as const,
|
||||
home: HOME,
|
||||
separator: "/",
|
||||
platform: "darwin",
|
||||
})),
|
||||
list,
|
||||
mkdir: vi.fn(),
|
||||
createFile: vi.fn(async () => ({ success: true as const, path: "" })),
|
||||
rename: vi.fn(async () => ({ success: true as const, path: "" })),
|
||||
trash: vi.fn(async () => ({
|
||||
success: true as const,
|
||||
trashed: 1,
|
||||
failed: [],
|
||||
})),
|
||||
ensureDir: vi.fn(),
|
||||
walk: vi.fn(),
|
||||
reveal: vi.fn(),
|
||||
open: vi.fn(),
|
||||
},
|
||||
localTransfer: {
|
||||
upload: vi.fn(),
|
||||
download: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
onProgress: vi.fn(() => () => {}),
|
||||
},
|
||||
};
|
||||
(window as unknown as { electronAPI: unknown }).electronAPI = api;
|
||||
(window as unknown as { IS_ELECTRON: boolean }).IS_ELECTRON = true;
|
||||
return api;
|
||||
}
|
||||
|
||||
function makeDataTransfer(
|
||||
types: string[],
|
||||
data: Record<string, string> = {},
|
||||
): DataTransfer {
|
||||
const store: Record<string, string> = { ...data };
|
||||
return {
|
||||
types,
|
||||
dropEffect: "none",
|
||||
effectAllowed: "all",
|
||||
files: [] as unknown as FileList,
|
||||
items: [] as unknown as DataTransferItemList,
|
||||
getData: (type: string) => store[type] ?? "",
|
||||
setData: (type: string, value: string) => {
|
||||
store[type] = value;
|
||||
if (!types.includes(type)) types.push(type);
|
||||
},
|
||||
clearData: vi.fn(),
|
||||
setDragImage: vi.fn(),
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
describe("LocalFilePane", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
installElectronApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
|
||||
it("lists the home directory on mount and remembers the last path", async () => {
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByText("fileManager.localItemCount:3"),
|
||||
).toBeInTheDocument();
|
||||
expect(localStorage.getItem("termix:file-manager:local-pane:path")).toBe(
|
||||
HOME,
|
||||
);
|
||||
});
|
||||
|
||||
it("navigates into a folder on double click", async () => {
|
||||
const api = installElectronApi();
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const row = document.querySelector(`[data-local-path="${HOME}/projects"]`);
|
||||
expect(row).not.toBeNull();
|
||||
fireEvent.click(row!, { detail: 2 });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.localFs.list).toHaveBeenCalledWith(`${HOME}/projects`),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts remote-grid drops and hands paths to the parent", async () => {
|
||||
const onRemoteItemsDropped = vi.fn();
|
||||
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const pane = screen.getByTestId("local-file-pane");
|
||||
const payload = JSON.stringify({
|
||||
type: "internal_files",
|
||||
files: ["/srv/app/a.log", "/srv/app/dir"],
|
||||
});
|
||||
const dataTransfer = makeDataTransfer(
|
||||
[REMOTE_FILES_DRAG_MIME, "text/plain"],
|
||||
{ "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" },
|
||||
);
|
||||
|
||||
fireEvent.dragEnter(pane, { dataTransfer });
|
||||
expect(
|
||||
screen.getByText("fileManager.dropToDownloadHere"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.drop(pane, { dataTransfer });
|
||||
expect(onRemoteItemsDropped).toHaveBeenCalledWith(
|
||||
["/srv/app/a.log", "/srv/app/dir"],
|
||||
HOME,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("fileManager.dropToDownloadHere"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops onto a folder row download into that folder", async () => {
|
||||
const onRemoteItemsDropped = vi.fn();
|
||||
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const folderRow = document.querySelector(
|
||||
`[data-local-path="${HOME}/projects"]`,
|
||||
)!;
|
||||
const payload = JSON.stringify({
|
||||
type: "internal_files",
|
||||
files: ["/srv/app/a.log"],
|
||||
});
|
||||
const dataTransfer = makeDataTransfer(
|
||||
[REMOTE_FILES_DRAG_MIME, "text/plain"],
|
||||
{ "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" },
|
||||
);
|
||||
fireEvent.dragOver(folderRow, { dataTransfer });
|
||||
fireEvent.drop(folderRow, { dataTransfer });
|
||||
expect(onRemoteItemsDropped).toHaveBeenCalledWith(
|
||||
["/srv/app/a.log"],
|
||||
`${HOME}/projects`,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides dotfiles when hidden files are toggled off", async () => {
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
document.querySelector(`[data-local-path="${HOME}/.zshrc"]`),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTitle("fileManager.localHideHidden"));
|
||||
expect(
|
||||
document.querySelector(`[data-local-path="${HOME}/.zshrc"]`),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText("fileManager.localItemCount:2"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a context menu for a row with entry actions", async () => {
|
||||
const onUploadToRemote = vi.fn();
|
||||
render(
|
||||
<LocalFilePane
|
||||
onRemoteItemsDropped={vi.fn()}
|
||||
onUploadToRemote={onUploadToRemote}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const row = document.querySelector(
|
||||
`[data-local-path="${HOME}/notes.txt"]`,
|
||||
)!;
|
||||
fireEvent.contextMenu(row, { clientX: 40, clientY: 50 });
|
||||
|
||||
const menu = screen.getByTestId("local-file-context-menu");
|
||||
expect(menu).toBeInTheDocument();
|
||||
expect(screen.getByText("fileManager.localOpen")).toBeInTheDocument();
|
||||
expect(screen.getByText("fileManager.rename")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("fileManager.localMoveToTrash"),
|
||||
).toBeInTheDocument();
|
||||
// Background-only actions are not offered for a row.
|
||||
expect(screen.queryByText("fileManager.newFolder")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("fileManager.localUploadToRemote"));
|
||||
expect(onUploadToRemote).toHaveBeenCalledWith([`${HOME}/notes.txt`]);
|
||||
expect(
|
||||
screen.queryByTestId("local-file-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("acts on the whole selection when right-clicking a selected row", async () => {
|
||||
const onUploadToRemote = vi.fn();
|
||||
render(
|
||||
<LocalFilePane
|
||||
onRemoteItemsDropped={vi.fn()}
|
||||
onUploadToRemote={onUploadToRemote}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const projects = document.querySelector(
|
||||
`[data-local-path="${HOME}/projects"]`,
|
||||
)!;
|
||||
const notes = document.querySelector(
|
||||
`[data-local-path="${HOME}/notes.txt"]`,
|
||||
)!;
|
||||
fireEvent.click(projects);
|
||||
fireEvent.click(notes, { metaKey: true });
|
||||
fireEvent.contextMenu(notes, { clientX: 40, clientY: 50 });
|
||||
|
||||
fireEvent.click(screen.getByText("fileManager.localUploadToRemoteMany:2"));
|
||||
expect(onUploadToRemote).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([`${HOME}/projects`, `${HOME}/notes.txt`]),
|
||||
);
|
||||
expect(onUploadToRemote.mock.calls[0][0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("offers folder-level actions on the background and creates a file", async () => {
|
||||
const api = installElectronApi();
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const body = screen
|
||||
.getByText("fileManager.localItemCount:3")
|
||||
.closest('[data-testid="local-file-pane"]')!
|
||||
.querySelector(".thin-scrollbar")!;
|
||||
fireEvent.contextMenu(body, { clientX: 100, clientY: 200 });
|
||||
|
||||
expect(screen.getByText("fileManager.newFolder")).toBeInTheDocument();
|
||||
expect(screen.getByText("fileManager.refresh")).toBeInTheDocument();
|
||||
expect(screen.queryByText("fileManager.rename")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("fileManager.newFile"));
|
||||
const input = screen.getByPlaceholderText("fileManager.newFile");
|
||||
fireEvent.change(input, { target: { value: "todo.md" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.localFs.createFile).toHaveBeenCalledWith(HOME, "todo.md"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renames inline from the context menu", async () => {
|
||||
const api = installElectronApi();
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const row = document.querySelector(
|
||||
`[data-local-path="${HOME}/notes.txt"]`,
|
||||
)!;
|
||||
fireEvent.contextMenu(row, { clientX: 40, clientY: 50 });
|
||||
fireEvent.click(screen.getByText("fileManager.rename"));
|
||||
|
||||
const input = screen.getByDisplayValue("notes.txt");
|
||||
fireEvent.change(input, { target: { value: "renamed.txt" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.localFs.rename).toHaveBeenCalledWith(
|
||||
`${HOME}/notes.txt`,
|
||||
"renamed.txt",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for confirmation before moving to the trash", async () => {
|
||||
const api = installElectronApi();
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const row = document.querySelector(
|
||||
`[data-local-path="${HOME}/notes.txt"]`,
|
||||
)!;
|
||||
fireEvent.contextMenu(row, { clientX: 40, clientY: 50 });
|
||||
fireEvent.click(screen.getByText("fileManager.localMoveToTrash"));
|
||||
|
||||
// Nothing is trashed until the toast's confirm action is clicked.
|
||||
expect(api.localFs.trash).not.toHaveBeenCalled();
|
||||
expect(sonnerToast).toHaveBeenCalledWith(
|
||||
"fileManager.localTrashConfirmSingle",
|
||||
expect.objectContaining({ action: expect.anything() }),
|
||||
);
|
||||
const call = sonnerToast.mock.calls.find(
|
||||
(c) => c[0] === "fileManager.localTrashConfirmSingle",
|
||||
)!;
|
||||
(call[1] as { action: { onClick: () => void } }).action.onClick();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.localFs.trash).toHaveBeenCalledWith([`${HOME}/notes.txt`]),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a pinned .. entry that goes up on double click and accepts drops", async () => {
|
||||
const api = installElectronApi();
|
||||
const onRemoteItemsDropped = vi.fn();
|
||||
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const parentRow = document.querySelector("[data-parent-entry]");
|
||||
expect(parentRow).not.toBeNull();
|
||||
// It is the first row, above the real entries.
|
||||
const firstRow = screen
|
||||
.getByTestId("local-file-pane")
|
||||
.querySelector(".thin-scrollbar > *");
|
||||
expect(firstRow).toBe(parentRow);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: "internal_files",
|
||||
files: ["/srv/app/a.log"],
|
||||
});
|
||||
const dataTransfer = makeDataTransfer(
|
||||
[REMOTE_FILES_DRAG_MIME, "text/plain"],
|
||||
{ "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" },
|
||||
);
|
||||
fireEvent.dragOver(parentRow!, { dataTransfer });
|
||||
fireEvent.drop(parentRow!, { dataTransfer });
|
||||
expect(onRemoteItemsDropped).toHaveBeenCalledWith(
|
||||
["/srv/app/a.log"],
|
||||
"/Users",
|
||||
);
|
||||
|
||||
fireEvent.click(parentRow!, { detail: 2 });
|
||||
await waitFor(() =>
|
||||
expect(api.localFs.list).toHaveBeenCalledWith("/Users"),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the header context menu hide and show columns", async () => {
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const kindHeader = screen.getByText("fileManager.kind");
|
||||
const kindCellsBefore = document.querySelectorAll(
|
||||
`[data-local-path="${HOME}/notes.txt"] > span`,
|
||||
).length;
|
||||
|
||||
fireEvent.contextMenu(kindHeader, { clientX: 300, clientY: 60 });
|
||||
const menu = screen.getByTestId("column-visibility-menu");
|
||||
expect(menu).toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
screen.getByRole("menuitemcheckbox", { name: "fileManager.kind" }),
|
||||
);
|
||||
|
||||
// The sortable header button is gone; only the menu's checkbox remains.
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "fileManager.kind" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
document.querySelectorAll(`[data-local-path="${HOME}/notes.txt"] > span`)
|
||||
.length,
|
||||
).toBe(kindCellsBefore - 1);
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem("termix:file-manager:columns:local:hidden")!,
|
||||
),
|
||||
).toEqual(["kind"]);
|
||||
});
|
||||
|
||||
it("ignores OS file drags (those belong to the remote grid)", async () => {
|
||||
const onRemoteItemsDropped = vi.fn();
|
||||
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const pane = screen.getByTestId("local-file-pane");
|
||||
const dataTransfer = makeDataTransfer(["Files"]);
|
||||
fireEvent.dragEnter(pane, { dataTransfer });
|
||||
expect(
|
||||
screen.queryByText("fileManager.dropToDownloadHere"),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.drop(pane, { dataTransfer });
|
||||
expect(onRemoteItemsDropped).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("puts a typed payload on drags that start in the pane", async () => {
|
||||
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const row = document.querySelector(`[data-local-path="${HOME}/notes.txt"]`);
|
||||
expect(row).not.toBeNull();
|
||||
|
||||
const dataTransfer = makeDataTransfer([]);
|
||||
fireEvent.dragStart(row!, { dataTransfer });
|
||||
expect(dataTransfer.types).toContain(LOCAL_FILES_DRAG_MIME);
|
||||
expect(JSON.parse(dataTransfer.getData("text/plain"))).toEqual({
|
||||
type: "local_files",
|
||||
paths: [`${HOME}/notes.txt`],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { TransferProgressBar } from "../../../features/file-manager/components/TransferProgressBar";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("TransferProgressBar", () => {
|
||||
it("exposes determinate progress and drives the fill with a transform", () => {
|
||||
const { container } = render(
|
||||
<TransferProgressBar value={42} label="Uploading archive" />,
|
||||
);
|
||||
|
||||
const progress = screen.getByRole("progressbar", {
|
||||
name: "Uploading archive",
|
||||
});
|
||||
expect(progress.getAttribute("aria-valuenow")).toBe("42");
|
||||
expect(progress.getAttribute("aria-busy")).toBe("false");
|
||||
expect(
|
||||
container
|
||||
.querySelector<HTMLElement>(".transfer-progress-fill")
|
||||
?.style.getPropertyValue("--transfer-progress"),
|
||||
).toBe("0.42");
|
||||
});
|
||||
|
||||
it("clamps invalid percentages before exposing them", () => {
|
||||
const { rerender } = render(
|
||||
<TransferProgressBar value={140} label="Uploading archive" />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
|
||||
"100",
|
||||
);
|
||||
|
||||
rerender(<TransferProgressBar value={-20} label="Uploading archive" />);
|
||||
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
|
||||
"0",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks unknown and stalled progress without inventing a percentage", () => {
|
||||
const { container } = render(
|
||||
<TransferProgressBar label="Reconnecting" stalled />,
|
||||
);
|
||||
|
||||
const progress = screen.getByRole("progressbar");
|
||||
expect(progress.hasAttribute("aria-valuenow")).toBe(false);
|
||||
expect(progress.getAttribute("aria-busy")).toBe("true");
|
||||
expect(progress.getAttribute("data-stalled")).toBe("true");
|
||||
expect(
|
||||
container
|
||||
.querySelector(".transfer-progress-fill")
|
||||
?.classList.contains("transfer-progress-indeterminate"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
container
|
||||
.querySelector(".transfer-progress-fill")
|
||||
?.classList.contains("transfer-progress-stalled"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatFileSize,
|
||||
runWithConcurrency,
|
||||
} from "../../../features/file-manager/file-manager-utils.js";
|
||||
|
||||
describe("formatFileSize", () => {
|
||||
it("returns a dash for undefined or null", () => {
|
||||
expect(formatFileSize(undefined)).toBe("-");
|
||||
expect(formatFileSize(null as unknown as number)).toBe("-");
|
||||
});
|
||||
|
||||
it("returns 0 B for zero", () => {
|
||||
expect(formatFileSize(0)).toBe("0 B");
|
||||
});
|
||||
|
||||
it("formats bytes without decimals", () => {
|
||||
expect(formatFileSize(512)).toBe("512 B");
|
||||
expect(formatFileSize(1023)).toBe("1023 B");
|
||||
});
|
||||
|
||||
it("formats kilobytes with one decimal under 10", () => {
|
||||
expect(formatFileSize(1024)).toBe("1.0 KB");
|
||||
expect(formatFileSize(1536)).toBe("1.5 KB");
|
||||
});
|
||||
|
||||
it("rounds to whole numbers at or above 10 units", () => {
|
||||
expect(formatFileSize(10 * 1024)).toBe("10 KB");
|
||||
expect(formatFileSize(1024 * 1024)).toBe("1.0 MB");
|
||||
});
|
||||
|
||||
it("scales up to larger units", () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024)).toBe("1.0 GB");
|
||||
expect(formatFileSize(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB");
|
||||
});
|
||||
|
||||
it("limits concurrent work", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const completed: number[] = [];
|
||||
|
||||
await runWithConcurrency([1, 2, 3, 4, 5], 2, async (item) => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
completed.push(item);
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
expect(maxActive).toBe(2);
|
||||
expect(completed.sort()).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useDragAndDrop } from "../../../../features/file-manager/hooks/useDragAndDrop.js";
|
||||
|
||||
function makeEntry(name: string, isDirectory: boolean) {
|
||||
return { name, isDirectory, isFile: !isDirectory } as FileSystemEntry;
|
||||
}
|
||||
|
||||
function makeDropEvent(entries: FileSystemEntry[], files: File[] = []) {
|
||||
const items = entries.map((entry) => ({
|
||||
webkitGetAsEntry: () => entry,
|
||||
}));
|
||||
|
||||
const dataTransfer = {
|
||||
items,
|
||||
files: Object.assign(files, { item: (i: number) => files[i] }),
|
||||
};
|
||||
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer,
|
||||
} as unknown as React.DragEvent;
|
||||
}
|
||||
|
||||
describe("useDragAndDrop", () => {
|
||||
it("hands directory entries to onItemsDropped", () => {
|
||||
const onItemsDropped = vi.fn();
|
||||
const onFilesDropped = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDragAndDrop({ onFilesDropped, onItemsDropped }),
|
||||
);
|
||||
|
||||
const dir = makeEntry("myfolder", true);
|
||||
act(() => result.current.dragHandlers.onDrop(makeDropEvent([dir])));
|
||||
|
||||
expect(onItemsDropped).toHaveBeenCalledWith([dir]);
|
||||
expect(onFilesDropped).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads entries before state updates clear dataTransfer", () => {
|
||||
const onItemsDropped = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDragAndDrop({ onFilesDropped: vi.fn(), onItemsDropped }),
|
||||
);
|
||||
|
||||
const dir = makeEntry("myfolder", true);
|
||||
const event = makeDropEvent([dir]);
|
||||
|
||||
// Mimic the browser neutering dataTransfer once the handler unwinds.
|
||||
act(() => {
|
||||
result.current.dragHandlers.onDrop(event);
|
||||
(event.dataTransfer as unknown as { items: unknown[] }).items = [];
|
||||
});
|
||||
|
||||
expect(onItemsDropped).toHaveBeenCalledWith([dir]);
|
||||
});
|
||||
|
||||
it("falls back to plain file upload when no directory is dropped", () => {
|
||||
const onFilesDropped = vi.fn();
|
||||
const onItemsDropped = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDragAndDrop({ onFilesDropped, onItemsDropped }),
|
||||
);
|
||||
|
||||
const file = new File(["hi"], "a.txt");
|
||||
act(() =>
|
||||
result.current.dragHandlers.onDrop(
|
||||
makeDropEvent([makeEntry("a.txt", false)], [file]),
|
||||
),
|
||||
);
|
||||
|
||||
expect(onItemsDropped).not.toHaveBeenCalled();
|
||||
expect(onFilesDropped).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects files over the size limit", () => {
|
||||
const onError = vi.fn();
|
||||
const onFilesDropped = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDragAndDrop({ onFilesDropped, onError, maxFileSize: 1 }),
|
||||
);
|
||||
|
||||
const big = new File(["x"], "big.bin");
|
||||
Object.defineProperty(big, "size", { value: 5 * 1024 * 1024 });
|
||||
|
||||
act(() => result.current.dragHandlers.onDrop(makeDropEvent([], [big])));
|
||||
|
||||
expect(onFilesDropped).not.toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useFileSelection } from "../../../../features/file-manager/hooks/useFileSelection.js";
|
||||
|
||||
type FileItem = {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
};
|
||||
|
||||
const f = (name: string): FileItem => ({
|
||||
name,
|
||||
type: "file",
|
||||
path: `/dir/${name}`,
|
||||
});
|
||||
|
||||
describe("useFileSelection", () => {
|
||||
it("single-selects, replacing prior selection", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a")));
|
||||
act(() => result.current.selectFile(f("b")));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
|
||||
expect(result.current.getSelectedCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("multi-selects and toggles off on repeat", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a"), true));
|
||||
act(() => result.current.selectFile(f("b"), true));
|
||||
expect(result.current.getSelectedCount()).toBe(2);
|
||||
act(() => result.current.selectFile(f("a"), true));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("reports isSelected by path", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(true);
|
||||
expect(result.current.isSelected(f("z"))).toBe(false);
|
||||
});
|
||||
|
||||
it("selects a contiguous range regardless of direction", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
const files = [f("a"), f("b"), f("c"), f("d")];
|
||||
act(() => result.current.selectRange(files, files[3], files[1]));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual([
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects all and clears", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
const files = [f("a"), f("b")];
|
||||
act(() => result.current.selectAll(files));
|
||||
expect(result.current.getSelectedCount()).toBe(2);
|
||||
act(() => result.current.clearSelection());
|
||||
expect(result.current.getSelectedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("toggleSelection adds then removes", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.toggleSelection(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(true);
|
||||
act(() => result.current.toggleSelection(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { usePaneWidth } from "@/features/file-manager/hooks/usePaneWidth";
|
||||
|
||||
const KEY = "test:pane-width";
|
||||
|
||||
function mouse(type: string, clientX: number) {
|
||||
document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
|
||||
}
|
||||
|
||||
const opts = { storageKey: KEY, defaultWidth: 224, minWidth: 160 };
|
||||
|
||||
describe("usePaneWidth", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("starts at the default and restores a stored width", () => {
|
||||
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(224);
|
||||
localStorage.setItem(KEY, "300");
|
||||
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(300);
|
||||
localStorage.setItem(KEY, "10"); // below min → ignored
|
||||
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(224);
|
||||
});
|
||||
|
||||
it("follows the drag, clamps to the minimum, and persists on release", () => {
|
||||
const { result } = renderHook(() => usePaneWidth(opts));
|
||||
act(() => {
|
||||
result.current.startResize({
|
||||
clientX: 400,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
expect(result.current.isResizing).toBe(true);
|
||||
act(() => mouse("mousemove", 460));
|
||||
expect(result.current.width).toBe(284);
|
||||
act(() => mouse("mousemove", 100));
|
||||
expect(result.current.width).toBe(160);
|
||||
act(() => mouse("mouseup", 100));
|
||||
expect(result.current.isResizing).toBe(false);
|
||||
expect(localStorage.getItem(KEY)).toBe("160");
|
||||
});
|
||||
|
||||
it("resets to the default", () => {
|
||||
localStorage.setItem(KEY, "300");
|
||||
const { result } = renderHook(() => usePaneWidth(opts));
|
||||
act(() => result.current.resetWidth());
|
||||
expect(result.current.width).toBe(224);
|
||||
expect(localStorage.getItem(KEY)).toBe("224");
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useResizableColumns } from "@/features/file-manager/hooks/useResizableColumns";
|
||||
|
||||
const columns = [
|
||||
{ key: "modified", labelKey: "m", defaultWidth: 120, minWidth: 70 },
|
||||
{ key: "size", labelKey: "s", defaultWidth: 80, minWidth: 56, maxWidth: 200 },
|
||||
];
|
||||
const STORAGE_KEY = "test:columns";
|
||||
|
||||
function mouse(type: string, clientX: number) {
|
||||
document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
|
||||
}
|
||||
|
||||
describe("useResizableColumns", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("starts from defaults and builds a grid template with a flexible name column", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
expect(result.current.gridTemplateColumns).toBe(
|
||||
"minmax(140px, 1fr) 120px 80px",
|
||||
);
|
||||
});
|
||||
|
||||
it("widens a column when its left-edge handle is dragged left and persists", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
const handle = result.current.getHandleProps("size");
|
||||
act(() => {
|
||||
handle.onMouseDown({
|
||||
clientX: 500,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
expect(result.current.resizingKey).toBe("size");
|
||||
act(() => mouse("mousemove", 460));
|
||||
expect(result.current.widths.size).toBe(120);
|
||||
act(() => mouse("mouseup", 460));
|
||||
expect(result.current.resizingKey).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual({
|
||||
modified: 120,
|
||||
size: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps to min/max and restores stored widths", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ modified: 10, size: 9999, junk: "x" }),
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
expect(result.current.widths).toEqual({ modified: 70, size: 200 });
|
||||
});
|
||||
|
||||
it("hides and shows columns, dropping them from the template", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
act(() => result.current.toggleColumn("size"));
|
||||
expect(result.current.isVisible("size")).toBe(false);
|
||||
expect(result.current.gridTemplateColumns).toBe("minmax(140px, 1fr) 120px");
|
||||
expect(result.current.visibleColumns.map((c) => c.key)).toEqual([
|
||||
"modified",
|
||||
]);
|
||||
expect(JSON.parse(localStorage.getItem(`${STORAGE_KEY}:hidden`)!)).toEqual([
|
||||
"size",
|
||||
]);
|
||||
|
||||
// A fresh mount restores the hidden set; toggling again shows it.
|
||||
const remount = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
expect(remount.result.current.isVisible("size")).toBe(false);
|
||||
act(() => remount.result.current.toggleColumn("size"));
|
||||
expect(remount.result.current.gridTemplateColumns).toBe(
|
||||
"minmax(140px, 1fr) 120px 80px",
|
||||
);
|
||||
});
|
||||
|
||||
it("resets a column to its default on double click", () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ modified: 150 }));
|
||||
const { result } = renderHook(() =>
|
||||
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
|
||||
);
|
||||
expect(result.current.widths.modified).toBe(150);
|
||||
act(() => {
|
||||
result.current.getHandleProps("modified").onDoubleClick({
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
expect(result.current.widths.modified).toBe(120);
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).modified).toBe(120);
|
||||
});
|
||||
});
|
||||
@@ -1,383 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
LOCAL_FILES_DRAG_MIME,
|
||||
REMOTE_FILES_DRAG_MIME,
|
||||
UnsafeLocalNameError,
|
||||
assertSafeLocalComponent,
|
||||
beginRemoteFilesDrag,
|
||||
buildLocalDestination,
|
||||
clampTransferConcurrency,
|
||||
describeLocalKind,
|
||||
getTransferConcurrency,
|
||||
runWithConcurrency,
|
||||
setTransferConcurrency,
|
||||
DEFAULT_TRANSFER_CONCURRENCY,
|
||||
TRANSFER_CONCURRENCY_STORAGE_KEY,
|
||||
formatLocalModified,
|
||||
isLocalFilesDrag,
|
||||
isRemoteFilesDrag,
|
||||
joinLocalPath,
|
||||
joinRemotePath,
|
||||
parseInternalFilesDragPayload,
|
||||
parseLocalFilesDragPayload,
|
||||
planRemoteDirectories,
|
||||
remoteBaseName,
|
||||
remoteDirForRelativePath,
|
||||
serializeLocalFilesDragPayload,
|
||||
sortLocalEntries,
|
||||
} from "@/features/file-manager/local-transfer-utils";
|
||||
import type { LocalFileEntry } from "@/types/electron";
|
||||
|
||||
describe("drag payloads", () => {
|
||||
it("round-trips local file payloads", () => {
|
||||
const raw = serializeLocalFilesDragPayload(["/Users/max/a.txt", "/tmp/b"]);
|
||||
expect(parseLocalFilesDragPayload(raw)).toEqual([
|
||||
"/Users/max/a.txt",
|
||||
"/tmp/b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects foreign or malformed payloads", () => {
|
||||
expect(parseLocalFilesDragPayload(null)).toBeNull();
|
||||
expect(parseLocalFilesDragPayload("not json")).toBeNull();
|
||||
expect(
|
||||
parseLocalFilesDragPayload(
|
||||
JSON.stringify({ type: "internal_files", files: ["/x"] }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseLocalFilesDragPayload(
|
||||
JSON.stringify({ type: "local_files", paths: [] }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseLocalFilesDragPayload(
|
||||
JSON.stringify({ type: "local_files", paths: [1, "", "/ok"] }),
|
||||
),
|
||||
).toEqual(["/ok"]);
|
||||
});
|
||||
|
||||
it("parses the remote grid's internal payload", () => {
|
||||
expect(
|
||||
parseInternalFilesDragPayload(
|
||||
JSON.stringify({ type: "internal_files", files: ["/srv/a", "/srv/b"] }),
|
||||
),
|
||||
).toEqual(["/srv/a", "/srv/b"]);
|
||||
expect(
|
||||
parseInternalFilesDragPayload(
|
||||
JSON.stringify({ type: "local_files", paths: ["/x"] }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("recognises drag origins from dataTransfer types", () => {
|
||||
expect(
|
||||
isLocalFilesDrag({ types: [LOCAL_FILES_DRAG_MIME, "text/plain"] }),
|
||||
).toBe(true);
|
||||
expect(isLocalFilesDrag({ types: ["Files"] })).toBe(false);
|
||||
expect(isRemoteFilesDrag({ types: [REMOTE_FILES_DRAG_MIME] })).toBe(true);
|
||||
expect(isRemoteFilesDrag({ types: ["text/plain"] })).toBe(false);
|
||||
expect(isRemoteFilesDrag(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path helpers", () => {
|
||||
it("joins remote paths without duplicate slashes", () => {
|
||||
expect(joinRemotePath("/", "a", "b")).toBe("/a/b");
|
||||
expect(joinRemotePath("/home/ubuntu/", "/proj/", "x.txt")).toBe(
|
||||
"/home/ubuntu/proj/x.txt",
|
||||
);
|
||||
expect(joinRemotePath("/home", "")).toBe("/home");
|
||||
});
|
||||
|
||||
it("joins local paths with the platform separator", () => {
|
||||
expect(joinLocalPath("/Users/max", "a.txt", "/")).toBe("/Users/max/a.txt");
|
||||
expect(joinLocalPath("/Users/max/", "a.txt", "/")).toBe("/Users/max/a.txt");
|
||||
expect(joinLocalPath("/", "a.txt", "/")).toBe("/a.txt");
|
||||
expect(joinLocalPath("C:\\Users\\max", "a.txt", "\\")).toBe(
|
||||
"C:\\Users\\max\\a.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives base names", () => {
|
||||
expect(remoteBaseName("/srv/app/file.log")).toBe("file.log");
|
||||
expect(remoteBaseName("/srv/app/")).toBe("app");
|
||||
expect(remoteBaseName("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("maps relative paths to their remote directory", () => {
|
||||
expect(remoteDirForRelativePath("/dst", "file.txt")).toBe("/dst");
|
||||
expect(remoteDirForRelativePath("/dst", "proj/src/main.rs")).toBe(
|
||||
"/dst/proj/src",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("download destination safety", () => {
|
||||
const WIN = "\\";
|
||||
const POSIX = "/";
|
||||
|
||||
it("keeps ordinary nested folders under the selected directory (Windows)", () => {
|
||||
expect(
|
||||
buildLocalDestination(
|
||||
"C:\\Downloads\\selected",
|
||||
"docs/2026/report.pdf",
|
||||
WIN,
|
||||
),
|
||||
).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf");
|
||||
expect(
|
||||
buildLocalDestination("C:\\Downloads\\selected\\", "a.txt", WIN),
|
||||
).toBe("C:\\Downloads\\selected\\a.txt");
|
||||
});
|
||||
|
||||
it("rejects backslash traversal in a POSIX file name on Windows", () => {
|
||||
// The reviewer's reproduction: "..\\outside.txt" is a legal POSIX name.
|
||||
expect(() =>
|
||||
buildLocalDestination("C:\\Downloads\\selected", "..\\outside.txt", WIN),
|
||||
).toThrow(UnsafeLocalNameError);
|
||||
expect(() =>
|
||||
buildLocalDestination(
|
||||
"C:\\Downloads\\selected",
|
||||
"sub/..\\..\\x.txt",
|
||||
WIN,
|
||||
),
|
||||
).toThrow(UnsafeLocalNameError);
|
||||
expect(() =>
|
||||
buildLocalDestination("C:\\Downloads\\selected", "dir\\file.txt", WIN),
|
||||
).toThrow(UnsafeLocalNameError);
|
||||
});
|
||||
|
||||
it("rejects absolute and drive-qualified names on Windows", () => {
|
||||
for (const name of [
|
||||
"C:\\Windows\\evil.dll",
|
||||
"C:evil.txt",
|
||||
"D:",
|
||||
"\\\\server\\share\\x",
|
||||
"\\absolute.txt",
|
||||
]) {
|
||||
expect(() =>
|
||||
buildLocalDestination("C:\\Downloads\\selected", name, WIN),
|
||||
).toThrow(UnsafeLocalNameError);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects names Windows cannot store: reserved devices, trailing dots/spaces, control chars", () => {
|
||||
for (const name of [
|
||||
"CON",
|
||||
"nul.txt",
|
||||
"COM1",
|
||||
"report.",
|
||||
"report ",
|
||||
"a\u0007b",
|
||||
"q?.txt",
|
||||
"a|b",
|
||||
]) {
|
||||
expect(() => assertSafeLocalComponent(name, WIN)).toThrow(
|
||||
UnsafeLocalNameError,
|
||||
);
|
||||
}
|
||||
expect(assertSafeLocalComponent("console.log", WIN)).toBe("console.log");
|
||||
expect(assertSafeLocalComponent("nulled.txt", WIN)).toBe("nulled.txt");
|
||||
});
|
||||
|
||||
it("rejects traversal and separators on every platform, but allows POSIX-legal backslashes on POSIX", () => {
|
||||
for (const rel of ["..", "../x", "a/../../x", "./x", "a\0b", ""]) {
|
||||
expect(() => buildLocalDestination("/home/max/dl", rel, POSIX)).toThrow(
|
||||
UnsafeLocalNameError,
|
||||
);
|
||||
}
|
||||
// On macOS/Linux a backslash is just a character in a file name and the
|
||||
// result is still inside the selected folder.
|
||||
expect(
|
||||
buildLocalDestination("/home/max/dl", "..\\outside.txt", POSIX),
|
||||
).toBe("/home/max/dl/..\\outside.txt");
|
||||
expect(buildLocalDestination("/", "etc/hosts", POSIX)).toBe("/etc/hosts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planRemoteDirectories", () => {
|
||||
it("lists every ancestor once, shallowest first", () => {
|
||||
const dirs = planRemoteDirectories(
|
||||
["proj/src/main.rs", "proj/README.md", "proj/src/lib/mod.rs", "top.txt"],
|
||||
["proj/empty", "other/nested/leaf"],
|
||||
);
|
||||
expect(dirs).toEqual([
|
||||
"other",
|
||||
"proj",
|
||||
"other/nested",
|
||||
"proj/empty",
|
||||
"proj/src",
|
||||
"other/nested/leaf",
|
||||
"proj/src/lib",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing for flat file drops", () => {
|
||||
expect(planRemoteDirectories(["a.txt", "b.txt"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote rows dragged out of the grid", () => {
|
||||
it("allows both a move (within the grid) and a copy (download onto the local pane)", () => {
|
||||
const store: Record<string, string> = {};
|
||||
const dataTransfer: Pick<DataTransfer, "effectAllowed" | "setData"> = {
|
||||
effectAllowed: "uninitialized",
|
||||
setData: (type: string, value: string) => {
|
||||
store[type] = value;
|
||||
},
|
||||
};
|
||||
beginRemoteFilesDrag(dataTransfer, ["/srv/a.txt", "/srv/dir"]);
|
||||
// Chromium silently drops nothing when dropEffect ("copy" on the local
|
||||
// pane) is not part of effectAllowed, so "move" alone breaks downloads.
|
||||
expect(dataTransfer.effectAllowed).toBe("copyMove");
|
||||
expect(isRemoteFilesDrag({ types: Object.keys(store) })).toBe(true);
|
||||
expect(parseInternalFilesDragPayload(store["text/plain"])).toEqual([
|
||||
"/srv/a.txt",
|
||||
"/srv/dir",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local entry presentation", () => {
|
||||
const entry = (
|
||||
name: string,
|
||||
type: LocalFileEntry["type"],
|
||||
size = 0,
|
||||
modifiedTimestamp = 0,
|
||||
): LocalFileEntry => ({
|
||||
name,
|
||||
path: `/x/${name}`,
|
||||
type,
|
||||
size,
|
||||
modifiedTimestamp,
|
||||
hidden: name.startsWith("."),
|
||||
});
|
||||
|
||||
it("formats modified times like the remote grid (ls -l style)", () => {
|
||||
const now = new Date(2026, 8, 12, 10, 0); // Sep 12 2026
|
||||
expect(
|
||||
formatLocalModified(new Date(2026, 8, 11, 16, 25).getTime(), now),
|
||||
).toBe("Sep 11 16:25");
|
||||
// Single-digit days are space-padded, minutes zero-padded.
|
||||
expect(formatLocalModified(new Date(2026, 7, 7, 9, 5).getTime(), now)).toBe(
|
||||
"Aug 7 09:05",
|
||||
);
|
||||
// Older than six months: year instead of time, two spaces like ls.
|
||||
expect(
|
||||
formatLocalModified(new Date(2025, 11, 24, 18, 30).getTime(), now),
|
||||
).toBe("Dec 24 2025");
|
||||
expect(formatLocalModified(undefined, now)).toBe("--");
|
||||
expect(formatLocalModified(Number.NaN, now)).toBe("--");
|
||||
});
|
||||
|
||||
it("describes kinds", () => {
|
||||
expect(describeLocalKind(entry("docs", "directory"))).toBe("folder");
|
||||
expect(describeLocalKind(entry("archive.tar.GZ", "file"))).toBe("gz");
|
||||
expect(describeLocalKind(entry(".env", "file"))).toBe("file");
|
||||
expect(describeLocalKind(entry("Makefile", "file"))).toBe("file");
|
||||
expect(describeLocalKind(entry("ln", "link"))).toBe("link");
|
||||
});
|
||||
|
||||
it("sorts folders first, then by the chosen field", () => {
|
||||
const entries = [
|
||||
entry("b.txt", "file", 20, 2),
|
||||
entry("zeta", "directory", 0, 1),
|
||||
entry("a.txt", "file", 10, 3),
|
||||
entry("alpha", "directory", 0, 4),
|
||||
];
|
||||
expect(sortLocalEntries(entries, "name", "asc").map((e) => e.name)).toEqual(
|
||||
["alpha", "zeta", "a.txt", "b.txt"],
|
||||
);
|
||||
expect(
|
||||
sortLocalEntries(entries, "size", "desc").map((e) => e.name),
|
||||
).toEqual(["zeta", "alpha", "b.txt", "a.txt"]);
|
||||
expect(
|
||||
sortLocalEntries(entries, "modified", "asc").map((e) => e.name),
|
||||
).toEqual(["zeta", "alpha", "b.txt", "a.txt"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parallel transfers", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("clamps and persists the concurrency preference", () => {
|
||||
expect(getTransferConcurrency()).toBe(DEFAULT_TRANSFER_CONCURRENCY);
|
||||
expect(clampTransferConcurrency(0)).toBe(1);
|
||||
expect(clampTransferConcurrency(99)).toBe(8);
|
||||
expect(clampTransferConcurrency("3.7")).toBe(3);
|
||||
expect(clampTransferConcurrency("nope")).toBe(DEFAULT_TRANSFER_CONCURRENCY);
|
||||
expect(setTransferConcurrency(6)).toBe(6);
|
||||
expect(localStorage.getItem(TRANSFER_CONCURRENCY_STORAGE_KEY)).toBe("6");
|
||||
expect(getTransferConcurrency()).toBe(6);
|
||||
});
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
it("never runs more than `limit` workers at once and processes everything", async () => {
|
||||
const gates = Array.from({ length: 6 }, deferred);
|
||||
const started: number[] = [];
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const run = runWithConcurrency([0, 1, 2, 3, 4, 5], 3, async (i) => {
|
||||
started.push(i);
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await gates[i].promise;
|
||||
active -= 1;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(started).toEqual([0, 1, 2]); // exactly `limit` dispatched
|
||||
gates[1].resolve();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(started).toEqual([0, 1, 2, 3]); // a free lane picks up the next item
|
||||
for (const g of gates) g.resolve();
|
||||
await run;
|
||||
expect(started).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
expect(peak).toBe(3);
|
||||
});
|
||||
|
||||
it("stops dispatching once asked to, letting in-flight items finish", async () => {
|
||||
let stop = false;
|
||||
const done: number[] = [];
|
||||
await runWithConcurrency(
|
||||
[1, 2, 3, 4, 5],
|
||||
2,
|
||||
async (i) => {
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
done.push(i);
|
||||
if (i === 2) stop = true;
|
||||
},
|
||||
() => stop,
|
||||
);
|
||||
expect(done.length).toBeLessThan(5);
|
||||
expect(done).toContain(1);
|
||||
expect(done).toContain(2);
|
||||
});
|
||||
|
||||
it("does not reject the batch when a single item fails (the worker reports it)", async () => {
|
||||
const failed: number[] = [];
|
||||
await runWithConcurrency([1, 2, 3], 2, async (i) => {
|
||||
try {
|
||||
if (i === 2) throw new Error("boom");
|
||||
} catch {
|
||||
failed.push(i);
|
||||
}
|
||||
});
|
||||
expect(failed).toEqual([2]);
|
||||
});
|
||||
|
||||
it("handles an empty list and a limit larger than the list", async () => {
|
||||
let calls = 0;
|
||||
await runWithConcurrency([], 4, async () => {
|
||||
calls += 1;
|
||||
});
|
||||
await runWithConcurrency([1], 8, async () => {
|
||||
calls += 1;
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const success = vi.fn();
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => success(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios.ts", () => ({}));
|
||||
vi.mock("../../../features/file-manager/transferNotificationStore.ts", () => ({
|
||||
markTransferNotified: vi.fn(),
|
||||
}));
|
||||
|
||||
import { showTransferCompletionToast } from "../../../features/file-manager/transferProgressMonitor.tsx";
|
||||
|
||||
describe("transfer completion toast", () => {
|
||||
beforeEach(() => {
|
||||
success.mockReset();
|
||||
});
|
||||
|
||||
it("clears the infinite duration inherited from the progress toast", () => {
|
||||
showTransferCompletionToast(
|
||||
{
|
||||
transferId: "transfer-1",
|
||||
status: "success",
|
||||
phase: "verifying",
|
||||
},
|
||||
((key: string) => key) as never,
|
||||
"progress-toast",
|
||||
);
|
||||
|
||||
expect(success).toHaveBeenCalledWith("transfer.transferSuccess", {
|
||||
id: "progress-toast",
|
||||
description: undefined,
|
||||
className: "!pr-10 !pl-4 transfer-progress-toast",
|
||||
duration: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "../../../features/guacamole/guacamole-websocket-url.js";
|
||||
|
||||
const httpsLocation = {
|
||||
protocol: "https:",
|
||||
host: "termix.example.com",
|
||||
} as Location;
|
||||
|
||||
describe("buildGuacamoleWebSocketBaseUrl", () => {
|
||||
it("uses the same origin guacamole websocket path in production web builds", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/guacamole/websocket/");
|
||||
});
|
||||
|
||||
it("preserves the runtime base path for proxied web deployments", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
||||
});
|
||||
|
||||
it("keeps direct local websocket access for development and embedded electron", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: true,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("ws://localhost:30008");
|
||||
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: true,
|
||||
isEmbeddedApp: true,
|
||||
basePath: "/termix",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("ws://127.0.0.1:30008");
|
||||
});
|
||||
|
||||
it("uses the configured remote server URL for electron remote mode", () => {
|
||||
expect(
|
||||
buildGuacamoleWebSocketBaseUrl({
|
||||
isDev: false,
|
||||
isElectronApp: true,
|
||||
isEmbeddedApp: false,
|
||||
configuredServerUrl: "https://termix.example.com/termix/",
|
||||
basePath: "",
|
||||
location: httpsLocation,
|
||||
}),
|
||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
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,
|
||||
uploadFile: async () => {},
|
||||
zoomIn: vi.fn(() => 1.25),
|
||||
zoomOut: vi.fn(() => 0.75),
|
||||
resetZoom: vi.fn(() => 1),
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
const { getByText } = render(
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol="rdp"
|
||||
metaKeyFamily="win"
|
||||
/>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("exposes VNC zoom controls without showing them for RDP", () => {
|
||||
const zoomIn = vi.fn(() => 1.25);
|
||||
const zoomOut = vi.fn(() => 0.75);
|
||||
const resetZoom = vi.fn(() => 1);
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey: vi.fn(),
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: async () => {},
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
const { getByLabelText, getByText } = render(
|
||||
<GuacamoleToolbar displayRef={displayRef} protocol="vnc" zoom={1.25} />,
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText("guacamole.toolbar.zoomOut"));
|
||||
fireEvent.click(getByLabelText("guacamole.toolbar.zoomIn"));
|
||||
fireEvent.click(getByText("125%"));
|
||||
expect(zoomOut).toHaveBeenCalledOnce();
|
||||
expect(zoomIn).toHaveBeenCalledOnce();
|
||||
expect(resetZoom).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("offers an explicit session-only hide action", () => {
|
||||
const onHide = vi.fn();
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey: vi.fn(),
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: vi.fn(),
|
||||
zoomIn: vi.fn(() => 1.25),
|
||||
zoomOut: vi.fn(() => 0.75),
|
||||
resetZoom: vi.fn(() => 1),
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
|
||||
const { getByLabelText } = render(
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol="rdp"
|
||||
onHide={onHide}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByLabelText("guacamole.toolbar.hide"));
|
||||
expect(onHide).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("labels the VNC meta key Super instead of Windows", () => {
|
||||
const displayRef = {
|
||||
current: {
|
||||
disconnect: vi.fn(),
|
||||
isConnected: () => true,
|
||||
sendKey: vi.fn(),
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: vi.fn(),
|
||||
zoomIn: vi.fn(() => 1.25),
|
||||
zoomOut: vi.fn(() => 0.75),
|
||||
resetZoom: vi.fn(() => 1),
|
||||
} satisfies GuacamoleDisplayHandle,
|
||||
} as React.RefObject<GuacamoleDisplayHandle>;
|
||||
|
||||
const { getByText, queryByText } = render(
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol="vnc"
|
||||
metaKeyFamily="super"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText("Super+L")).toBeTruthy();
|
||||
expect(getByText("Super")).toBeTruthy();
|
||||
expect(queryByText("Win+L")).toBeNull();
|
||||
expect(queryByText("Win")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isPasteShortcut,
|
||||
pasteTextToRemote,
|
||||
type GuacamoleClipboardClient,
|
||||
} from "../../../features/guacamole/guacamole-clipboard.js";
|
||||
|
||||
describe("Guacamole clipboard paste", () => {
|
||||
it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => {
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "v",
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "V",
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "v",
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("updates the remote clipboard before sending Ctrl+V", () => {
|
||||
const events: string[] = [];
|
||||
const client: GuacamoleClipboardClient = {
|
||||
createClipboardStream: vi.fn((mimetype: string) => {
|
||||
events.push(`stream:${mimetype}`);
|
||||
return {
|
||||
sendBlob: () => events.push("blob"),
|
||||
sendEnd: () => events.push("end"),
|
||||
};
|
||||
}),
|
||||
sendKeyEvent: vi.fn((pressed: number, keysym: number) => {
|
||||
events.push(`key:${pressed}:${keysym}`);
|
||||
}),
|
||||
};
|
||||
|
||||
pasteTextToRemote(client, "Firefox clipboard");
|
||||
|
||||
expect(events).toEqual([
|
||||
"stream:text/plain",
|
||||
"blob",
|
||||
"end",
|
||||
"key:1:65507",
|
||||
"key:1:118",
|
||||
"key:0:118",
|
||||
"key:0:65507",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getGuacamoleDisplaySize,
|
||||
readConfiguredDimension,
|
||||
} from "../../../features/guacamole/guacamole-display-size";
|
||||
|
||||
describe("getGuacamoleDisplaySize", () => {
|
||||
it("requests native pixels and matching DPI for HiDPI RDP", () => {
|
||||
expect(getGuacamoleDisplaySize(1280, 720, "rdp", 2)).toEqual({
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
dpi: 192,
|
||||
pixelRatio: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("scales a configured RDP DPI with the device pixel ratio", () => {
|
||||
expect(getGuacamoleDisplaySize(1000, 600, "rdp", 1.5, 120)).toEqual({
|
||||
width: 1500,
|
||||
height: 900,
|
||||
dpi: 180,
|
||||
pixelRatio: 1.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves non-RDP protocols at CSS-pixel dimensions", () => {
|
||||
expect(getGuacamoleDisplaySize(1280, 720, "vnc", 2)).toEqual({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
pixelRatio: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("caps pathological pixel ratios to protect remote session size", () => {
|
||||
expect(getGuacamoleDisplaySize(400, 800, "rdp", 4)).toEqual({
|
||||
width: 1200,
|
||||
height: 2400,
|
||||
dpi: 288,
|
||||
pixelRatio: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a configured resolution in place of the container size", () => {
|
||||
expect(
|
||||
getGuacamoleDisplaySize(800, 600, "rdp", 1, undefined),
|
||||
).toMatchObject({ width: 800, height: 600 });
|
||||
expect(
|
||||
getGuacamoleDisplaySize(1920, 1080, "rdp", 1, undefined),
|
||||
).toMatchObject({ width: 1920, height: 1080 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("readConfiguredDimension", () => {
|
||||
it("accepts the strings the host editor stores", () => {
|
||||
expect(readConfiguredDimension("1920")).toBe(1920);
|
||||
expect(readConfiguredDimension(1080)).toBe(1080);
|
||||
});
|
||||
|
||||
it("treats an unset or unusable value as 'follow the container'", () => {
|
||||
expect(readConfiguredDimension("")).toBeUndefined();
|
||||
expect(readConfiguredDimension(undefined)).toBeUndefined();
|
||||
expect(readConfiguredDimension(null)).toBeUndefined();
|
||||
expect(readConfiguredDimension("auto")).toBeUndefined();
|
||||
expect(readConfiguredDimension("0")).toBeUndefined();
|
||||
expect(readConfiguredDimension("-1080")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canUploadToRdpDrive,
|
||||
getFileDropDisposition,
|
||||
hasDraggedFiles,
|
||||
} from "@/features/guacamole/guacamole-file-drop.ts";
|
||||
|
||||
describe("Guacamole file drop", () => {
|
||||
it("recognizes external file drags", () => {
|
||||
expect(hasDraggedFiles(["Files"])).toBe(true);
|
||||
expect(hasDraggedFiles(["text/plain"])).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects files when upload is unavailable instead of ignoring them", () => {
|
||||
expect(getFileDropDisposition(["Files"], 1, false)).toBe("reject");
|
||||
expect(getFileDropDisposition(["Files"], 1, true)).toBe("upload");
|
||||
});
|
||||
|
||||
it("accepts an enabled RDP drive before a filesystem object is advertised", () => {
|
||||
expect(canUploadToRdpDrive(true, true, false)).toBe(true);
|
||||
expect(canUploadToRdpDrive(true, false, true)).toBe(true);
|
||||
expect(canUploadToRdpDrive(true, false, false)).toBe(false);
|
||||
expect(canUploadToRdpDrive(false, true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
STREAM_INDEX_MIMETYPE,
|
||||
basename,
|
||||
isDirectoryMimetype,
|
||||
joinPath,
|
||||
parentPath,
|
||||
parseDirectoryIndex,
|
||||
createClientFileStream,
|
||||
type GuacamoleFileStreamClient,
|
||||
} from "../../../features/guacamole/guacamole-filesystem.js";
|
||||
import type Guacamole from "guacamole-common-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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("direct RDP upload", () => {
|
||||
it("opens a connection-level file stream when no filesystem object is available", () => {
|
||||
const stream = {} as Guacamole.OutputStream;
|
||||
const client = {
|
||||
createFileStream: vi.fn(() => stream),
|
||||
} as GuacamoleFileStreamClient;
|
||||
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
|
||||
|
||||
expect(createClientFileStream(client, file)).toBe(stream);
|
||||
expect(client.createFileStream).toHaveBeenCalledWith(
|
||||
"text/plain",
|
||||
"notes.txt",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectMetaKeyFamily,
|
||||
metaKeyLabels,
|
||||
} from "../../../features/guacamole/guacamole-meta-key.js";
|
||||
|
||||
describe("guacamole toolbar meta key labels", () => {
|
||||
it("keeps Windows labels for RDP regardless of the local device", () => {
|
||||
expect(
|
||||
detectMetaKeyFamily("rdp", {
|
||||
platform: "MacIntel",
|
||||
userAgent: "Macintosh; Intel Mac OS X",
|
||||
userAgentDataPlatform: "macOS",
|
||||
}),
|
||||
).toBe("win");
|
||||
});
|
||||
|
||||
it("labels VNC with the local device's modifier instead of Windows", () => {
|
||||
expect(
|
||||
detectMetaKeyFamily("vnc", {
|
||||
platform: "MacIntel",
|
||||
userAgent: "Macintosh; Intel Mac OS X",
|
||||
userAgentDataPlatform: "macOS",
|
||||
}),
|
||||
).toBe("cmd");
|
||||
expect(
|
||||
detectMetaKeyFamily("vnc", {
|
||||
platform: "Linux x86_64",
|
||||
userAgent: "X11; Linux x86_64",
|
||||
userAgentDataPlatform: "Linux",
|
||||
}),
|
||||
).toBe("super");
|
||||
expect(
|
||||
detectMetaKeyFamily("vnc", {
|
||||
platform: "Win32",
|
||||
userAgent: "Windows NT 10.0",
|
||||
userAgentDataPlatform: "Windows",
|
||||
}),
|
||||
).toBe("win");
|
||||
});
|
||||
|
||||
it("exposes Super/Cmd button copy for non-Windows VNC sessions", () => {
|
||||
expect(metaKeyLabels("super").short).toBe("Super");
|
||||
expect(metaKeyLabels("super").lock).toBe("Super+L");
|
||||
expect(metaKeyLabels("cmd").short).toBe("Cmd");
|
||||
expect(metaKeyLabels("cmd").lock).toBe("Cmd+L");
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
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);
|
||||
});
|
||||
|
||||
it("does not forward a wheel flood as individual remote scroll clicks", () => {
|
||||
vi.useFakeTimers();
|
||||
const element = document.createElement("div");
|
||||
const onState = vi.fn();
|
||||
const dispose = bindPointerInput(element, null, onState);
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
element.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: 120,
|
||||
deltaMode: 0,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const presses = onState.mock.calls.filter(
|
||||
([state]) => state.up === true || state.down === true,
|
||||
);
|
||||
expect(presses.length).toBeLessThanOrEqual(2);
|
||||
|
||||
vi.advanceTimersByTime(40);
|
||||
const laterPresses = onState.mock.calls.filter(
|
||||
([state]) => state.up === true || state.down === true,
|
||||
);
|
||||
expect(laterPresses.length).toBeLessThanOrEqual(4);
|
||||
|
||||
dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clampPendingScrollClicks,
|
||||
createScrollCoalescer,
|
||||
isScrollButtonState,
|
||||
MAX_SCROLL_CLICKS_PER_FLUSH,
|
||||
SCROLL_FLUSH_INTERVAL_MS,
|
||||
takeScrollClicks,
|
||||
} from "../../../features/guacamole/guacamole-scroll.js";
|
||||
|
||||
describe("guacamole scroll coalescing", () => {
|
||||
it("caps a click flood so a laggy tunnel cannot queue a backlog", () => {
|
||||
expect(clampPendingScrollClicks(0, 40)).toBe(MAX_SCROLL_CLICKS_PER_FLUSH);
|
||||
expect(clampPendingScrollClicks(0, -40)).toBe(-MAX_SCROLL_CLICKS_PER_FLUSH);
|
||||
expect(takeScrollClicks(20)).toEqual({
|
||||
clicks: MAX_SCROLL_CLICKS_PER_FLUSH,
|
||||
direction: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the first notch immediately and drops later clicks in the same burst", () => {
|
||||
const send = vi.fn();
|
||||
let now = 1_000;
|
||||
const scheduled: Array<{ fn: () => void; ms: number }> = [];
|
||||
const coalescer = createScrollCoalescer(
|
||||
send,
|
||||
() => now,
|
||||
(fn, ms) => {
|
||||
scheduled.push({ fn, ms });
|
||||
return scheduled.length;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
coalescer.notePointer({
|
||||
x: 12,
|
||||
y: 34,
|
||||
left: false,
|
||||
middle: false,
|
||||
right: false,
|
||||
up: false,
|
||||
down: false,
|
||||
});
|
||||
for (let i = 0; i < 20; i++) coalescer.ingestClick(1);
|
||||
|
||||
const firstBurst = send.mock.calls.map(([state]) => state);
|
||||
expect(firstBurst).toEqual([
|
||||
{
|
||||
x: 12,
|
||||
y: 34,
|
||||
left: false,
|
||||
middle: false,
|
||||
right: false,
|
||||
up: false,
|
||||
down: true,
|
||||
},
|
||||
{
|
||||
x: 12,
|
||||
y: 34,
|
||||
left: false,
|
||||
middle: false,
|
||||
right: false,
|
||||
up: false,
|
||||
down: false,
|
||||
},
|
||||
]);
|
||||
|
||||
send.mockClear();
|
||||
now += SCROLL_FLUSH_INTERVAL_MS;
|
||||
expect(scheduled).toHaveLength(1);
|
||||
scheduled[0].fn();
|
||||
expect(send.mock.calls).toHaveLength(MAX_SCROLL_CLICKS_PER_FLUSH * 2);
|
||||
coalescer.dispose();
|
||||
});
|
||||
|
||||
it("recognizes only the scroll buttons as coalesced input", () => {
|
||||
expect(isScrollButtonState({ up: true, down: false })).toBe(true);
|
||||
expect(isScrollButtonState({ up: false, down: true })).toBe(true);
|
||||
expect(isScrollButtonState({ up: false, down: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
const fetchId = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/guacamole-api", () => ({ getGuacamoleConnectionId: fetchId }));
|
||||
import { watchGuacamoleConnectionId } from "../../../features/guacamole/guacamole-session-id";
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchId.mockReset();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
it("discovers the sharing ID after handshake without changing the connection origin", async () => {
|
||||
fetchId.mockResolvedValueOnce(null).mockResolvedValueOnce("guacd-id");
|
||||
const ready = vi.fn();
|
||||
const stop = watchGuacamoleConnectionId("connect-id", "remote", ready);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(fetchId).toHaveBeenLastCalledWith(
|
||||
"connect-id",
|
||||
"remote",
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(ready).toHaveBeenCalledExactlyOnceWith("guacd-id");
|
||||
stop();
|
||||
});
|
||||
it("ignores a late response after disconnect or replacement", async () => {
|
||||
let resolve!: (id: string) => void;
|
||||
fetchId.mockReturnValue(
|
||||
new Promise<string>((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
);
|
||||
const ready = vi.fn();
|
||||
const stop = watchGuacamoleConnectionId("old", "local", ready);
|
||||
stop();
|
||||
resolve("old-guacd-id");
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(ready).not.toHaveBeenCalled();
|
||||
expect(fetchId.mock.calls[0][2].aborted).toBe(true);
|
||||
});
|
||||
it("bounds discovery when the session never opens", async () => {
|
||||
fetchId.mockResolvedValue(null);
|
||||
const stop = watchGuacamoleConnectionId("missing", "local", vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(fetchId).toHaveBeenCalledTimes(20);
|
||||
stop();
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { needsRdpCredentialPrompt } from "@/features/guacamole/rdp-credential-prompt";
|
||||
|
||||
describe("needsRdpCredentialPrompt", () => {
|
||||
it("prompts recipients when RDP has no saved authentication", () => {
|
||||
expect(
|
||||
needsRdpCredentialPrompt({ protocol: "rdp", rdpAuthType: "none" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not prompt when the recipient has a personal override", () => {
|
||||
expect(
|
||||
needsRdpCredentialPrompt({
|
||||
protocol: "rdp",
|
||||
rdpAuthType: "none",
|
||||
authOverrides: {
|
||||
rdp: { credentialId: 7, required: false, ownerAuthShared: true },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not prompt for other protocols", () => {
|
||||
expect(
|
||||
needsRdpCredentialPrompt({ protocol: "vnc", rdpAuthType: "none" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { ClockWidget } from "../../../features/homepage/widgets/ClockWidget";
|
||||
import { ClockEditForm } from "../../../features/homepage/dialogs/ClockEditForm";
|
||||
import {
|
||||
normalizeTimezone,
|
||||
validateClockTimezone,
|
||||
} from "../../../features/homepage/clock-timezone";
|
||||
import type { CanvasWidget, ClockConfig } from "@/types/homepage-types";
|
||||
|
||||
const widget: CanvasWidget = {
|
||||
id: 1,
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: {},
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 8,
|
||||
h: 5,
|
||||
zOrder: 0,
|
||||
};
|
||||
|
||||
function renderClock(config: Partial<ClockConfig> = {}) {
|
||||
return render(
|
||||
<ClockWidget
|
||||
widget={widget}
|
||||
config={{ showSeconds: false, format: "24h", ...config }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderForm(config: Partial<ClockConfig> = {}) {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ClockEditForm
|
||||
config={{ showSeconds: false, format: "24h", ...config }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
onChange,
|
||||
input: screen.getByPlaceholderText(/America\/New_York/) as HTMLInputElement,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("ClockWidget timezone handling", () => {
|
||||
it("renders instead of throwing when a saved config holds an invalid timezone", () => {
|
||||
// Regression: "America/New York" (space, not underscore) made
|
||||
// toLocaleTimeString throw a RangeError and took down the whole canvas.
|
||||
expect(() => renderClock({ timezone: "America/New York" })).not.toThrow();
|
||||
expect(screen.queryByText("America/New York")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to local time formatting for an invalid timezone", () => {
|
||||
renderClock({ timezone: "Not/AZone" });
|
||||
const local = new Date().toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
expect(screen.getByText(local)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps and displays a valid timezone", () => {
|
||||
renderClock({ timezone: "America/New_York" });
|
||||
expect(screen.getByText("America/New_York")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no timezone label when none is configured", () => {
|
||||
const { container } = renderClock();
|
||||
expect(
|
||||
container.querySelector('[class*="text-muted-foreground/60"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("formats in the configured timezone, not the host timezone", () => {
|
||||
renderClock({ timezone: "Asia/Tokyo" });
|
||||
const tokyo = new Date().toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Asia/Tokyo",
|
||||
});
|
||||
expect(screen.getByText(tokyo)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateClockTimezone", () => {
|
||||
it("turns spaces into underscores", () => {
|
||||
expect(validateClockTimezone("America/New York")).toEqual({
|
||||
timezone: "America/New_York",
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(validateClockTimezone(" Europe/Paris ")).toEqual({
|
||||
timezone: "Europe/Paris",
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unusable zone as invalid but still returns it normalized", () => {
|
||||
expect(validateClockTimezone("Not/AZone")).toEqual({
|
||||
timezone: "Not/AZone",
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats blank input as clearing the timezone", () => {
|
||||
expect(validateClockTimezone(" ")).toEqual({
|
||||
timezone: undefined,
|
||||
valid: true,
|
||||
});
|
||||
expect(validateClockTimezone(undefined)).toEqual({
|
||||
timezone: undefined,
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes every run of whitespace", () => {
|
||||
expect(normalizeTimezone("America/Argentina/Buenos Aires")).toBe(
|
||||
"America/Argentina/Buenos_Aires",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClockEditForm", () => {
|
||||
it("stores what the user typed without rewriting it mid-edit", () => {
|
||||
const { onChange, input } = renderForm();
|
||||
fireEvent.change(input, { target: { value: "America/Los Angeles" } });
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timezone: "America/Los Angeles" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags an unusable zone the same way FolderMetadataDialog flags a duplicate name", () => {
|
||||
const { input } = renderForm({ timezone: "Not/AZone" });
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(input.className).toContain("border-destructive");
|
||||
expect(screen.getByText("homepage.invalidTimezone")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts a zone typed with a space, since it is normalized before checking", () => {
|
||||
const { input } = renderForm({ timezone: "America/New York" });
|
||||
expect(input.getAttribute("aria-invalid")).toBe("false");
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
});
|
||||
|
||||
it("says nothing about an empty field", () => {
|
||||
const { input } = renderForm();
|
||||
expect(input.getAttribute("aria-invalid")).toBe("false");
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the timezone when the field is emptied", () => {
|
||||
const { onChange, input } = renderForm({ timezone: "Europe/Paris" });
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timezone: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the other clock settings when the timezone changes", () => {
|
||||
const { onChange, input } = renderForm({
|
||||
showSeconds: true,
|
||||
format: "12h",
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "UTC" } });
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
timezone: "UTC",
|
||||
showSeconds: true,
|
||||
format: "12h",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { WidgetEditDialog } from "../../../features/homepage/dialogs/WidgetEditDialog";
|
||||
import type { CanvasWidget } from "@/types/homepage-types";
|
||||
|
||||
function clockWidget(timezone?: string): CanvasWidget {
|
||||
return {
|
||||
id: 7,
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: { timezone, showSeconds: true, format: "24h" },
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 8,
|
||||
h: 5,
|
||||
zOrder: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function open(timezone?: string) {
|
||||
const onSave = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<WidgetEditDialog
|
||||
widget={clockWidget(timezone)}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
onSave,
|
||||
onClose,
|
||||
input: screen.getByPlaceholderText(/America\/New_York/) as HTMLInputElement,
|
||||
save: screen
|
||||
.getByText("homepage.save")
|
||||
.closest("button") as HTMLButtonElement,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("WidgetEditDialog clock timezone validation", () => {
|
||||
it("stores the normalized zone when the user typed a space", () => {
|
||||
const { onSave, onClose, input, save } = open();
|
||||
fireEvent.change(input, { target: { value: "America/New York" } });
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
expect(save.disabled).toBe(false);
|
||||
|
||||
fireEvent.click(save);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Clock",
|
||||
expect.objectContaining({ timezone: "America/New_York" }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Save while the zone is unusable", () => {
|
||||
const { onSave, onClose, input, save } = open();
|
||||
fireEvent.change(input, { target: { value: "Not/AZone" } });
|
||||
|
||||
expect(save.disabled).toBe(true);
|
||||
expect(screen.getByText("homepage.invalidTimezone")).toBeTruthy();
|
||||
|
||||
fireEvent.click(save);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-enables Save once the zone becomes usable", () => {
|
||||
const { onSave, input, save } = open();
|
||||
fireEvent.change(input, { target: { value: "Not/AZone" } });
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(input, { target: { value: "Asia/Tokyo" } });
|
||||
expect(save.disabled).toBe(false);
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
|
||||
fireEvent.click(save);
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Clock",
|
||||
expect.objectContaining({ timezone: "Asia/Tokyo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves a blank field as no timezone", () => {
|
||||
const { onSave, input, save } = open("Europe/Paris");
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
fireEvent.click(save);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Clock",
|
||||
expect.objectContaining({ timezone: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
screenToCanvas,
|
||||
canvasToScreen,
|
||||
zoomAroundPoint,
|
||||
} from "../../../../features/homepage/canvas/canvasGeometry";
|
||||
|
||||
const PAN = { x: 100, y: 200 };
|
||||
const ZOOM = 2;
|
||||
|
||||
describe("screenToCanvas", () => {
|
||||
it("inverts canvasToScreen", () => {
|
||||
const canvas = { x: 50, y: 75 };
|
||||
const screen = canvasToScreen(canvas.x, canvas.y, PAN, ZOOM);
|
||||
const back = screenToCanvas(screen.x, screen.y, PAN, ZOOM);
|
||||
expect(back.x).toBeCloseTo(canvas.x);
|
||||
expect(back.y).toBeCloseTo(canvas.y);
|
||||
});
|
||||
|
||||
it("accounts for pan offset", () => {
|
||||
const result = screenToCanvas(100, 200, PAN, 1);
|
||||
expect(result.x).toBe(0);
|
||||
expect(result.y).toBe(0);
|
||||
});
|
||||
|
||||
it("accounts for zoom", () => {
|
||||
const result = screenToCanvas(300, 400, PAN, ZOOM);
|
||||
expect(result.x).toBe((300 - 100) / 2);
|
||||
expect(result.y).toBe((400 - 200) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvasToScreen", () => {
|
||||
it("maps origin with pan offset", () => {
|
||||
const result = canvasToScreen(0, 0, PAN, ZOOM);
|
||||
expect(result.x).toBe(100);
|
||||
expect(result.y).toBe(200);
|
||||
});
|
||||
|
||||
it("scales by zoom", () => {
|
||||
const result = canvasToScreen(10, 20, PAN, ZOOM);
|
||||
expect(result.x).toBe(120);
|
||||
expect(result.y).toBe(240);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zoomAroundPoint", () => {
|
||||
it("keeps the mouse point on the same canvas coordinate", () => {
|
||||
const mouseX = 400;
|
||||
const mouseY = 300;
|
||||
const pan = { x: 0, y: 0 };
|
||||
const oldZoom = 1;
|
||||
const newZoom = 2;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
|
||||
|
||||
// Canvas coordinate under mouse before zoom
|
||||
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
|
||||
// Canvas coordinate under mouse after zoom with newPan
|
||||
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
|
||||
|
||||
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
|
||||
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
|
||||
});
|
||||
|
||||
it("returns correct pan for zoom-out", () => {
|
||||
const mouseX = 200;
|
||||
const mouseY = 150;
|
||||
const pan = { x: 50, y: 50 };
|
||||
const oldZoom = 2;
|
||||
const newZoom = 1;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
|
||||
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
|
||||
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
|
||||
|
||||
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
|
||||
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
snapToGrid,
|
||||
snapToGridFloor,
|
||||
} from "../../../../features/homepage/canvas/snapToGrid";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
describe("snapToGrid", () => {
|
||||
it("snaps to nearest grid multiple", () => {
|
||||
expect(snapToGrid(0)).toBe(0);
|
||||
expect(snapToGrid(GRID_SIZE)).toBe(GRID_SIZE);
|
||||
expect(snapToGrid(GRID_SIZE * 2)).toBe(GRID_SIZE * 2);
|
||||
});
|
||||
|
||||
it("rounds to nearest (up when exactly halfway)", () => {
|
||||
expect(snapToGrid(GRID_SIZE / 2)).toBe(GRID_SIZE);
|
||||
expect(snapToGrid(GRID_SIZE * 1.5)).toBe(GRID_SIZE * 2);
|
||||
});
|
||||
|
||||
it("rounds down when below halfway", () => {
|
||||
expect(snapToGrid(GRID_SIZE * 0.4)).toBe(0);
|
||||
expect(snapToGrid(GRID_SIZE * 1.4)).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it("works with negative values", () => {
|
||||
expect(snapToGrid(-GRID_SIZE)).toBe(-GRID_SIZE);
|
||||
expect(snapToGrid(-GRID_SIZE * 0.4)).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapToGridFloor", () => {
|
||||
it("always rounds down to floor multiple", () => {
|
||||
expect(snapToGridFloor(GRID_SIZE - 1)).toBe(0);
|
||||
expect(snapToGridFloor(GRID_SIZE)).toBe(GRID_SIZE);
|
||||
expect(snapToGridFloor(GRID_SIZE + 1)).toBe(GRID_SIZE);
|
||||
expect(snapToGridFloor(GRID_SIZE * 2 - 1)).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it("handles zero", () => {
|
||||
expect(snapToGridFloor(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import type { GpuMetrics } from "@/types/stats-widgets";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { GpuCard } from "../../../features/host-metrics/cards/GpuCard";
|
||||
|
||||
const GPU_METRICS: GpuMetrics = {
|
||||
source: "nvidia-smi",
|
||||
gpus: [
|
||||
{
|
||||
index: 0,
|
||||
uuid: "GPU-rtx",
|
||||
name: "NVIDIA GeForce RTX 4090",
|
||||
driverVersion: "550.54.14",
|
||||
utilizationPercent: 37,
|
||||
memoryUsedMiB: 8123,
|
||||
memoryTotalMiB: 24564,
|
||||
memoryPercent: 33.1,
|
||||
temperatureCelsius: 61,
|
||||
powerDrawWatts: 182.4,
|
||||
powerLimitWatts: 450,
|
||||
fanPercent: 45,
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
uuid: "GPU-h100",
|
||||
name: "NVIDIA H100 80GB HBM3",
|
||||
driverVersion: "550.54.14",
|
||||
utilizationPercent: 0,
|
||||
memoryUsedMiB: 1,
|
||||
memoryTotalMiB: 81559,
|
||||
memoryPercent: 0,
|
||||
temperatureCelsius: 34,
|
||||
powerDrawWatts: null,
|
||||
powerLimitWatts: 700,
|
||||
fanPercent: null,
|
||||
},
|
||||
],
|
||||
processes: [
|
||||
{
|
||||
gpuIndex: 0,
|
||||
pid: 12345,
|
||||
name: "/usr/bin/python3",
|
||||
memoryUsedMiB: 7890,
|
||||
},
|
||||
{ gpuIndex: 1, pid: 23456, name: "worker", memoryUsedMiB: null },
|
||||
],
|
||||
};
|
||||
|
||||
function renderCard(gpu: GpuMetrics | undefined, gpuHistories = {}) {
|
||||
const metrics = { gpu } as ServerMetrics;
|
||||
render(<GpuCard metrics={metrics} gpuHistories={gpuHistories} />);
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("GpuCard", () => {
|
||||
it("explains that no NVIDIA GPU was found", () => {
|
||||
renderCard({ source: "none", gpus: [], processes: [] });
|
||||
|
||||
expect(screen.getByText("hostMetrics.gpu.noDevice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats metrics from a backend without gpu support as no GPU", () => {
|
||||
renderCard(undefined);
|
||||
|
||||
expect(screen.getByText("hostMetrics.gpu.noDevice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the readings of every GPU", () => {
|
||||
renderCard(GPU_METRICS);
|
||||
|
||||
expect(screen.getByText("NVIDIA GeForce RTX 4090")).toBeInTheDocument();
|
||||
expect(screen.getByText("NVIDIA H100 80GB HBM3")).toBeInTheDocument();
|
||||
expect(screen.getByText("7.9/24.0G")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.0/79.6G")).toBeInTheDocument();
|
||||
expect(screen.getByText("61°C")).toBeInTheDocument();
|
||||
expect(screen.getByText("182/450W")).toBeInTheDocument();
|
||||
expect(screen.getByText("N/A/700W")).toBeInTheDocument();
|
||||
expect(screen.getByText("45%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("hostMetrics.gpu.noDevice")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws each GPU's history as labelled utilization and VRAM trends", () => {
|
||||
renderCard(
|
||||
{ ...GPU_METRICS, gpus: [GPU_METRICS.gpus[0]] },
|
||||
{ 0: { utilization: [10, 20], memory: [30, 31] } },
|
||||
);
|
||||
|
||||
for (const name of [
|
||||
"hostMetrics.gpu.utilization",
|
||||
"hostMetrics.gpu.memory",
|
||||
]) {
|
||||
const trend = screen.getByRole("figure", { name });
|
||||
expect(trend.querySelector("svg")).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("lists GPU processes by executable name with the GPU and VRAM they hold", () => {
|
||||
renderCard(GPU_METRICS);
|
||||
|
||||
const python = screen.getByText("python3");
|
||||
expect(python).toHaveAttribute("title", "/usr/bin/python3");
|
||||
expect(screen.getByText("7.7G · GPU 0")).toBeInTheDocument();
|
||||
expect(screen.getByText("N/A · GPU 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when no process is using a GPU", () => {
|
||||
renderCard({ ...GPU_METRICS, processes: [] });
|
||||
|
||||
expect(screen.getByText("hostMetrics.gpu.noProcesses")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const SAMPLE = {
|
||||
cpu: { percent: 12, cores: 8, load: [0.5, 0.4, 0.3] },
|
||||
memory: { percent: 40, usedGiB: 12.8, totalGiB: 32 },
|
||||
disk: { percent: 55 },
|
||||
gpu: {
|
||||
source: "nvidia-smi",
|
||||
gpus: [
|
||||
{
|
||||
index: 0,
|
||||
uuid: "GPU-0",
|
||||
name: "NVIDIA GeForce RTX 2070 SUPER",
|
||||
driverVersion: "580.173.02",
|
||||
utilizationPercent: 0,
|
||||
memoryUsedMiB: 5689,
|
||||
memoryTotalMiB: 8192,
|
||||
memoryPercent: 69.4,
|
||||
temperatureCelsius: 35,
|
||||
powerDrawWatts: 24,
|
||||
powerLimitWatts: 215,
|
||||
fanPercent: 0,
|
||||
},
|
||||
],
|
||||
processes: [],
|
||||
},
|
||||
lastChecked: "2026-09-18T07:00:00.000Z",
|
||||
} as ServerMetrics;
|
||||
|
||||
vi.mock("@/main-axios.ts", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/main-axios.ts")>()),
|
||||
getServerStatusById: vi.fn(async () => ({ status: "online" })),
|
||||
getServerMetricsById: vi.fn(async () => SAMPLE),
|
||||
startMetricsPolling: vi.fn(async () => ({ viewerSessionId: "viewer-1" })),
|
||||
stopMetricsPolling: vi.fn(async () => undefined),
|
||||
sendMetricsHeartbeat: vi.fn(async () => true),
|
||||
getSnippets: vi.fn(async () => []),
|
||||
getSSHHosts: vi.fn(async () => []),
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/UiPreferencesContext", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/contexts/UiPreferencesContext")>()),
|
||||
useAreaPreferences: () => ({ columns: 3 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-confirmation.ts", () => ({
|
||||
useConfirmation: () => ({ confirmWithToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../../features/host-metrics/hooks/useHostMetricsPreferences.ts",
|
||||
() => ({
|
||||
useHostMetricsPreferences: () => ({
|
||||
layout: {
|
||||
slots: [{ id: "gpu", order: 0, colSpan: 2, height: null }],
|
||||
columns: 3,
|
||||
},
|
||||
setLayout: vi.fn(),
|
||||
loaded: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
import { HostMetricsTab } from "../../../features/host-metrics/HostMetricsTab";
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver ??= class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("HostMetricsTab", () => {
|
||||
it("draws the trend lines from the first metrics sample, before the next poll", async () => {
|
||||
render(
|
||||
<HostMetricsTab
|
||||
hostConfig={{
|
||||
id: 1,
|
||||
name: "gpu-box",
|
||||
ip: "10.0.0.5",
|
||||
username: "admin",
|
||||
port: 22,
|
||||
authType: "password",
|
||||
statsConfig: JSON.stringify({
|
||||
metricsEnabled: true,
|
||||
metricsInterval: 30,
|
||||
enabledWidgets: ["gpu"],
|
||||
}),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trend = await screen.findByRole(
|
||||
"figure",
|
||||
{ name: "hostMetrics.gpu.memory" },
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(trend.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GpuDevice } from "@/types/stats-widgets";
|
||||
import { appendGpuHistories } from "../../../features/host-metrics/cards/gpu-history";
|
||||
|
||||
function gpu(
|
||||
index: number,
|
||||
utilizationPercent: number | null,
|
||||
memoryPercent: number | null,
|
||||
): GpuDevice {
|
||||
return {
|
||||
index,
|
||||
uuid: `GPU-${index}`,
|
||||
name: `GPU ${index}`,
|
||||
driverVersion: "550.54.14",
|
||||
utilizationPercent,
|
||||
memoryUsedMiB: null,
|
||||
memoryTotalMiB: null,
|
||||
memoryPercent,
|
||||
temperatureCelsius: null,
|
||||
powerDrawWatts: null,
|
||||
powerLimitWatts: null,
|
||||
fanPercent: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("appendGpuHistories", () => {
|
||||
it("records utilization and memory in a separate series per GPU", () => {
|
||||
const first = appendGpuHistories({}, [gpu(0, 10, 20), gpu(1, 90, 5)], 30);
|
||||
const second = appendGpuHistories(
|
||||
first,
|
||||
[gpu(0, 15, 25), gpu(1, 80, 6)],
|
||||
30,
|
||||
);
|
||||
|
||||
expect(second).toEqual({
|
||||
0: { utilization: [10, 15], memory: [20, 25] },
|
||||
1: { utilization: [90, 80], memory: [5, 6] },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps only the newest samples once the series is full", () => {
|
||||
let histories = {};
|
||||
for (const value of [1, 2, 3, 4]) {
|
||||
histories = appendGpuHistories(histories, [gpu(0, value, value)], 3);
|
||||
}
|
||||
|
||||
expect(histories).toEqual({
|
||||
0: { utilization: [2, 3, 4], memory: [2, 3, 4] },
|
||||
});
|
||||
});
|
||||
|
||||
it("records an unavailable reading as zero", () => {
|
||||
expect(appendGpuHistories({}, [gpu(0, null, null)], 30)).toEqual({
|
||||
0: { utilization: [0], memory: [0] },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops the series of a GPU that is no longer reported", () => {
|
||||
const before = appendGpuHistories({}, [gpu(0, 10, 10), gpu(1, 20, 20)], 30);
|
||||
|
||||
expect(appendGpuHistories(before, [gpu(0, 11, 11)], 30)).toEqual({
|
||||
0: { utilization: [10, 11], memory: [10, 11] },
|
||||
});
|
||||
expect(appendGpuHistories(before, undefined, 30)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import type { GpuMetrics } from "@/types/stats-widgets";
|
||||
import { metricsChangeKey } from "../../../features/host-metrics/metrics-change-key";
|
||||
|
||||
function poll(gpu: GpuMetrics): ServerMetrics {
|
||||
return {
|
||||
cpu: { percent: 3, cores: 8, load: [0.1, 0.1, 0.1] },
|
||||
memory: { percent: 40, usedGiB: 12, totalGiB: 32 },
|
||||
disk: { percent: 55 },
|
||||
gpu,
|
||||
lastChecked: "2026-09-18T00:00:00.000Z",
|
||||
} as ServerMetrics;
|
||||
}
|
||||
|
||||
function gpuAt(utilizationPercent: number, memoryPercent: number): GpuMetrics {
|
||||
return {
|
||||
source: "nvidia-smi",
|
||||
gpus: [
|
||||
{
|
||||
index: 0,
|
||||
uuid: "GPU-0",
|
||||
name: "NVIDIA GeForce RTX 4090",
|
||||
driverVersion: "550.54.14",
|
||||
utilizationPercent,
|
||||
memoryUsedMiB: 1000,
|
||||
memoryTotalMiB: 24564,
|
||||
memoryPercent,
|
||||
temperatureCelsius: 50,
|
||||
powerDrawWatts: 30,
|
||||
powerLimitWatts: 450,
|
||||
fanPercent: 30,
|
||||
},
|
||||
],
|
||||
processes: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("metricsChangeKey", () => {
|
||||
it("changes when only GPU utilization moves", () => {
|
||||
expect(metricsChangeKey(poll(gpuAt(0, 4)))).not.toBe(
|
||||
metricsChangeKey(poll(gpuAt(95, 4))),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when only GPU memory moves", () => {
|
||||
expect(metricsChangeKey(poll(gpuAt(0, 4)))).not.toBe(
|
||||
metricsChangeKey(poll(gpuAt(0, 80))),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when a GPU process starts", () => {
|
||||
const idle = gpuAt(0, 4);
|
||||
const busy: GpuMetrics = {
|
||||
...idle,
|
||||
processes: [
|
||||
{ gpuIndex: 0, pid: 4242, name: "python3", memoryUsedMiB: 900 },
|
||||
],
|
||||
};
|
||||
|
||||
expect(metricsChangeKey(poll(idle))).not.toBe(metricsChangeKey(poll(busy)));
|
||||
});
|
||||
|
||||
it("ignores GPU jitter inside the same 5% bucket", () => {
|
||||
expect(metricsChangeKey(poll(gpuAt(41, 20)))).toBe(
|
||||
metricsChangeKey(poll(gpuAt(42, 21))),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
selectTemperatureSensor,
|
||||
temperaturePreferenceKey,
|
||||
} from "../../../features/host-metrics/temperature-preference";
|
||||
|
||||
const sensors = [
|
||||
{ label: "k10temp: Tctl", celsius: 55 },
|
||||
{ label: "nvme-pci-0100: Composite", celsius: 70 },
|
||||
];
|
||||
|
||||
describe("temperature sensor preference", () => {
|
||||
it("selects the configured sensor by its complete label", () => {
|
||||
expect(selectTemperatureSensor(sensors, "k10temp: Tctl")).toEqual(
|
||||
sensors[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when a sensor disappears", () => {
|
||||
expect(selectTemperatureSensor(sensors, "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("scopes the preference to a host", () => {
|
||||
expect(temperaturePreferenceKey(42)).toBe(
|
||||
"termix-host-metrics:42:temperature-sensor",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAsciicast } from "../../../features/session-recording/asciicast";
|
||||
|
||||
describe("parseAsciicast", () => {
|
||||
it("parses terminal dimensions and timed input/output", () => {
|
||||
const recording = parseAsciicast(
|
||||
'{"version":2,"width":120,"height":30}\n[0.1,"o","hello"]\n[1.5,"i","ls\\r"]\n[2,"r","100x40"]\n',
|
||||
);
|
||||
expect(recording.width).toBe(120);
|
||||
expect(recording.height).toBe(30);
|
||||
expect(recording.duration).toBe(2);
|
||||
expect(recording.events).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("rejects unsupported recording versions", () => {
|
||||
expect(() => parseAsciicast('{"version":1}\n')).toThrow(
|
||||
"Unsupported asciicast version",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
createSessionShare: vi.fn(),
|
||||
getActiveSessionShares: vi.fn(async () => ({ shares: [] })),
|
||||
revokeSessionShare: vi.fn(async () => ({ success: true as const })),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/session-sharing-api", () => api);
|
||||
|
||||
const mainAxios = vi.hoisted(() => ({
|
||||
getUserList: vi.fn(async () => ({
|
||||
users: [
|
||||
{ userId: "u1", username: "alice" },
|
||||
{ userId: "u2", username: "bob" },
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => mainAxios);
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts ? `${key}:${JSON.stringify(opts)}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: vi.fn(async () => {}) },
|
||||
});
|
||||
|
||||
import { ShareSessionModal } from "../../../features/session-sharing/ShareSessionModal";
|
||||
|
||||
beforeEach(() => {
|
||||
api.createSessionShare.mockReset();
|
||||
api.getActiveSessionShares.mockReset();
|
||||
api.getActiveSessionShares.mockResolvedValue({ shares: [] });
|
||||
api.revokeSessionShare.mockReset();
|
||||
mainAxios.getUserList.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ShareSessionModal", () => {
|
||||
it("creates a link share with the default read-only permission and 24h expiry", async () => {
|
||||
api.createSessionShare.mockResolvedValue({
|
||||
shareId: "share-1",
|
||||
linkToken: "tok-123",
|
||||
expiresAt: "2026-07-21T00:00:00.000Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<ShareSessionModal
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
hostId={42}
|
||||
sessionId="sess-1"
|
||||
protocol="ssh"
|
||||
tabInstanceId="tab-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
const createButton = await screen.findByText(
|
||||
"sessionSharing.createLinkButton",
|
||||
);
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.createSessionShare).toHaveBeenCalledWith({
|
||||
hostId: 42,
|
||||
sessionId: "sess-1",
|
||||
tabInstanceId: "tab-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
targetUserId: undefined,
|
||||
permissionLevel: "read-only",
|
||||
expiryHours: 24,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not submit a user share until a target user is selected", async () => {
|
||||
render(
|
||||
<ShareSessionModal
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
hostId={7}
|
||||
sessionId="sess-2"
|
||||
protocol="rdp"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByText("sessionSharing.modeTab.user"));
|
||||
|
||||
const shareButton = await screen.findByText(
|
||||
"sessionSharing.createShareButton",
|
||||
);
|
||||
expect((shareButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(api.createSessionShare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits a user share with the selected target and read-write permission", async () => {
|
||||
api.createSessionShare.mockResolvedValue({
|
||||
shareId: "share-2",
|
||||
linkToken: null,
|
||||
expiresAt: "2026-07-21T00:00:00.000Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<ShareSessionModal
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
hostId={7}
|
||||
sessionId="sess-2"
|
||||
protocol="vnc"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByText("sessionSharing.modeTab.user"));
|
||||
fireEvent.click(await screen.findByText("alice"));
|
||||
|
||||
const select = await screen.findByDisplayValue(
|
||||
"sessionSharing.permissionLevel.readOnly",
|
||||
);
|
||||
fireEvent.change(select, { target: { value: "read-write" } });
|
||||
|
||||
fireEvent.click(screen.getByText("sessionSharing.createShareButton"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.createSessionShare).toHaveBeenCalledWith({
|
||||
hostId: 7,
|
||||
sessionId: "sess-2",
|
||||
tabInstanceId: undefined,
|
||||
protocol: "vnc",
|
||||
shareType: "user",
|
||||
targetUserId: "u1",
|
||||
permissionLevel: "read-write",
|
||||
expiryHours: 24,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call create when there is no live session id", async () => {
|
||||
render(
|
||||
<ShareSessionModal
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
hostId={1}
|
||||
sessionId={null}
|
||||
protocol="ssh"
|
||||
/>,
|
||||
);
|
||||
|
||||
const createButton = await screen.findByText(
|
||||
"sessionSharing.createLinkButton",
|
||||
);
|
||||
expect((createButton as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("loads and renders active shares for the host", async () => {
|
||||
api.getActiveSessionShares.mockResolvedValue({
|
||||
shares: [
|
||||
{
|
||||
id: "share-x",
|
||||
hostId: 42,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "sess-1",
|
||||
tabInstanceId: null,
|
||||
shareType: "link",
|
||||
targetUserId: null,
|
||||
linkToken: "tok-abc",
|
||||
permissionLevel: "read-only",
|
||||
createdAt: "2026-07-20T00:00:00.000Z",
|
||||
expiresAt: "2026-07-21T00:00:00.000Z",
|
||||
revokedAt: null,
|
||||
lastJoinedAt: null,
|
||||
joinCount: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<ShareSessionModal
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
hostId={42}
|
||||
sessionId="sess-1"
|
||||
protocol="ssh"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("sessionSharing.linkShareBadge")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
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 (): Promise<unknown> => {
|
||||
throw new Error("resolveShareLink not mocked for this test");
|
||||
}),
|
||||
ShareLinkError: class ShareLinkError extends Error {
|
||||
kind: string;
|
||||
constructor(message: string, kind: string) {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/session-sharing-api", () => api);
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("react-xtermjs", () => ({
|
||||
useXTerm: () => ({
|
||||
instance: {
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
},
|
||||
ref: { current: document.createElement("div") },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: class FitAddon {
|
||||
fit() {}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/features/guacamole/GuacamoleDisplay.tsx", () => ({
|
||||
GuacamoleDisplay: () => <div data-testid="guacamole-display" />,
|
||||
}));
|
||||
|
||||
import SharedSessionView from "../../../features/session-sharing/SharedSessionView";
|
||||
|
||||
function setSearch(search: string) {
|
||||
window.history.pushState({}, "", `/?${search}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
api.resolveShareLink.mockReset();
|
||||
api.resolveShareLink.mockImplementation(async () => {
|
||||
throw new Error("resolveShareLink not mocked for this test");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("SharedSessionView", () => {
|
||||
it("shows a friendly error when no token is present in the URL", async () => {
|
||||
setSearch("view=shared");
|
||||
|
||||
render(<SharedSessionView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("sessionSharing.guestView.linkInvalid"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
expect(api.resolveShareLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a generic error when the link is invalid or expired", async () => {
|
||||
setSearch("view=shared&token=bad");
|
||||
api.resolveShareLink.mockRejectedValue(
|
||||
new api.ShareLinkError("not found", "not-found"),
|
||||
);
|
||||
|
||||
render(<SharedSessionView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("sessionSharing.guestView.linkInvalid"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a rate-limit specific message on 429", async () => {
|
||||
setSearch("view=shared&token=abc");
|
||||
api.resolveShareLink.mockRejectedValue(
|
||||
new api.ShareLinkError("slow down", "rate-limited"),
|
||||
);
|
||||
|
||||
render(<SharedSessionView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.resolveShareLink).toHaveBeenCalledWith("abc");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("sessionSharing.guestView.rateLimited"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the guacamole display for a resolved rdp share", async () => {
|
||||
setSearch("view=shared&token=abc");
|
||||
api.resolveShareLink.mockResolvedValue({
|
||||
protocol: "rdp",
|
||||
permissionLevel: "read-only",
|
||||
wsPath: "/guacamole/websocket/",
|
||||
connectParams: { token: "guac-join-token" },
|
||||
});
|
||||
|
||||
render(<SharedSessionView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("guacamole-display")).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
screen.getByText("sessionSharing.guestView.readOnlyBadge"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { toast } from "sonner";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SftpTransferTab } from "@/features/sftp/SftpTransferTab";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
addTransferRecent: vi.fn(),
|
||||
browseSSHDirectory: vi.fn(),
|
||||
changeSSHPermissions: vi.fn(),
|
||||
createSSHFolder: vi.fn(),
|
||||
deleteSSHItem: vi.fn(),
|
||||
ensureSSHSessionForHost: vi.fn(),
|
||||
getSSHHosts: vi.fn(),
|
||||
getTransferProgressPercent: vi.fn(() => undefined),
|
||||
renameSSHItem: vi.fn(),
|
||||
transferToHost: vi.fn(),
|
||||
beginTransferProgressMonitoring: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
addTransferRecent: api.addTransferRecent,
|
||||
browseSSHDirectory: api.browseSSHDirectory,
|
||||
changeSSHPermissions: api.changeSSHPermissions,
|
||||
createSSHFolder: api.createSSHFolder,
|
||||
deleteSSHItem: api.deleteSSHItem,
|
||||
ensureSSHSessionForHost: api.ensureSSHSessionForHost,
|
||||
getSSHHosts: api.getSSHHosts,
|
||||
getTransferProgressPercent: api.getTransferProgressPercent,
|
||||
renameSSHItem: api.renameSSHItem,
|
||||
transferToHost: api.transferToHost,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/file-manager/transferProgressMonitor", () => ({
|
||||
beginTransferProgressMonitoring: api.beginTransferProgressMonitoring,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/file-manager/transferMetricsFormat", () => ({
|
||||
createFormatTransferMetrics: () => () => "",
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.getSSHHosts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "prod",
|
||||
ip: "10.0.0.1",
|
||||
enableFileManager: true,
|
||||
connectionType: "ssh",
|
||||
defaultPath: "/srv",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "backup",
|
||||
ip: "10.0.0.2",
|
||||
enableFileManager: true,
|
||||
connectionType: "ssh",
|
||||
defaultPath: "/srv",
|
||||
},
|
||||
]);
|
||||
api.ensureSSHSessionForHost.mockImplementation(async (host) => ({
|
||||
state: "ready",
|
||||
sessionId: String(host.id),
|
||||
}));
|
||||
api.browseSSHDirectory.mockImplementation(
|
||||
async (sessionId: string, path: string) => ({
|
||||
status: "ok",
|
||||
path,
|
||||
files: [
|
||||
{
|
||||
name: `remote-${sessionId}.txt`,
|
||||
type: "file",
|
||||
size: 24,
|
||||
modified: "2026-07-18T10:30:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
api.transferToHost.mockResolvedValue({ transferId: "transfer-1" });
|
||||
api.beginTransferProgressMonitoring.mockReturnValue({
|
||||
toastId: "toast-1",
|
||||
waitForCompletion: Promise.resolve({
|
||||
transferId: "transfer-1",
|
||||
status: "success",
|
||||
phase: "transferring",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
async function selectHosts() {
|
||||
const selects = await screen.findAllByRole("combobox");
|
||||
fireEvent.change(selects[0], { target: { value: "1" } });
|
||||
fireEvent.change(selects[1], { target: { value: "2" } });
|
||||
await screen.findByText("remote-1.txt");
|
||||
await screen.findByText("remote-2.txt");
|
||||
}
|
||||
|
||||
describe("SftpTransferTab", () => {
|
||||
it("loads file manager-enabled hosts into both host pickers", async () => {
|
||||
render(<SftpTransferTab />);
|
||||
const selects = await screen.findAllByRole("combobox");
|
||||
expect(selects).toHaveLength(2);
|
||||
expect(api.getSSHHosts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies a source server file to the destination server via the context menu", async () => {
|
||||
render(<SftpTransferTab />);
|
||||
await selectHosts();
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
|
||||
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.transferToHost).toHaveBeenCalledWith(
|
||||
"1",
|
||||
["/srv/remote-1.txt"],
|
||||
"2",
|
||||
"/srv",
|
||||
false,
|
||||
"auto",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("records the destination as a transfer recent after a successful copy", async () => {
|
||||
render(<SftpTransferTab />);
|
||||
await selectHosts();
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
|
||||
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.addTransferRecent).toHaveBeenCalledWith(1, 2, "/srv", "/srv");
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks a same-host transfer where the destination is inside the source path", async () => {
|
||||
render(<SftpTransferTab />);
|
||||
const selects = await screen.findAllByRole("combobox");
|
||||
fireEvent.change(selects[0], { target: { value: "1" } });
|
||||
fireEvent.change(selects[1], { target: { value: "1" } });
|
||||
const rows = await screen.findAllByText("remote-1.txt");
|
||||
|
||||
// Destination pane starts at the same "/srv" listing as the source, so
|
||||
// browsing into the selected source file's own path (as if it were a
|
||||
// folder) makes the destination nested inside the source selection.
|
||||
const destPathInput = screen.getAllByDisplayValue("/srv")[1];
|
||||
fireEvent.change(destPathInput, {
|
||||
target: { value: "/srv/remote-1.txt" },
|
||||
});
|
||||
fireEvent.keyDown(destPathInput, { key: "Enter" });
|
||||
await waitFor(() =>
|
||||
expect(api.browseSSHDirectory).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(rows[0]);
|
||||
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"sftpTransfer.destinationInsideSource",
|
||||
);
|
||||
});
|
||||
expect(api.transferToHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renames a remote file from the row context menu", async () => {
|
||||
render(<SftpTransferTab />);
|
||||
await selectHosts();
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
|
||||
await userEvent.click(screen.getByText("sftpTransfer.rename"));
|
||||
const nameInput = screen.getByDisplayValue("remote-1.txt");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "renamed.txt");
|
||||
await userEvent.click(screen.getByText("sftpTransfer.save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.renameSSHItem).toHaveBeenCalledWith(
|
||||
"1",
|
||||
"/srv/remote-1.txt",
|
||||
"renamed.txt",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a remote file after confirming", async () => {
|
||||
render(<SftpTransferTab />);
|
||||
await selectHosts();
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
|
||||
await userEvent.click(screen.getByText("sftpTransfer.delete"));
|
||||
const confirmButtons = await screen.findAllByText("sftpTransfer.delete");
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.deleteSSHItem).toHaveBeenCalledWith(
|
||||
"1",
|
||||
"/srv/remote-1.txt",
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasSameHostTransferConflict,
|
||||
joinRemotePath,
|
||||
normalizeRemoteDir,
|
||||
} from "@/features/sftp/sftp-transfer-utils";
|
||||
|
||||
describe("sftp transfer utilities", () => {
|
||||
it("normalizes and joins remote paths", () => {
|
||||
expect(normalizeRemoteDir(" /home/user// ")).toBe("/home/user");
|
||||
expect(joinRemotePath("/home/user/", "docs/readme.md")).toBe(
|
||||
"/home/user/docs/readme.md",
|
||||
);
|
||||
expect(joinRemotePath("/", "tmp/file.txt")).toBe("/tmp/file.txt");
|
||||
expect(joinRemotePath("home/user", "logs")).toBe("/home/user/logs");
|
||||
});
|
||||
|
||||
it("detects same-host destination conflicts", () => {
|
||||
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/app")).toBe(true);
|
||||
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/app/logs")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/releases")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,894 +0,0 @@
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAndroidHardwareKeySequence } from "@/features/terminal/android-hardware-keyboard";
|
||||
|
||||
const key = (
|
||||
value: string,
|
||||
modifiers: Partial<KeyboardEvent> = {},
|
||||
): Pick<
|
||||
KeyboardEvent,
|
||||
"key" | "ctrlKey" | "altKey" | "metaKey" | "shiftKey"
|
||||
> => ({
|
||||
key: value,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...modifiers,
|
||||
});
|
||||
|
||||
describe("getAndroidHardwareKeySequence", () => {
|
||||
it("maps cursor keys in normal and application modes", () => {
|
||||
expect(
|
||||
getAndroidHardwareKeySequence(key("ArrowUp"), false, undefined),
|
||||
).toBe("\x1b[A");
|
||||
expect(getAndroidHardwareKeySequence(key("ArrowUp"), true, undefined)).toBe(
|
||||
"\x1bOA",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps Delete and the default Backspace mode", () => {
|
||||
expect(getAndroidHardwareKeySequence(key("Delete"), false, undefined)).toBe(
|
||||
"\x1b[3~",
|
||||
);
|
||||
expect(
|
||||
getAndroidHardwareKeySequence(key("Backspace"), false, undefined),
|
||||
).toBe("\x7f");
|
||||
});
|
||||
|
||||
it("leaves control-h Backspace and modified keys to existing handlers", () => {
|
||||
expect(
|
||||
getAndroidHardwareKeySequence(key("Backspace"), false, "control-h"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getAndroidHardwareKeySequence(
|
||||
key("ArrowLeft", { ctrlKey: true }),
|
||||
false,
|
||||
undefined,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("Android IME composition", () => {
|
||||
let terminal: Terminal | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
terminal?.dispose();
|
||||
container?.remove();
|
||||
terminal = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
it("forwards a shorter Vietnamese replacement to the shell", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal();
|
||||
terminal.open(container);
|
||||
|
||||
const input: string[] = [];
|
||||
terminal.onData((data) => input.push(data));
|
||||
|
||||
const textarea = terminal.textarea!;
|
||||
textarea.value = "Hoar";
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
textarea.selectionEnd = textarea.value.length;
|
||||
textarea.dispatchEvent(new CompositionEvent("compositionstart"));
|
||||
textarea.dispatchEvent(
|
||||
new CompositionEvent("compositionupdate", { data: "Hoar" }),
|
||||
);
|
||||
await tick();
|
||||
|
||||
textarea.value = "Hỏa";
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
textarea.selectionEnd = textarea.value.length;
|
||||
textarea.dispatchEvent(
|
||||
new CompositionEvent("compositionupdate", { data: "Hỏa" }),
|
||||
);
|
||||
await tick();
|
||||
textarea.dispatchEvent(new CompositionEvent("compositionend"));
|
||||
await tick();
|
||||
|
||||
expect(input.join("")).toBe("\x7f\x7f\x7fỏa");
|
||||
});
|
||||
|
||||
it("coalesces rapid textarea changes without dropping or duplicating input", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal();
|
||||
terminal.open(container);
|
||||
|
||||
const input: string[] = [];
|
||||
terminal.onData((data) => input.push(data));
|
||||
|
||||
const textarea = terminal.textarea!;
|
||||
const type = (value: string) => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit),
|
||||
);
|
||||
textarea.value = value;
|
||||
textarea.selectionStart = value.length;
|
||||
textarea.selectionEnd = value.length;
|
||||
};
|
||||
|
||||
type("t");
|
||||
type("te");
|
||||
type("ter");
|
||||
type("termix");
|
||||
await tick();
|
||||
|
||||
expect(input.join("")).toBe("termix");
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
fontSizeStorageKey,
|
||||
readFontSize,
|
||||
saveFontSize,
|
||||
} from "@/features/terminal/font-size-storage";
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
describe("persistent terminal zoom", () => {
|
||||
it("survives repeated mounts and option refreshes", () => {
|
||||
const key = fontSizeStorageKey("host-sync-id");
|
||||
saveFontSize(localStorage, key, 14, 18);
|
||||
for (let mount = 0; mount < 3; mount++)
|
||||
expect(readFontSize(localStorage, key, 14)).toBe(18);
|
||||
});
|
||||
it("invalidates an override when defaults change while unmounted", () => {
|
||||
const key = fontSizeStorageKey(1);
|
||||
saveFontSize(localStorage, key, 14, 18);
|
||||
expect(readFontSize(localStorage, key, 16)).toBeNull();
|
||||
expect(readFontSize(localStorage, key, 14)).toBeNull();
|
||||
});
|
||||
it("does not share storage for missing host identities", () => {
|
||||
const key = fontSizeStorageKey(undefined);
|
||||
saveFontSize(localStorage, key, 14, 18);
|
||||
expect(readFontSize(localStorage, key, 14)).toBeNull();
|
||||
expect(localStorage.length).toBe(0);
|
||||
});
|
||||
it("ignores legacy values without a known configured size", () => {
|
||||
localStorage.setItem("legacy", "18");
|
||||
expect(readFontSize(localStorage, "legacy", 14)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
textarea.value = value;
|
||||
textarea.selectionStart = value.length;
|
||||
textarea.selectionEnd = value.length;
|
||||
}
|
||||
|
||||
// iOS Safari/WKWebView reports keyCode 229 for ordinary software-keyboard
|
||||
// input, not just true IME composition, so xterm routes typing through
|
||||
// CompositionHelper.keydown -> _handleAnyTextareaChanges instead of the
|
||||
// normal keypress path. That handler snapshots the textarea value on
|
||||
// keydown, then diffs it against the value a setTimeout(0) later.
|
||||
function dispatchIOSKeydown(textarea: HTMLTextAreaElement) {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit),
|
||||
);
|
||||
}
|
||||
|
||||
describe("iOS rapid typing (keyCode 229 outside composition)", () => {
|
||||
let terminal: Terminal | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
terminal?.dispose();
|
||||
container?.remove();
|
||||
terminal = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
it("forwards a mid-word autocorrect rewrite instead of dropping it", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal();
|
||||
terminal.open(container);
|
||||
|
||||
const input: string[] = [];
|
||||
terminal.onData((data) => input.push(data));
|
||||
|
||||
const textarea = terminal.textarea!;
|
||||
|
||||
// keydown fires while the textarea still holds the pre-keystroke value;
|
||||
// the browser (or, on iOS, autocorrect) mutates the value afterward.
|
||||
// Autocorrect can rewrite characters earlier in the word, not just
|
||||
// append at the cursor, so the old value is no longer a literal
|
||||
// substring of the new one.
|
||||
dispatchIOSKeydown(textarea);
|
||||
setTextareaValue(textarea, "wrold");
|
||||
await tick();
|
||||
|
||||
dispatchIOSKeydown(textarea);
|
||||
setTextareaValue(textarea, "world");
|
||||
await tick();
|
||||
|
||||
expect(input.join("")).toBe("wrold" + "orld");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveTerminalContextMenuAction,
|
||||
selectedTextToCopy,
|
||||
} from "@/features/terminal/terminal-clipboard-actions";
|
||||
|
||||
describe("terminal clipboard actions", () => {
|
||||
it("copies a completed left-button selection when enabled", () => {
|
||||
expect(
|
||||
selectedTextToCopy({
|
||||
copyOnSelect: true,
|
||||
button: 0,
|
||||
selection: "selected output",
|
||||
}),
|
||||
).toBe("selected output");
|
||||
expect(
|
||||
selectedTextToCopy({ copyOnSelect: false, button: 0, selection: "x" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("pastes on right-click after copy-on-select", () => {
|
||||
expect(
|
||||
resolveTerminalContextMenuAction({
|
||||
rightClickCopyPaste: true,
|
||||
copyOnSelect: true,
|
||||
hasSelection: true,
|
||||
}),
|
||||
).toBe("paste");
|
||||
});
|
||||
|
||||
it("preserves existing right-click and native-menu behavior", () => {
|
||||
expect(
|
||||
resolveTerminalContextMenuAction({
|
||||
rightClickCopyPaste: true,
|
||||
copyOnSelect: false,
|
||||
hasSelection: true,
|
||||
}),
|
||||
).toBe("copy");
|
||||
expect(
|
||||
resolveTerminalContextMenuAction({
|
||||
rightClickCopyPaste: false,
|
||||
copyOnSelect: true,
|
||||
hasSelection: true,
|
||||
}),
|
||||
).toBe("native");
|
||||
});
|
||||
});
|
||||
@@ -1,332 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
|
||||
const getCookieMock = vi.fn<(name: string) => string | undefined>();
|
||||
|
||||
vi.mock("@/main-axios.ts", () => ({
|
||||
getCookie: (name: string) => getCookieMock(name),
|
||||
}));
|
||||
|
||||
import {
|
||||
getUseRightClickCopyPaste,
|
||||
handleTerminalClipboardKeyEvent,
|
||||
createTerminalContextMenuHandler,
|
||||
} from "@/features/terminal/terminal-clipboard";
|
||||
|
||||
function createFakeTerminal({ selection = "" }: { selection?: string } = {}) {
|
||||
return {
|
||||
hasSelection: vi.fn(() => selection.length > 0),
|
||||
getSelection: vi.fn(() => selection),
|
||||
clearSelection: vi.fn(),
|
||||
paste: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createActions() {
|
||||
return {
|
||||
writeTextToClipboard: vi.fn().mockResolvedValue(true),
|
||||
readTextFromClipboard: vi.fn().mockResolvedValue("clipboard-text"),
|
||||
};
|
||||
}
|
||||
|
||||
function createKeyEvent(init: {
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
key?: string;
|
||||
code?: string;
|
||||
}) {
|
||||
return {
|
||||
type: "keydown",
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
key: "",
|
||||
code: "",
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
...init,
|
||||
} as unknown as KeyboardEvent;
|
||||
}
|
||||
|
||||
function createMouseEvent(init: { ctrlKey?: boolean } = {}) {
|
||||
return {
|
||||
ctrlKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
...init,
|
||||
} as unknown as MouseEvent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getCookieMock.mockReset();
|
||||
getCookieMock.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe("getUseRightClickCopyPaste", () => {
|
||||
it("defaults to enabled when the cookie is unset", () => {
|
||||
getCookieMock.mockReturnValue(undefined);
|
||||
expect(getUseRightClickCopyPaste()).toBe(true);
|
||||
});
|
||||
|
||||
it('is disabled only when the cookie is explicitly "false"', () => {
|
||||
getCookieMock.mockReturnValue("false");
|
||||
expect(getUseRightClickCopyPaste()).toBe(false);
|
||||
|
||||
getCookieMock.mockReturnValue("true");
|
||||
expect(getUseRightClickCopyPaste()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleTerminalClipboardKeyEvent", () => {
|
||||
it("copies the selection on Ctrl+C and clears it", () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ ctrlKey: true, code: "KeyC", key: "c" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(e.stopPropagation).toHaveBeenCalled();
|
||||
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
expect(terminal.clearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves Ctrl+C alone (SIGINT) when there is no selection", () => {
|
||||
const terminal = createFakeTerminal({ selection: "" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ ctrlKey: true, code: "KeyC", key: "c" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies via Ctrl+Insert without clearing the selection", () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ ctrlKey: true, key: "Insert" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
expect(terminal.clearSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies and clears the selection on Ctrl+Shift+C", () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
code: "KeyC",
|
||||
key: "C",
|
||||
});
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
expect(terminal.clearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op for Ctrl+Shift+C without a selection", () => {
|
||||
const terminal = createFakeTerminal({ selection: "" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
code: "KeyC",
|
||||
key: "C",
|
||||
});
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("always pastes on Ctrl+Shift+V regardless of selection", async () => {
|
||||
const terminal = createFakeTerminal({ selection: "irrelevant" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
code: "KeyV",
|
||||
key: "V",
|
||||
});
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(actions.readTextFromClipboard).toHaveBeenCalled();
|
||||
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
|
||||
});
|
||||
|
||||
it("leaves plain Ctrl+V to the browser's native paste event by default", () => {
|
||||
const terminal = createFakeTerminal();
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ ctrlKey: true, code: "KeyV", key: "v" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
expect(e.stopPropagation).not.toHaveBeenCalled();
|
||||
expect(actions.readTextFromClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the clipboard explicitly for plain Ctrl+V when configured", async () => {
|
||||
const terminal = createFakeTerminal();
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ ctrlKey: true, code: "KeyV", key: "v" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
{ plainPasteMode: "explicit" },
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(actions.readTextFromClipboard).toHaveBeenCalled();
|
||||
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
|
||||
});
|
||||
|
||||
it("ignores keys with no clipboard shortcut", () => {
|
||||
const terminal = createFakeTerminal();
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ key: "a", code: "KeyA" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
expect(actions.readTextFromClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTerminalContextMenuHandler", () => {
|
||||
it("routes Ctrl+right-click to the passthrough callback instead of the clipboard", () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const onCtrlClick = vi.fn();
|
||||
const handler = createTerminalContextMenuHandler(
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
{ onCtrlClick },
|
||||
);
|
||||
const e = createMouseEvent({ ctrlKey: true });
|
||||
|
||||
handler(e);
|
||||
|
||||
expect(onCtrlClick).toHaveBeenCalledTimes(1);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to normal right-click behavior when Ctrl is held with no passthrough configured", () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const handler = createTerminalContextMenuHandler(
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
const e = createMouseEvent({ ctrlKey: true });
|
||||
|
||||
handler(e);
|
||||
|
||||
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("does nothing when the right-click preference is disabled", () => {
|
||||
getCookieMock.mockReturnValue("false");
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const handler = createTerminalContextMenuHandler(
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
const e = createMouseEvent();
|
||||
|
||||
handler(e);
|
||||
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies the current selection when right-click copy/paste is enabled", async () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const handler = createTerminalContextMenuHandler(
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
const e = createMouseEvent();
|
||||
|
||||
handler(e);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
expect(terminal.clearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pastes when there is no selection", async () => {
|
||||
const terminal = createFakeTerminal({ selection: "" });
|
||||
const actions = createActions();
|
||||
const handler = createTerminalContextMenuHandler(
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
const e = createMouseEvent();
|
||||
|
||||
handler(e);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(actions.readTextFromClipboard).toHaveBeenCalled();
|
||||
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getNextTerminalFontSize,
|
||||
getTerminalFontZoomDirection,
|
||||
} from "../../../features/terminal/terminal-font-zoom";
|
||||
|
||||
function keyEvent(
|
||||
overrides: Partial<KeyboardEvent>,
|
||||
): Pick<KeyboardEvent, "altKey" | "code" | "ctrlKey" | "key" | "metaKey"> {
|
||||
return {
|
||||
altKey: false,
|
||||
code: "",
|
||||
ctrlKey: false,
|
||||
key: "",
|
||||
metaKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getTerminalFontZoomDirection", () => {
|
||||
it("recognizes Ctrl+Plus and Ctrl+Minus", () => {
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "Equal", key: "+" }),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "Minus", key: "-" }),
|
||||
),
|
||||
).toBe(-1);
|
||||
});
|
||||
|
||||
it("recognizes unshifted equal, numpad, and macOS shortcuts", () => {
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "Equal", key: "=" }),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "NumpadAdd", key: "+" }),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ metaKey: true, code: "NumpadSubtract", key: "-" }),
|
||||
),
|
||||
).toBe(-1);
|
||||
});
|
||||
|
||||
it("ignores unmodified and Alt-modified keys", () => {
|
||||
expect(
|
||||
getTerminalFontZoomDirection(keyEvent({ code: "Equal", key: "+" })),
|
||||
).toBe(0);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ altKey: true, ctrlKey: true, code: "Minus", key: "-" }),
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextTerminalFontSize", () => {
|
||||
it("changes the font size one pixel at a time", () => {
|
||||
expect(getNextTerminalFontSize(14, 1)).toBe(15);
|
||||
expect(getNextTerminalFontSize(14, -1)).toBe(13);
|
||||
});
|
||||
|
||||
it("keeps the existing zoom limits", () => {
|
||||
expect(getNextTerminalFontSize(36, 1)).toBe(36);
|
||||
expect(getNextTerminalFontSize(8, -1)).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ensureTerminalFontsLoaded } from "../../../features/terminal/terminal-global-styles";
|
||||
|
||||
describe("ensureTerminalFontsLoaded", () => {
|
||||
it("requests regular, bold, italic, and bold-italic variants for the given font", () => {
|
||||
const load = vi.fn().mockResolvedValue([]);
|
||||
const originalFonts = document.fonts;
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: { load },
|
||||
});
|
||||
|
||||
try {
|
||||
ensureTerminalFontsLoaded("Caskaydia Cove Nerd Font Mono");
|
||||
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'400 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'700 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'italic 400 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'italic 700 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledTimes(4);
|
||||
} finally {
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: originalFonts,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not throw when document.fonts is unavailable", () => {
|
||||
const originalFonts = document.fonts;
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => ensureTerminalFontsLoaded("JetBrains Mono")).not.toThrow();
|
||||
} finally {
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: originalFonts,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("swallows rejected font load promises", async () => {
|
||||
const load = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
const originalFonts = document.fonts;
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: { load },
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => ensureTerminalFontsLoaded("Fira Code")).not.toThrow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: originalFonts,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
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("''");
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isPhysicalShortcutKey,
|
||||
isTabKeyEvent,
|
||||
} from "@/features/terminal/terminal-key-event";
|
||||
|
||||
describe("isTabKeyEvent", () => {
|
||||
it.each([
|
||||
["key", new KeyboardEvent("keydown", { key: "Tab" })],
|
||||
["code", new KeyboardEvent("keydown", { code: "Tab" })],
|
||||
[
|
||||
"legacy keyCode",
|
||||
new KeyboardEvent("keydown", { keyCode: 9 } as KeyboardEventInit),
|
||||
],
|
||||
])("recognizes Tab from %s", (_source, event) => {
|
||||
expect(isTabKeyEvent(event)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat another key as Tab", () => {
|
||||
expect(isTabKeyEvent(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPhysicalShortcutKey", () => {
|
||||
it("matches copy and paste by physical code under a Cyrillic layout", () => {
|
||||
expect(isPhysicalShortcutKey({ key: "с", code: "KeyC" }, "KeyC", "c")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isPhysicalShortcutKey({ key: "м", code: "KeyV" }, "KeyV", "v")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not confuse another physical key with a translated character", () => {
|
||||
expect(isPhysicalShortcutKey({ key: "c", code: "KeyV" }, "KeyC", "c")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to key when code is unavailable", () => {
|
||||
expect(isPhysicalShortcutKey({ key: "C", code: "" }, "KeyC", "c")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,417 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getTunnelTypeForMode,
|
||||
getTunnelPortLabels,
|
||||
getTunnelModeDescription,
|
||||
} from "../../../features/tunnel/tunnel-form-utils.js";
|
||||
|
||||
// A fake translate that echoes the key plus any interpolation args so we can
|
||||
// assert which translation key + payload the helpers chose.
|
||||
const t = (key: string, opts?: Record<string, string | number>) =>
|
||||
opts ? `${key}:${JSON.stringify(opts)}` : key;
|
||||
|
||||
describe("getTunnelTypeForMode", () => {
|
||||
it("maps remote to remote and everything else to local", () => {
|
||||
expect(getTunnelTypeForMode("remote")).toBe("remote");
|
||||
expect(getTunnelTypeForMode("local")).toBe("local");
|
||||
expect(getTunnelTypeForMode("dynamic")).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTunnelPortLabels", () => {
|
||||
it("labels client local mode source/endpoint ports", () => {
|
||||
const labels = getTunnelPortLabels("client", "local", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.localPort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.destinationPort");
|
||||
});
|
||||
|
||||
it("swaps labels for client remote mode", () => {
|
||||
const labels = getTunnelPortLabels("client", "remote", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.remotePort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.localPort");
|
||||
});
|
||||
|
||||
it("uses host/endpoint labels in server scope", () => {
|
||||
const labels = getTunnelPortLabels("server", "local", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.currentHostPort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.endpointPort");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTunnelModeDescription", () => {
|
||||
const ports = { sourcePort: 8080, endpointPort: 9090 };
|
||||
|
||||
it("selects the client dynamic description with only the source port", () => {
|
||||
const desc = getTunnelModeDescription("client", "dynamic", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionClientDynamic");
|
||||
expect(desc).toContain("8080");
|
||||
expect(desc).not.toContain("9090");
|
||||
});
|
||||
|
||||
it("selects the client local description with both ports", () => {
|
||||
const desc = getTunnelModeDescription("client", "local", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionClientLocal");
|
||||
expect(desc).toContain("8080");
|
||||
expect(desc).toContain("9090");
|
||||
});
|
||||
|
||||
it("selects the server remote description", () => {
|
||||
const desc = getTunnelModeDescription("server", "remote", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionServerRemote");
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -65,4 +65,9 @@ describe("getStatusClasses", () => {
|
||||
it("uses the accent color for the accent scheme", () => {
|
||||
expect(getStatusClasses(true, "accent", "dot")).toContain("accent-brand");
|
||||
});
|
||||
|
||||
it("draws reachable as a warning in both schemes", () => {
|
||||
expect(getStatusClasses("reachable", "accent", "dot")).toContain("amber");
|
||||
expect(getStatusClasses("reachable", "status", "dot")).toContain("amber");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const api = vi.hoisted(() => ({ getSshAuthProviders: vi.fn() }));
|
||||
vi.mock("@/api/auth-methods-api", () => api);
|
||||
|
||||
import {
|
||||
invalidateSshAuthProviders,
|
||||
useSshAuthProviders,
|
||||
} from "@/hooks/useSshAuthProviders";
|
||||
import { registerSshAuthEditor } from "@/plugin-host/auth-registry";
|
||||
|
||||
function summary(type: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type,
|
||||
labelKey: `hosts.filterAuth${type}`,
|
||||
pluginId: "core",
|
||||
fields: [],
|
||||
credentialType: false,
|
||||
needsUserInteraction: false,
|
||||
supportsBackground: true,
|
||||
available: true,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
invalidateSshAuthProviders();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useSshAuthProviders", () => {
|
||||
it("lists the server's types, flags ones whose plugin is off, and merges editors", async () => {
|
||||
api.getSshAuthProviders.mockResolvedValue([
|
||||
summary("password", { credentialType: true }),
|
||||
summary("corp", { pluginId: "corp" }),
|
||||
summary("gone", {
|
||||
available: false,
|
||||
pluginId: "gone-plugin",
|
||||
missingPlugin: { id: "gone-plugin", name: "Gone" },
|
||||
}),
|
||||
]);
|
||||
const dispose = registerSshAuthEditor({
|
||||
id: "corp",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp:title",
|
||||
});
|
||||
try {
|
||||
const { result } = renderHook(() => useSshAuthProviders());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.providers.map((p) => p.type)).toEqual([
|
||||
"password",
|
||||
"corp",
|
||||
"gone",
|
||||
]);
|
||||
expect(result.current.find("corp")?.editorTitleKey).toBe("corp:title");
|
||||
expect(result.current.find("gone")).toMatchObject({
|
||||
available: false,
|
||||
missingPlugin: { name: "Gone" },
|
||||
});
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers the built-in types while the server has not answered", () => {
|
||||
api.getSshAuthProviders.mockReturnValue(new Promise(() => {}));
|
||||
const { result } = renderHook(() => useSshAuthProviders());
|
||||
expect(result.current.loaded).toBe(false);
|
||||
expect(result.current.providers.map((p) => p.type)).toEqual([
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"agent",
|
||||
"none",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
dispatchCtrlW,
|
||||
createCommandPaletteShortcutMatcher,
|
||||
getAltDigitShortcut,
|
||||
isShiftKey,
|
||||
} from "../../lib/app-keyboard-shortcuts";
|
||||
@@ -40,3 +41,74 @@ describe("app keyboard shortcuts", () => {
|
||||
expect(dispatchCtrlW(document.createElement("div"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("command palette shortcut matching", () => {
|
||||
const shift = () =>
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "Shift",
|
||||
code: "ShiftLeft",
|
||||
shiftKey: true,
|
||||
});
|
||||
it("requires a fresh pair after each double Shift", () => {
|
||||
const { matches } = createCommandPaletteShortcutMatcher();
|
||||
expect(matches(shift(), 1000)).toBe(false);
|
||||
expect(matches(shift(), 1100)).toBe(true);
|
||||
expect(matches(shift(), 1200)).toBe(false);
|
||||
expect(matches(shift(), 1300)).toBe(true);
|
||||
});
|
||||
it("does not combine slow presses", () => {
|
||||
const { matches } = createCommandPaletteShortcutMatcher();
|
||||
expect(matches(shift(), 1000)).toBe(false);
|
||||
expect(matches(shift(), 1300)).toBe(false);
|
||||
expect(matches(shift(), 1400)).toBe(true);
|
||||
});
|
||||
it.each([
|
||||
{ key: "A", code: "KeyA", shiftKey: true },
|
||||
{ key: "Shift", code: "ShiftLeft", ctrlKey: true },
|
||||
{ key: "Shift", code: "ShiftLeft", metaKey: true },
|
||||
{ key: "Shift", code: "ShiftLeft", altKey: true },
|
||||
{ key: "Shift", code: "ShiftLeft", isComposing: true },
|
||||
{ key: "Process", keyCode: 229 },
|
||||
{ key: "Shift", code: "ShiftLeft", repeat: true },
|
||||
])("breaks the sequence on %j", (init) => {
|
||||
const { matches } = createCommandPaletteShortcutMatcher();
|
||||
matches(shift(), 1000);
|
||||
expect(matches(new KeyboardEvent("keydown", init), 1050)).toBe(false);
|
||||
expect(matches(shift(), 1100)).toBe(false);
|
||||
});
|
||||
it("resets on lost focus or composition start", () => {
|
||||
const { matches, reset } = createCommandPaletteShortcutMatcher();
|
||||
matches(shift(), 1000);
|
||||
reset();
|
||||
expect(matches(shift(), 1100)).toBe(false);
|
||||
});
|
||||
it("respects consumed key events", () => {
|
||||
const { matches } = createCommandPaletteShortcutMatcher();
|
||||
matches(shift(), 1000);
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Shift",
|
||||
cancelable: true,
|
||||
});
|
||||
event.preventDefault();
|
||||
expect(matches(event, 1050)).toBe(false);
|
||||
expect(matches(shift(), 1100)).toBe(false);
|
||||
});
|
||||
it.each([{ ctrlKey: true }, { metaKey: true }])(
|
||||
"keeps the explicit shortcut: %j",
|
||||
(modifier) => {
|
||||
const { matches } = createCommandPaletteShortcutMatcher();
|
||||
expect(
|
||||
matches(new KeyboardEvent("keydown", { code: "KeyK", ...modifier })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matches(
|
||||
new KeyboardEvent("keydown", {
|
||||
code: "KeyK",
|
||||
...modifier,
|
||||
isComposing: true,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const registered = vi.hoisted(() => ({
|
||||
cards: new Map<
|
||||
string,
|
||||
{ defaultHeight?: number; defaultPanel?: "main" | "side" }
|
||||
>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/dashboard/dashboard-cards-registry", () => ({
|
||||
getRegisteredDashboardCard: (id: string) => registered.cards.get(id),
|
||||
}));
|
||||
|
||||
const { buildDashboardSlots, presetHiddenRailTabs } =
|
||||
await import("../../lib/apply-ui-preset.js");
|
||||
|
||||
afterEach(() => {
|
||||
registered.cards.clear();
|
||||
});
|
||||
|
||||
describe("buildDashboardSlots", () => {
|
||||
it("places core cards in the main panel by default", () => {
|
||||
const slots = buildDashboardSlots(["stats_bar", "quick_actions"]);
|
||||
expect(slots.map((s) => s.panel)).toEqual(["main", "main"]);
|
||||
expect(slots.map((s) => s.height)).toEqual([96, 160]);
|
||||
});
|
||||
|
||||
it("keeps recent_activity in the side panel", () => {
|
||||
const slots = buildDashboardSlots(["stats_bar", "recent_activity"]);
|
||||
expect(slots.find((s) => s.id === "recent_activity")?.panel).toBe("side");
|
||||
});
|
||||
|
||||
it("reads a plugin card's own height and panel from the registry", () => {
|
||||
registered.cards.set("service_links", {
|
||||
defaultHeight: 200,
|
||||
defaultPanel: "side",
|
||||
});
|
||||
const slots = buildDashboardSlots(["stats_bar", "service_links"]);
|
||||
const link = slots.find((s) => s.id === "service_links");
|
||||
expect(link?.panel).toBe("side");
|
||||
expect(link?.height).toBe(200);
|
||||
});
|
||||
|
||||
it("defaults an unregistered plugin card to the main panel with no fixed height", () => {
|
||||
const slots = buildDashboardSlots(["some_plugin_card"]);
|
||||
expect(slots).toEqual([
|
||||
{
|
||||
key: "some_plugin_card_0",
|
||||
id: "some_plugin_card",
|
||||
panel: "main",
|
||||
order: 0,
|
||||
height: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders main and side cards independently", () => {
|
||||
registered.cards.set("service_links", { defaultPanel: "side" });
|
||||
const slots = buildDashboardSlots([
|
||||
"stats_bar",
|
||||
"service_links",
|
||||
"quick_actions",
|
||||
"recent_activity",
|
||||
]);
|
||||
expect(slots.map((s) => [s.id, s.panel, s.order])).toEqual([
|
||||
["stats_bar", "main", 0],
|
||||
["service_links", "side", 0],
|
||||
["quick_actions", "main", 1],
|
||||
["recent_activity", "side", 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("presetHiddenRailTabs", () => {
|
||||
const items = [
|
||||
{ id: "snippets", simplePreset: true },
|
||||
{ id: "docker-like" },
|
||||
{ id: "pinned", hideable: false },
|
||||
];
|
||||
|
||||
it("hides plugin items that do not opt in when the preset asks", () => {
|
||||
expect(
|
||||
presetHiddenRailTabs(
|
||||
{ hiddenTabs: ["quick-connect"], hidePluginItems: true },
|
||||
items,
|
||||
),
|
||||
).toEqual(["quick-connect", "docker-like"]);
|
||||
});
|
||||
|
||||
it("leaves plugin items alone otherwise", () => {
|
||||
expect(presetHiddenRailTabs({ hiddenTabs: [] }, items)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
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,4 +1,7 @@
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
|
||||
const getLinkedSession = vi.hoisted(() => vi.fn(async () => null));
|
||||
vi.mock("@/lib/linked-server", () => ({ getLinkedSession }));
|
||||
import {
|
||||
buildOriginWsUrl,
|
||||
resolveConnectionOrigin,
|
||||
@@ -15,7 +18,7 @@ describe("resolveConnectionOrigin", () => {
|
||||
// Support#1240: these can now originate from the desktop, but only when a
|
||||
// host opts in. Left on Default they stay remote, so an upgrade never moves
|
||||
// an existing host onto a local guacd the user has not set up.
|
||||
it("resolves rdp/vnc/telnet to remote when the host has no override", async () => {
|
||||
it("resolves to remote with defaultRemote when the host has no override", async () => {
|
||||
win.IS_ELECTRON = true;
|
||||
win.electronAPI = {
|
||||
invoke: async (channel: string) =>
|
||||
@@ -23,54 +26,42 @@ describe("resolveConnectionOrigin", () => {
|
||||
? { defaultConnectionOrigin: "local" }
|
||||
: null,
|
||||
};
|
||||
for (const connectionType of ["rdp", "vnc", "telnet"]) {
|
||||
await expect(
|
||||
resolveConnectionOrigin({ connectionType, connectionOrigin: null }),
|
||||
).resolves.toBe("remote");
|
||||
}
|
||||
});
|
||||
|
||||
it("honors an explicit local override for rdp/vnc/telnet", async () => {
|
||||
win.IS_ELECTRON = true;
|
||||
for (const connectionType of ["rdp", "vnc", "telnet"]) {
|
||||
await expect(
|
||||
resolveConnectionOrigin({ connectionType, connectionOrigin: "local" }),
|
||||
).resolves.toBe("local");
|
||||
}
|
||||
});
|
||||
|
||||
it("honors an explicit remote override for rdp/vnc/telnet", async () => {
|
||||
win.IS_ELECTRON = true;
|
||||
for (const connectionType of ["rdp", "vnc", "telnet"]) {
|
||||
await expect(
|
||||
resolveConnectionOrigin({ connectionType, connectionOrigin: "remote" }),
|
||||
).resolves.toBe("remote");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves rdp to local outside Electron, where there is only one backend", async () => {
|
||||
await expect(
|
||||
resolveConnectionOrigin({
|
||||
connectionType: "rdp",
|
||||
connectionOrigin: null,
|
||||
}),
|
||||
resolveConnectionOrigin(
|
||||
{ connectionOrigin: null },
|
||||
{ defaultRemote: true },
|
||||
),
|
||||
).resolves.toBe("remote");
|
||||
});
|
||||
|
||||
it("honors an explicit override over defaultRemote", async () => {
|
||||
win.IS_ELECTRON = true;
|
||||
await expect(
|
||||
resolveConnectionOrigin(
|
||||
{ connectionOrigin: "local" },
|
||||
{ defaultRemote: true },
|
||||
),
|
||||
).resolves.toBe("local");
|
||||
await expect(
|
||||
resolveConnectionOrigin(
|
||||
{ connectionOrigin: "remote" },
|
||||
{ defaultRemote: true },
|
||||
),
|
||||
).resolves.toBe("remote");
|
||||
});
|
||||
|
||||
it("resolves to local outside Electron even with defaultRemote", async () => {
|
||||
await expect(
|
||||
resolveConnectionOrigin(
|
||||
{ connectionOrigin: null },
|
||||
{ defaultRemote: true },
|
||||
),
|
||||
).resolves.toBe("local");
|
||||
});
|
||||
|
||||
it("always resolves serial to local, even with a remote override", async () => {
|
||||
win.IS_ELECTRON = true;
|
||||
it("resolves to local outside Electron regardless of the host override", async () => {
|
||||
await expect(
|
||||
resolveConnectionOrigin({
|
||||
connectionType: "serial",
|
||||
connectionOrigin: "remote",
|
||||
}),
|
||||
).resolves.toBe("local");
|
||||
});
|
||||
|
||||
it("resolves to local outside Electron regardless of connectionType", async () => {
|
||||
await expect(
|
||||
resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: "remote",
|
||||
}),
|
||||
).resolves.toBe("local");
|
||||
@@ -80,13 +71,11 @@ describe("resolveConnectionOrigin", () => {
|
||||
win.IS_ELECTRON = true;
|
||||
await expect(
|
||||
resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: "remote",
|
||||
}),
|
||||
).resolves.toBe("remote");
|
||||
await expect(
|
||||
resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: "local",
|
||||
}),
|
||||
).resolves.toBe("local");
|
||||
@@ -104,7 +93,6 @@ describe("resolveConnectionOrigin", () => {
|
||||
};
|
||||
await expect(
|
||||
resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: null,
|
||||
}),
|
||||
).resolves.toBe("remote");
|
||||
@@ -119,7 +107,6 @@ describe("resolveConnectionOrigin", () => {
|
||||
};
|
||||
await expect(
|
||||
resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: null,
|
||||
}),
|
||||
).resolves.toBe("local");
|
||||
@@ -127,14 +114,15 @@ describe("resolveConnectionOrigin", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 embedded backend authenticates a local WebSocket from its subprotocol,
|
||||
* 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, so a socket has to carry the credential itself.
|
||||
*
|
||||
* 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.
|
||||
* This caught a real bug: the Docker console opted out of the token and had no
|
||||
* other credential left, so its handshake was closed with 1008 while logs and
|
||||
* stats kept working. The fixtures below use the plugin socket routes these
|
||||
* channels actually use now; every plugin shares the backend port.
|
||||
*/
|
||||
describe("buildOriginWsUrl", () => {
|
||||
const store: Record<string, string> = {};
|
||||
@@ -154,13 +142,13 @@ describe("buildOriginWsUrl", () => {
|
||||
// Every interactive channel on the embedded backend relies on this.
|
||||
const target = await buildOriginWsUrl({
|
||||
origin: "local",
|
||||
localPort: 30009,
|
||||
localPath: "/docker/console/",
|
||||
remotePath: "/docker/console/",
|
||||
localPort: 30001,
|
||||
localPath: "/plugin-ws/docker/console",
|
||||
remotePath: "/plugin-ws/docker/console",
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
url: "ws://127.0.0.1:30009/docker/console/",
|
||||
url: "ws://127.0.0.1:30001/plugin-ws/docker/console",
|
||||
protocols: ["termix.jwt.local-jwt"],
|
||||
});
|
||||
});
|
||||
@@ -168,39 +156,34 @@ describe("buildOriginWsUrl", () => {
|
||||
it("omits it only when a caller asks", async () => {
|
||||
const target = await buildOriginWsUrl({
|
||||
origin: "local",
|
||||
localPort: 30009,
|
||||
localPath: "/docker/console/",
|
||||
remotePath: "/docker/console/",
|
||||
localPort: 30001,
|
||||
localPath: "/plugin-ws/docker/console",
|
||||
remotePath: "/plugin-ws/docker/console",
|
||||
includeJwt: false,
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
url: "ws://127.0.0.1:30009/docker/console/",
|
||||
url: "ws://127.0.0.1:30001/plugin-ws/docker/console",
|
||||
protocols: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not duplicate the Guacamole token on remote connections", async () => {
|
||||
win.electronAPI = {
|
||||
invoke: async (channel: string) => {
|
||||
if (channel === "get-remote-sync-config") {
|
||||
return { serverUrl: "https://termix.example" };
|
||||
}
|
||||
if (channel === "get-remote-sync-jwt") return "remote-jwt";
|
||||
return null;
|
||||
},
|
||||
};
|
||||
getLinkedSession.mockResolvedValueOnce({
|
||||
serverUrl: "https://termix.example",
|
||||
token: "remote-jwt",
|
||||
} as never);
|
||||
|
||||
const target = await buildOriginWsUrl({
|
||||
origin: "remote",
|
||||
localPort: 30008,
|
||||
localPath: "/guacamole/websocket/",
|
||||
remotePath: "/guacamole/websocket/",
|
||||
localPort: 30001,
|
||||
localPath: "/plugin-ws/remote-desktop/display",
|
||||
remotePath: "/plugin-ws/remote-desktop/display",
|
||||
includeJwt: false,
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
url: "wss://termix.example/guacamole/websocket/",
|
||||
url: "wss://termix.example/plugin-ws/remote-desktop/display",
|
||||
protocols: [],
|
||||
});
|
||||
});
|
||||
@@ -210,13 +193,13 @@ describe("buildOriginWsUrl", () => {
|
||||
|
||||
const target = await buildOriginWsUrl({
|
||||
origin: "local",
|
||||
localPort: 30002,
|
||||
localPath: "",
|
||||
remotePath: "/ssh/websocket/",
|
||||
localPort: 30001,
|
||||
localPath: "/plugin-ws/ssh-terminal/terminal",
|
||||
remotePath: "/plugin-ws/ssh-terminal/terminal",
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
url: "ws://127.0.0.1:30002",
|
||||
url: "ws://127.0.0.1:30001/plugin-ws/ssh-terminal/terminal",
|
||||
protocols: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createId } from "@/lib/create-id";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("createId", () => {
|
||||
it("uses randomUUID when available", () => {
|
||||
vi.stubGlobal("crypto", { randomUUID: () => "uuid-1" });
|
||||
expect(createId()).toBe("uuid-1");
|
||||
});
|
||||
|
||||
it("falls back without randomUUID, e.g. over plain http", () => {
|
||||
vi.stubGlobal("crypto", {});
|
||||
const first = createId();
|
||||
const second = createId();
|
||||
expect(first).toBeTruthy();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
HostDefaultBadge,
|
||||
HostDefaultsContext,
|
||||
HostOnly,
|
||||
type HostDefaultsContextValue,
|
||||
} from "@/lib/host-defaults-context";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) =>
|
||||
options?.name ? `${key}:${options.name}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
function context(
|
||||
partial: Partial<HostDefaultsContextValue> = {},
|
||||
): HostDefaultsContextValue {
|
||||
return {
|
||||
mode: "host",
|
||||
isOwn: () => false,
|
||||
source: () => ({ level: "user" }),
|
||||
reset: vi.fn(),
|
||||
isAvailable: () => true,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function withContext(value: HostDefaultsContextValue, ui: React.ReactNode) {
|
||||
return render(
|
||||
<HostDefaultsContext.Provider value={value}>
|
||||
{ui}
|
||||
</HostDefaultsContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("HostDefaultBadge", () => {
|
||||
it("renders nothing outside the host editor", () => {
|
||||
const { container } = render(
|
||||
<HostDefaultBadge settingKey="core.sshPort" />,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("says where an inherited value comes from", () => {
|
||||
withContext(
|
||||
context({
|
||||
source: () => ({ level: "folder", folderName: "Prod" }),
|
||||
}),
|
||||
<HostDefaultBadge settingKey="core.sshPort" />,
|
||||
);
|
||||
expect(screen.getByText("hostDefaults.source.folder:Prod")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows nothing for a value that follows the built-in default", () => {
|
||||
const { container } = withContext(
|
||||
context({ source: () => ({ level: "builtin" }) }),
|
||||
<HostDefaultBadge settingKey="core.sshPort" />,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("offers a reset for a host's own value", () => {
|
||||
const value = context({ isOwn: () => true });
|
||||
withContext(value, <HostDefaultBadge settingKey="core.sshPort" />);
|
||||
fireEvent.click(screen.getByText("hostDefaults.custom"));
|
||||
expect(value.reset).toHaveBeenCalledWith("core.sshPort");
|
||||
});
|
||||
|
||||
it("offers a clear for a value set at the level being edited", () => {
|
||||
withContext(
|
||||
context({ mode: "defaults", isOwn: () => true, source: () => undefined }),
|
||||
<HostDefaultBadge settingKey="core.sshPort" />,
|
||||
);
|
||||
expect(screen.getByText("hostDefaults.setHere")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says when nothing is set", () => {
|
||||
withContext(
|
||||
context({ source: () => undefined }),
|
||||
<HostDefaultBadge settingKey="core.username" />,
|
||||
);
|
||||
expect(screen.getByText("hostDefaults.notSet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("in the defaults editor", () => {
|
||||
it("hides a row whose key cannot be a default at this level", () => {
|
||||
withContext(
|
||||
context({ mode: "defaults", isAvailable: () => false }),
|
||||
<SettingRow label="Jump hosts" defaultKey="core.jumpHosts">
|
||||
<span />
|
||||
</SettingRow>,
|
||||
);
|
||||
expect(screen.queryByText("Jump hosts")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides what only one host can have", () => {
|
||||
withContext(
|
||||
context({ mode: "defaults" }),
|
||||
<HostOnly>
|
||||
<span>Address</span>
|
||||
</HostOnly>,
|
||||
);
|
||||
expect(screen.queryByText("Address")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { dispatchKeybindingAction } from "../../lib/keybinding-dispatch";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { KeybindingDispatchContext } from "../../lib/keybinding-dispatch";
|
||||
|
||||
function makeContext(
|
||||
overrides: Partial<KeybindingDispatchContext> = {},
|
||||
): KeybindingDispatchContext & {
|
||||
sentData: string[];
|
||||
} {
|
||||
const sentData: string[] = [];
|
||||
const ws = {
|
||||
readyState: 1,
|
||||
send: vi.fn((raw: string) => {
|
||||
sentData.push(JSON.parse(raw).data);
|
||||
}),
|
||||
};
|
||||
|
||||
const ctx: KeybindingDispatchContext & { sentData: string[] } = {
|
||||
terminal: {
|
||||
getSelection: vi.fn(() => ""),
|
||||
clearSelection: vi.fn(),
|
||||
paste: vi.fn(),
|
||||
} as unknown as Terminal,
|
||||
webSocketRef: { current: ws as unknown as WebSocket },
|
||||
writeTextToClipboard: vi.fn().mockResolvedValue(true),
|
||||
readTextFromClipboard: vi.fn().mockResolvedValue(""),
|
||||
getSnippetById: vi.fn().mockResolvedValue(undefined),
|
||||
sentData,
|
||||
...overrides,
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
describe("dispatchKeybindingAction", () => {
|
||||
it("copy writes the selection to clipboard and clears it", () => {
|
||||
const ctx = makeContext();
|
||||
(ctx.terminal.getSelection as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
"hello",
|
||||
);
|
||||
dispatchKeybindingAction({ type: "copy" }, ctx);
|
||||
expect(ctx.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
expect(ctx.terminal.clearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copy does nothing when there is no selection", () => {
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction({ type: "copy" }, ctx);
|
||||
expect(ctx.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
expect(ctx.terminal.clearSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("paste reads the clipboard and pastes into the terminal", async () => {
|
||||
const ctx = makeContext({
|
||||
readTextFromClipboard: vi.fn().mockResolvedValue("pasted text"),
|
||||
});
|
||||
dispatchKeybindingAction({ type: "paste" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(ctx.terminal.paste).toHaveBeenCalledWith("pasted text");
|
||||
});
|
||||
|
||||
it("sendControlCode sends the corresponding control byte", () => {
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction(
|
||||
{ type: "sendControlCode", controlCode: "c" },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.sentData).toEqual(["\x03"]);
|
||||
});
|
||||
|
||||
it("sendText sends literal text without a trailing return by default", () => {
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction({ type: "sendText", text: "ls -la" }, ctx);
|
||||
expect(ctx.sentData).toEqual(["ls -la"]);
|
||||
});
|
||||
|
||||
it("sendText appends \\r when appendEnter is true", () => {
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction(
|
||||
{ type: "sendText", text: "ls -la", appendEnter: true },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.sentData).toEqual(["ls -la\r"]);
|
||||
});
|
||||
|
||||
it("runSnippet resolves the snippet and sends its content with a trailing return", async () => {
|
||||
const ctx = makeContext({
|
||||
getSnippetById: vi.fn().mockResolvedValue({ content: "uptime" }),
|
||||
});
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(ctx.sentData).toEqual(["uptime\r"]);
|
||||
});
|
||||
|
||||
it("runSnippet does nothing when the snippet no longer exists", async () => {
|
||||
const ctx = makeContext({
|
||||
getSnippetById: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
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,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn(() => true));
|
||||
const get = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
vi.mock("@/main-axios", () => ({ authApi: { get } }));
|
||||
|
||||
const { getLinkedSession, notifySyncChanged } =
|
||||
await import("@/lib/linked-server");
|
||||
|
||||
beforeEach(() => {
|
||||
notifySyncChanged();
|
||||
get.mockReset();
|
||||
isElectron.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("linked session", () => {
|
||||
it("reads the session from the embedded backend and caches it", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: { serverUrl: "https://termix.example/", token: "t" },
|
||||
});
|
||||
await expect(getLinkedSession()).resolves.toEqual({
|
||||
serverUrl: "https://termix.example",
|
||||
token: "t",
|
||||
});
|
||||
await getLinkedSession();
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
expect(get).toHaveBeenCalledWith("/sync/link/session");
|
||||
});
|
||||
|
||||
it("reads it again after a sync change", async () => {
|
||||
get.mockResolvedValue({ data: { serverUrl: null, token: null } });
|
||||
await expect(getLinkedSession()).resolves.toBeNull();
|
||||
notifySyncChanged();
|
||||
get.mockResolvedValue({ data: { serverUrl: "https://x", token: "t" } });
|
||||
await expect(getLinkedSession()).resolves.toEqual({
|
||||
serverUrl: "https://x",
|
||||
token: "t",
|
||||
});
|
||||
});
|
||||
|
||||
it("is null on the web and when the backend cannot answer", async () => {
|
||||
isElectron.mockReturnValue(false);
|
||||
await expect(getLinkedSession()).resolves.toBeNull();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
|
||||
isElectron.mockReturnValue(true);
|
||||
get.mockRejectedValue(new Error("down"));
|
||||
await expect(getLinkedSession()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getMacLineNavigationSequence } from "../../lib/mac-line-navigation";
|
||||
|
||||
function keyEvent(
|
||||
init: Partial<KeyboardEvent> & Pick<KeyboardEvent, "code">,
|
||||
): KeyboardEvent {
|
||||
return {
|
||||
type: "keydown",
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...init,
|
||||
} as KeyboardEvent;
|
||||
}
|
||||
|
||||
describe("getMacLineNavigationSequence", () => {
|
||||
it("maps Cmd+arrow keys on macOS", () => {
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel" });
|
||||
expect(
|
||||
getMacLineNavigationSequence(
|
||||
keyEvent({ code: "ArrowLeft", metaKey: true }),
|
||||
),
|
||||
).toBe("\x01");
|
||||
expect(
|
||||
getMacLineNavigationSequence(
|
||||
keyEvent({ code: "ArrowRight", metaKey: true }),
|
||||
),
|
||||
).toBe("\x05");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ignores modified and non-mac shortcuts", () => {
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel" });
|
||||
expect(
|
||||
getMacLineNavigationSequence(
|
||||
keyEvent({ code: "ArrowLeft", metaKey: true, shiftKey: true }),
|
||||
),
|
||||
).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
vi.stubGlobal("navigator", { platform: "Win32" });
|
||||
expect(
|
||||
getMacLineNavigationSequence(
|
||||
keyEvent({ code: "ArrowLeft", metaKey: true }),
|
||||
),
|
||||
).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { matchesPermission } from "@/lib/permissions";
|
||||
|
||||
/**
|
||||
* These cases mirror PermissionManager.hasPermission on the backend. If that
|
||||
* changes, this file is the tripwire.
|
||||
*/
|
||||
describe("matchesPermission", () => {
|
||||
it("grants on an exact match", () => {
|
||||
expect(
|
||||
matchesPermission(["ai.services.use"], false, "ai.services.use"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("grants on the superuser wildcard", () => {
|
||||
expect(matchesPermission(["*"], false, "anything.at.all")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants on each dotted wildcard level", () => {
|
||||
expect(matchesPermission(["ai.services.*"], false, "ai.services.use")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(matchesPermission(["ai.*"], false, "ai.services.use")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants to an admin with no explicit permissions", () => {
|
||||
expect(matchesPermission([], true, "ai.services.use")).toBe(true);
|
||||
});
|
||||
|
||||
it("denies a non-admin without a matching grant", () => {
|
||||
expect(matchesPermission([], false, "ai.services.use")).toBe(false);
|
||||
expect(matchesPermission(["hosts.view"], false, "ai.services.use")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat a prefix as a wildcard without the star", () => {
|
||||
expect(matchesPermission(["ai.services"], false, "ai.services.use")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a sibling wildcard grant an unrelated permission", () => {
|
||||
expect(matchesPermission(["hosts.*"], false, "ai.services.use")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a single-segment permission", () => {
|
||||
expect(matchesPermission(["admin"], false, "admin")).toBe(true);
|
||||
// "admin.*" covers bare "admin" too: the backend builds the same
|
||||
// single-segment wildcard, so the frontend must agree.
|
||||
expect(matchesPermission(["admin.*"], false, "admin")).toBe(true);
|
||||
expect(matchesPermission(["other"], false, "admin")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ electron: false }));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: { defaults: { baseURL: "http://server:8080/" } },
|
||||
createRemoteOriginApiInstance: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron: () => state.electron }));
|
||||
vi.mock("@/lib/device-id", () => ({ getDeviceId: () => "device-1" }));
|
||||
|
||||
import { pluginFetch } from "@/lib/plugin-transport";
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response("ok"));
|
||||
|
||||
beforeEach(() => {
|
||||
state.electron = false;
|
||||
fetchMock.mockClear();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("pluginFetch", () => {
|
||||
it("fetches the plugin's own mount point with the session cookie", async () => {
|
||||
await pluginFetch("ai", "chat/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url).toBe("http://server:8080/plugin-api/ai/chat/stream");
|
||||
expect(init.credentials).toBe("include");
|
||||
expect(init.method).toBe("POST");
|
||||
const headers = init.headers as Headers;
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(headers.get("x-termix-device-id")).toBe("device-1");
|
||||
expect(headers.get("authorization")).toBeNull();
|
||||
});
|
||||
|
||||
it("sends the stored token in the desktop app, like the shared client", async () => {
|
||||
state.electron = true;
|
||||
localStorage.setItem("jwt", "token-1");
|
||||
|
||||
await pluginFetch("ai", "/status");
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url).toBe("http://server:8080/plugin-api/ai/status");
|
||||
const headers = init.headers as Headers;
|
||||
expect(headers.get("x-electron-app")).toBe("true");
|
||||
expect(headers.get("authorization")).toBe("Bearer token-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { rem, remScale } from "@/lib/rem";
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.style.fontSize = "";
|
||||
});
|
||||
|
||||
describe("rem", () => {
|
||||
it("states a Normal-size pixel length in rem", () => {
|
||||
expect(rem(14)).toBe("1rem");
|
||||
expect(rem(160)).toBe(`${160 / 14}rem`);
|
||||
});
|
||||
|
||||
it("reports how much the interface size scales a pixel", () => {
|
||||
document.documentElement.style.fontSize = "14px";
|
||||
expect(remScale()).toBe(1);
|
||||
document.documentElement.style.fontSize = "17.5px";
|
||||
expect(remScale()).toBe(1.25);
|
||||
});
|
||||
});
|
||||
@@ -1,112 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn());
|
||||
const getLinkedSession = vi.hoisted(() => vi.fn());
|
||||
const remoteApi = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
vi.mock("@/main-axios", () => ({ getRemoteStatsApi: () => remoteApi }));
|
||||
vi.mock("@/lib/linked-server", () => ({ getLinkedSession }));
|
||||
vi.mock("@/main-axios", () => ({ getRemoteCoreApi: () => remoteApi }));
|
||||
|
||||
import {
|
||||
getConnectedRemoteApi,
|
||||
hydrateLocalSharedHostAuth,
|
||||
markRemoteSharedHosts,
|
||||
resolveRemoteHostId,
|
||||
} from "@/lib/remote-server-api";
|
||||
|
||||
describe("remote server API", () => {
|
||||
const invoke = vi.fn();
|
||||
const LINKED = { serverUrl: "https://termix.example", token: "t" };
|
||||
|
||||
describe("remote server API", () => {
|
||||
beforeEach(() => {
|
||||
isElectron.mockReset();
|
||||
getLinkedSession.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);
|
||||
it("is available only while this desktop is linked", async () => {
|
||||
getLinkedSession.mockResolvedValueOnce(null);
|
||||
await expect(getConnectedRemoteApi()).resolves.toBeNull();
|
||||
|
||||
isElectron.mockReturnValue(true);
|
||||
invoke.mockResolvedValueOnce({ serverUrl: "https://termix.example" });
|
||||
getLinkedSession.mockResolvedValueOnce(LINKED);
|
||||
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" }] },
|
||||
});
|
||||
it("asks the linked server for its id of a host by sync id", async () => {
|
||||
getLinkedSession.mockResolvedValue(LINKED);
|
||||
remoteApi.get.mockResolvedValueOnce({ data: { id: 41, name: "web" } });
|
||||
|
||||
await expect(resolveRemoteHostId("host-a")).resolves.toBe(41);
|
||||
await expect(resolveRemoteHostId("host a")).resolves.toBe(41);
|
||||
expect(remoteApi.get).toHaveBeenCalledWith("/sync/v2/hosts/host%20a");
|
||||
});
|
||||
|
||||
it("answers null for an unknown host, no sync id, or no link", async () => {
|
||||
getLinkedSession.mockResolvedValue(LINKED);
|
||||
remoteApi.get.mockRejectedValueOnce(new Error("404"));
|
||||
await expect(resolveRemoteHostId("missing")).resolves.toBeNull();
|
||||
expect(remoteApi.get).toHaveBeenCalledWith("/sync/hosts");
|
||||
await expect(resolveRemoteHostId(null)).resolves.toBeNull();
|
||||
|
||||
getLinkedSession.mockResolvedValue(null);
|
||||
await expect(resolveRemoteHostId("host-a")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
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",
|
||||
connectionOrigin: "local",
|
||||
},
|
||||
] as SSHHost[]);
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
id: -9,
|
||||
syncId: "shared-host",
|
||||
connectionOrigin: "local",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates a remote shared host for a local connection without persisting remote ids", async () => {
|
||||
isElectron.mockReturnValue(true);
|
||||
invoke.mockResolvedValue({ serverUrl: "https://termix.example" });
|
||||
remoteApi.get.mockResolvedValue({
|
||||
data: {
|
||||
username: "recipient",
|
||||
authType: "key",
|
||||
key: "PRIVATE KEY",
|
||||
keyPassword: "passphrase",
|
||||
keyType: "ed25519",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await hydrateLocalSharedHostAuth({
|
||||
id: -9,
|
||||
isShared: true,
|
||||
syncId: "shared-host",
|
||||
credentialId: 77,
|
||||
username: "owner",
|
||||
authType: "key",
|
||||
});
|
||||
|
||||
expect(remoteApi.get).toHaveBeenCalledWith(
|
||||
"/host/db/host/9/local-connection-auth",
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
id: -9,
|
||||
syncId: null,
|
||||
credentialId: undefined,
|
||||
username: "recipient",
|
||||
key: "PRIVATE KEY",
|
||||
keyPassword: "passphrase",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves local and owned hosts untouched", async () => {
|
||||
const host = { id: 9, isShared: false, username: "root" };
|
||||
it("leaves hosts as they are, since shared copies carry their own auth", async () => {
|
||||
const host = { id: 9, isShared: true, username: "root" };
|
||||
await expect(hydrateLocalSharedHostAuth(host)).resolves.toBe(host);
|
||||
expect(remoteApi.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 4405 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user