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:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import { hostImportNormalizer } from "../../src/backend/host-import.js";
import type {
LiveSessionsV1,
TerminalHistoryV1,
} from "../../src/backend/services.js";
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("ssh-terminal activate", () => {
it("serves the terminal socket", async () => {
server = await startServer();
expect(server.mock.wsRoutes).toMatchObject([
{ path: "/terminal", raw: false },
]);
});
it("provides sessions.live as ssh and terminal.history", async () => {
server = await startServer();
const live = server.mock.services.get(
"sessions.live#ssh",
) as LiveSessionsV1;
expect(live.getSession("missing")).toBeNull();
expect(live.listForUser("user-1")).toEqual([]);
expect(live.write("missing", "ls\r")).toBe(false);
expect(live.idleTimeoutMinutes()).toBe(30);
await server.request("POST", "/command-history", {
body: { hostId: 1, command: "df -h" },
});
const history = server.mock.services.get(
"terminal.history",
) as TerminalHistoryV1;
const listed = await server.mock.actAs("user-1", () => history.list(1, 10));
expect(listed.map((entry) => entry.command)).toEqual(["df -h"]);
});
it("keeps a plugin caller to its own actor's sessions", async () => {
server = await startServer();
const live = server.mock.services.get(
"sessions.live#ssh",
) as LiveSessionsV1;
const other = await server.mock.actAs("user-2", async () =>
live.listForUser("user-1"),
);
expect(other).toEqual([]);
});
it("uses the admin session timeout for detached sessions", async () => {
server = await startServer({ settings: { sessionTimeoutMinutes: 90 } });
const live = server.mock.services.get(
"sessions.live#ssh",
) as LiveSessionsV1;
expect(live.idleTimeoutMinutes()).toBe(90);
});
it("offers a host import normalizer", async () => {
server = await startServer();
expect(
server.mock.ctx.registry.consume("ssh-terminal.hostImportNormalizer"),
).toBe(hostImportNormalizer);
});
it.each(["db:own", "network:serve"])(
"fails closed without %s",
async (capability) => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
for (const dispose of [...mock.disposals].reverse()) await dispose();
},
);
});
describe("host import", () => {
it("carries the terminal switches of an imported host", () => {
expect(hostImportNormalizer({ name: "web" })).toBeNull();
expect(
hostImportNormalizer({
enableTerminal: false,
enableCommandHistory: true,
}),
).toEqual({ enableTerminal: false, enableCommandHistory: true });
});
});
@@ -0,0 +1,126 @@
/**
* A share-link guest only ever acts as a participant. Once it has left the
* session it can send nothing, so a read-only guest cannot "disconnect" and
* then type into the owner's shell.
*/
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTerminalSocket } from "../../src/backend/terminal-socket.js";
class FakeSocket extends EventEmitter {
readyState = 1;
sent: string[] = [];
closedWith: number | null = null;
send(message: string) {
this.sent.push(message);
}
close(code = 1000) {
this.closedWith = code;
this.readyState = 3;
this.emit("close");
}
ping() {}
terminate() {
this.close(1006);
}
message(type: string, data?: unknown) {
this.emit("message", Buffer.from(JSON.stringify({ type, data })));
}
}
function fakeSessions(permissionLevel: "read-only" | "read-write") {
const written: string[] = [];
const participants = new Map<
unknown,
{ isOwner: boolean; permissionLevel: string }
>();
const session = {
isConnected: true,
sshStream: { write: (chunk: Buffer) => written.push(chunk.toString()) },
};
const manager = {
getSession: (id: string) => (id === "s1" ? session : null),
joinAsParticipant: (_id: string, ws: unknown) => {
participants.set(ws, { isOwner: false, permissionLevel });
return "s1";
},
getBuffer: () => "",
getParticipantForWs: (_session: unknown, ws: unknown) =>
participants.get(ws) ?? null,
removeParticipant: (_id: string, ws: unknown) => {
participants.delete(ws);
},
bufferInput: () => {},
};
return { manager, written };
}
function socketFor(permissionLevel: "read-only" | "read-write") {
const sessions = fakeSessions(permissionLevel);
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
const terminal = createTerminalSocket({
ctx: {} as never,
log: log as never,
sessionManager: sessions.manager as never,
getTmux: () => null,
getSharing: () => null,
getGuests: () => ({
resolve: async () => ({
ok: true,
share: { id: "share-1", sessionId: "s1", permissionLevel },
}),
recordJoin: async () => {},
}),
} as never);
return { terminal, written: sessions.written };
}
async function joinAsGuest(terminal: ReturnType<typeof socketFor>["terminal"]) {
const ws = new FakeSocket();
await terminal.handleConnection({
socket: ws,
request: { url: "/terminal?shareToken=abc", headers: {} },
userId: "",
clientIp: "10.0.0.1",
requestOrigin: "",
isDataUnlocked: () => false,
} as never);
return ws;
}
let cleanup: Array<() => void> = [];
afterEach(() => {
cleanup.forEach((fn) => fn());
cleanup = [];
});
describe("share-link guests", () => {
it("cannot type into a read-only session", async () => {
const { terminal, written } = socketFor("read-only");
const ws = await joinAsGuest(terminal);
cleanup.push(() => terminal.closeAll());
ws.message("input", "rm -rf ~\n");
expect(written).toEqual([]);
});
it("cannot type after leaving the session", async () => {
const { terminal, written } = socketFor("read-only");
const ws = await joinAsGuest(terminal);
cleanup.push(() => terminal.closeAll());
ws.message("disconnect");
ws.message("input", "rm -rf ~\n");
expect(written).toEqual([]);
expect(ws.closedWith).toBe(1000);
});
it("a read-write guest types until it leaves", async () => {
const { terminal, written } = socketFor("read-write");
const ws = await joinAsGuest(terminal);
cleanup.push(() => terminal.closeAll());
ws.message("input", "ls\n");
ws.message("disconnect");
ws.message("input", "whoami\n");
expect(written).toEqual(["ls\n"]);
});
});
@@ -0,0 +1,98 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it, with the acting user taken from a test header.
*/
export async function startServer(
options: {
permissions?: string[];
settings?: Record<string, unknown>;
hosts?: PluginHostSummary[];
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions,
settings: options.settings,
hosts: options.hosts,
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
for (const dispose of [...mock.disposals].reverse()) await dispose();
db.close();
},
};
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
hostImportNormalizer,
hostPayloadLegacy,
} from "../../src/backend/host-import";
describe("hostImportNormalizer", () => {
it("reads a 2.8 export's terminalConfig into the host settings", () => {
expect(
hostImportNormalizer({
enableTerminal: false,
terminalConfig: {
fontSize: 16,
autoTmux: true,
keepaliveInterval: 30,
startupSnippetId: 2,
},
}),
).toEqual({
enableTerminal: false,
inheritAppearance: false,
fontSize: 16,
autoTmux: true,
});
});
it("reads the raw JSON a SQLite export carries", () => {
expect(
hostImportNormalizer({
terminalConfig: JSON.stringify({ localEcho: "on" }),
}),
).toEqual({ localEcho: "on" });
});
it("leaves terminalConfig alone when the export carries the plugin's own values", () => {
expect(
hostImportNormalizer({
pluginSettings: { "ssh-terminal": { autoTmux: false } },
terminalConfig: { autoTmux: true },
}),
).toBeNull();
});
it("imports nothing from a row without terminal values", () => {
expect(hostImportNormalizer({ name: "x" })).toBeNull();
});
});
describe("hostPayloadLegacy", () => {
it("puts the settings back in the 2.8 terminalConfig shape", () => {
const { terminalConfig } = hostPayloadLegacy({
autoTmux: true,
inheritAppearance: true,
fontSize: 20,
}) as { terminalConfig: Record<string, unknown> };
expect(terminalConfig.autoTmux).toBe(true);
// The values already follow the host defaults, so the look is included.
expect(terminalConfig.fontSize).toBe(20);
expect(terminalConfig.localEcho).toBe("auto");
});
it("includes the look of a host that has its own", () => {
const { terminalConfig } = hostPayloadLegacy({
inheritAppearance: false,
fontSize: 20,
}) as { terminalConfig: Record<string, unknown> };
expect(terminalConfig.fontSize).toBe(20);
expect(terminalConfig.theme).toBe("termix");
});
});
@@ -0,0 +1,191 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
DEFAULT_IMAGE_HOST_PATH,
DEFAULT_IMAGE_MAX_BYTES,
DEFAULT_IMAGE_MAX_COUNT,
DEFAULT_IMAGE_TTL_MS,
defaultImageLocalDir,
parseImageHostPath,
parseImageLocalDir,
parseTerminalImageStorageMode,
resolveTerminalImageStorageSettings,
TERMINAL_IMAGE_STORAGE_KEYS,
} from "../../../src/backend/images/image-storage-settings.js";
import { createFakeContext } from "@termix/plugin-sdk/testing";
import { readImageStorageSettings } from "../../../src/backend/settings.js";
// parseImageLocalDir resolves against the host platform, so a POSIX literal
// becomes a drive-rooted path on Windows. Compare against the same resolution.
function localDir(posixPath: string): string {
return path.resolve(posixPath);
}
function stubSettings(values: Record<string, string> = {}) {
return {
get: async (key: string) => values[key] ?? null,
};
}
const EMPTY_ENV: NodeJS.ProcessEnv = {};
describe("terminal image storage settings", () => {
describe("mode parsing", () => {
it("accepts the three documented modes case-insensitively", () => {
expect(parseTerminalImageStorageMode("auto")).toBe("auto");
expect(parseTerminalImageStorageMode("LOCAL")).toBe("local");
expect(parseTerminalImageStorageMode(" remote-sftp ")).toBe(
"remote-sftp",
);
});
it("rejects unknown modes and non-strings", () => {
expect(parseTerminalImageStorageMode("s3")).toBeNull();
expect(parseTerminalImageStorageMode("")).toBeNull();
expect(parseTerminalImageStorageMode(undefined)).toBeNull();
});
});
describe("path validation", () => {
it("accepts absolute local directories and normalizes them", () => {
expect(parseImageLocalDir("/var/lib/termix/images")).toBe(
localDir("/var/lib/termix/images"),
);
expect(parseImageLocalDir("/var/lib/termix/../termix/images")).toBeNull();
});
it("rejects relative, empty and NUL-containing local directories", () => {
expect(parseImageLocalDir("images")).toBeNull();
expect(parseImageLocalDir("./db/data/images")).toBeNull();
expect(parseImageLocalDir("")).toBeNull();
expect(parseImageLocalDir("/tmp/a\0b")).toBeNull();
});
it("requires the agent-visible host path to be POSIX-absolute", () => {
expect(parseImageHostPath("/tmp/termix-image-v0")).toBe(
"/tmp/termix-image-v0",
);
expect(parseImageHostPath("tmp/termix-image-v0")).toBeNull();
expect(parseImageHostPath("C:\\images")).toBeNull();
expect(parseImageHostPath("/tmp/a\0b")).toBeNull();
});
});
describe("resolution precedence", () => {
it("uses built-in defaults when neither the database nor env has values", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
EMPTY_ENV,
);
expect(resolved.mode).toBe("auto");
expect(resolved.localDir).toBe(defaultImageLocalDir(EMPTY_ENV));
expect(resolved.hostPath).toBe(DEFAULT_IMAGE_HOST_PATH);
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
expect(resolved.maxCount).toBe(DEFAULT_IMAGE_MAX_COUNT);
expect(resolved.maxBytes).toBe(DEFAULT_IMAGE_MAX_BYTES);
});
it("seeds defaults from legacy TERMIX_IMAGE_* env when no DB value exists", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{
TERMIX_IMAGE_DIR: "/host-tmp/images",
TERMIX_IMAGE_HOST_PATH: "/tmp/images",
TERMIX_IMAGE_TTL_MS: "60000",
TERMIX_MAX_IMAGE_COUNT: "5",
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10485760",
},
);
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
expect(resolved.hostPath).toBe("/tmp/images");
expect(resolved.ttlMs).toBe(60_000);
expect(resolved.maxCount).toBe(5);
expect(resolved.maxBytes).toBe(10_485_760);
});
it("lets persisted DB values win over legacy env values", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "/db/images",
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "1000",
[TERMINAL_IMAGE_STORAGE_KEYS.maxCount]: "7",
}),
{
TERMIX_IMAGE_DIR: "/host-tmp/images",
TERMIX_IMAGE_TTL_MS: "60000",
TERMIX_MAX_IMAGE_COUNT: "5",
},
);
expect(resolved.localDir).toBe(localDir("/db/images"));
expect(resolved.ttlMs).toBe(1_000);
expect(resolved.maxCount).toBe(7);
});
it("falls through to env and defaults when the DB value is invalid", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "relative/path",
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "not-a-number",
}),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
});
it("keeps legacy explicit local mappings on local mode", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.mode).toBe("local");
// hostPath falls back to the legacy local dir, which resolves natively.
expect(resolved.hostPath).toBe(localDir("/host-tmp/images"));
expect(resolved.localMappingConfigured).toBe(true);
});
it("lets a persisted DB mode override the legacy local mapping", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: "remote-sftp",
}),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.mode).toBe("remote-sftp");
});
it("clamps out-of-range numeric values like the legacy env parsing did", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{
TERMIX_IMAGE_TTL_MS: "-5",
TERMIX_MAX_IMAGE_COUNT: "0",
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10",
},
);
expect(resolved.ttlMs).toBe(0);
expect(resolved.maxCount).toBe(1);
expect(resolved.maxBytes).toBe(1_048_576);
});
it("reads the plugin's admin settings, and treats unset fields as unset", async () => {
const { ctx } = createFakeContext({
settings: {
imageStorageMode: "local",
imageLocalDir: "/persisted/images",
},
});
const resolved = await readImageStorageSettings(ctx);
expect(resolved.mode).toBe("local");
expect(resolved.localDir).toBe(localDir("/persisted/images"));
// Nothing stored and no environment: the built-in default.
expect(resolved.maxCount).toBe(DEFAULT_IMAGE_MAX_COUNT);
});
it("keeps TERMINAL_IMAGE_STORAGE_KEYS as the lookup names", () => {
expect(TERMINAL_IMAGE_STORAGE_KEYS.mode).toBe(
"terminal_image_storage_mode",
);
});
});
});
@@ -0,0 +1,571 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EventEmitter } from "events";
import fs from "fs/promises";
import os from "os";
import path from "path";
import { randomUUID } from "crypto";
import {
REMOTE_IMAGE_DIR,
selectImageStorageMode,
storeImageLocally,
storeImageViaSftp,
TerminalImageStorageError,
type ImageSftpClient,
} from "../../../src/backend/images/image-storage.js";
import type { TerminalImageStorageSettings } from "../../../src/backend/images/image-storage-settings.js";
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
function settings(
overrides: Partial<TerminalImageStorageSettings>,
): TerminalImageStorageSettings {
return {
mode: "local",
localDir: "/nonexistent",
hostPath: "/tmp/termix-image-v0",
ttlMs: 3_600_000,
maxCount: 100,
maxBytes: 5_368_709_120,
localMappingConfigured: false,
...overrides,
};
}
describe("selectImageStorageMode", () => {
it("keeps explicit modes deterministic regardless of capability", () => {
expect(
selectImageStorageMode(settings({ mode: "local" }), {
remoteSftpAvailable: true,
}),
).toBe("local");
expect(
selectImageStorageMode(settings({ mode: "remote-sftp" }), {
remoteSftpAvailable: false,
}),
).toBe("remote-sftp");
});
it("falls back on capability only in auto mode", () => {
expect(
selectImageStorageMode(settings({ mode: "auto" }), {
remoteSftpAvailable: true,
}),
).toBe("remote-sftp");
expect(
selectImageStorageMode(settings({ mode: "auto" }), {
remoteSftpAvailable: false,
}),
).toBe("unavailable");
expect(
selectImageStorageMode(
settings({ mode: "auto", localMappingConfigured: true }),
{ remoteSftpAvailable: false, localHostVisible: true },
),
).toBe("local");
});
});
describe("storeImageLocally", () => {
let dir: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-images-test-"));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
async function seedFile(bytes: number, mtimeMs?: number): Promise<string> {
const name = `${randomUUID()}.png`;
const filePath = path.join(dir, name);
await fs.writeFile(filePath, Buffer.alloc(bytes));
if (mtimeMs !== undefined) {
const date = new Date(mtimeMs);
await fs.utimes(filePath, date, date);
}
return name;
}
it("writes a UUID-named PNG and returns the agent-visible host path", async () => {
const stored = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, hostPath: "/host-view/images" }),
);
expect(stored.storage).toBe("local");
expect(stored.filename).toBe(`${stored.id}.png`);
expect(stored.shellPath).toBe(
path.posix.join("/host-view/images", stored.filename),
);
expect(stored.shellPath).not.toContain(dir);
await expect(fs.readFile(path.join(dir, stored.filename))).resolves.toEqual(
PNG_BYTES,
);
});
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the count cap is full", async () => {
await seedFile(10);
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, maxCount: 1 }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the byte cap is full", async () => {
await seedFile(900);
const error = await storeImageLocally(
Buffer.alloc(200),
settings({ localDir: dir, maxBytes: 1_000 }),
).catch((caught: unknown) => caught);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("cleans expired files during upload so they no longer count", async () => {
await seedFile(10, Date.now() - 2 * 3_600_000);
const stored = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, maxCount: 1, ttlMs: 3_600_000 }),
);
expect(stored.storage).toBe("local");
const remaining = await fs.readdir(dir);
expect(remaining).toEqual([stored.filename]);
});
it("fails closed when local storage inspection is unavailable", async () => {
const blocked = path.join(dir, "blocked-file");
await fs.writeFile(blocked, "not a directory");
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: blocked }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_LOCAL_INSPECTION_FAILED",
);
});
it("reports inspection failures before attempting a write", async () => {
const blocked = path.join(dir, "blocked");
await fs.writeFile(blocked, "not a directory");
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: path.join(blocked, "images") }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_LOCAL_INSPECTION_FAILED",
);
});
});
describe("storeImageViaSftp", () => {
function fakeSftp(behavior: {
mkdirError?: Error;
writeError?: Error;
stallWrite?: boolean;
stallLock?: boolean;
stallReaddir?: boolean;
stallRmdir?: boolean;
stallUnlink?: boolean;
readdirEntries?: Array<{ filename: string; mtime?: number; size?: number }>;
readdirError?: Error;
statMode?: number;
}): {
sftp: ImageSftpClient;
written: Map<string, Buffer>;
calls: {
mkdir: Array<{ dir: string; mode?: number }>;
createWriteStream: Array<{ path: string; mode?: number }>;
};
streams: Array<
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
>;
} {
const written = new Map<string, Buffer>();
const streams: Array<
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
> = [];
const calls = { mkdir: [], createWriteStream: [] } as {
mkdir: Array<{ dir: string; mode?: number }>;
createWriteStream: Array<{ path: string; mode?: number }>;
};
const sftp = {
mkdir: (
dir: string,
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
maybeCallback?: (err?: Error) => void,
) => {
const callback =
typeof attrsOrCallback === "function"
? attrsOrCallback
: maybeCallback!;
calls.mkdir.push({
dir,
mode:
typeof attrsOrCallback === "function"
? undefined
: attrsOrCallback.mode,
});
if (
behavior.stallLock &&
dir === `${REMOTE_IMAGE_DIR}/.termix-write-lock`
) {
return;
}
callback(dir === REMOTE_IMAGE_DIR ? behavior.mkdirError : undefined);
},
stat: (
_dir: string,
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
) => callback(undefined, { mode: behavior.statMode ?? 0o40700 }),
chmod: (_dir: string, _mode: number, callback: (error?: Error) => void) =>
callback(),
readdir: (
_dir: string,
callback: (
error: Error | undefined,
entries: Array<{
filename: string;
attrs?: { mtime?: number; size?: number };
}>,
) => void,
) => {
if (behavior.stallReaddir) return;
callback(
behavior.readdirError,
behavior.readdirEntries?.map((entry) => ({
filename: entry.filename,
attrs: entry,
})) ?? [],
);
},
createWriteStream: (remotePath: string, options?: { mode?: number }) => {
calls.createWriteStream.push({ path: remotePath, mode: options?.mode });
const stream = new EventEmitter() as NodeJS.WritableStream & {
end: (data: Buffer) => void;
destroy: () => void;
destroyed: boolean;
};
stream.destroyed = false;
stream.destroy = () => {
stream.destroyed = true;
};
streams.push(stream);
(stream as { end: unknown }).end = (data: Buffer) => {
if (behavior.stallWrite) return;
queueMicrotask(() => {
if (behavior.writeError) {
stream.emit("error", behavior.writeError);
return;
}
written.set(remotePath, data);
stream.emit("close");
});
};
return stream;
},
unlink: (_remotePath: string, callback: (error?: Error) => void) => {
if (behavior.stallUnlink) return;
callback();
},
rmdir: (_dir: string, callback: (error?: Error) => void) => {
if (behavior.stallRmdir) return;
callback();
},
} as unknown as ImageSftpClient;
return { sftp, written, calls, streams };
}
it("writes into the remote image directory and returns its POSIX path", async () => {
const { sftp, written } = fakeSftp({});
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
expect(stored.storage).toBe("remote-sftp");
expect(stored.shellPath).toBe(`${REMOTE_IMAGE_DIR}/${stored.id}.png`);
expect(written.get(stored.shellPath)).toEqual(PNG_BYTES);
});
it("requests restrictive modes for the remote directory and file", async () => {
const { sftp, calls } = fakeSftp({});
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
expect(stored.storage).toBe("remote-sftp");
expect(calls.mkdir).toEqual([
{ dir: REMOTE_IMAGE_DIR, mode: 0o700 },
{ dir: `${REMOTE_IMAGE_DIR}/.termix-write-lock`, mode: 0o700 },
]);
expect(calls.createWriteStream).toEqual([
{ path: stored.shellPath, mode: 0o600 },
]);
});
it("removes expired UUID PNGs with best-effort remote retention", async () => {
const nowMs = 10_000_000;
const expiredName = `${randomUUID()}.png`;
const freshName = `${randomUUID()}.png`;
const unlinked: string[] = [];
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient & {
readdir: (
dir: string,
callback: (
err: Error | undefined,
entries: Array<{ filename: string; attrs?: { mtime?: number } }>,
) => void,
) => void;
unlink: (remotePath: string, callback: (err?: Error) => void) => void;
};
sftp.readdir = (_dir, callback) =>
callback(undefined, [
{ filename: expiredName, attrs: { mtime: (nowMs - 7_200_000) / 1000 } },
{ filename: freshName, attrs: { mtime: nowMs / 1000 } },
{ filename: "other.txt", attrs: { mtime: 0 } },
]);
sftp.unlink = (remotePath, callback) => {
unlinked.push(remotePath);
callback();
};
await storeImageViaSftp(sftp, PNG_BYTES, {
ttlMs: 3_600_000,
nowMs,
});
expect(unlinked).toEqual([`${REMOTE_IMAGE_DIR}/${expiredName}`]);
});
it("fails closed when remote quota inspection fails", async () => {
const { sftp } = fakeSftp({
readdirError: new Error("remote listing failed"),
});
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 10,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
);
});
it("rejects remote writes at the configured count limit", async () => {
const { sftp } = fakeSftp({
readdirEntries: [{ filename: `${randomUUID()}.png`, size: 12 }],
});
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 1,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("destroys a stalled SFTP write after its timeout", async () => {
const { sftp, streams } = fakeSftp({ stallWrite: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
writeTimeoutMs: 25,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
expect(streams[0]!.destroyed).toBe(true);
});
it("rejects an existing remote path that is not a directory", async () => {
const { sftp } = fakeSftp({
mkdirError: new Error("Failure: file already exists"),
statMode: 0o100644,
});
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
});
it("preserves SFTP client context for directory inspection", async () => {
const base = fakeSftp({ mkdirError: new Error("already exists") });
const sftp = base.sftp;
sftp.lstat = function (
this: ImageSftpClient,
_dir: string,
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
) {
if (this !== sftp) {
callback(new Error("SFTP context lost"));
return;
}
callback(undefined, { mode: 0o40700 });
};
const result = await storeImageViaSftp(sftp, PNG_BYTES);
expect(result.storage).toBe("remote-sftp");
});
it("tolerates mkdir failures for an already-existing directory", async () => {
const { sftp } = fakeSftp({
mkdirError: new Error("Failure: file already exists"),
});
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
storage: "remote-sftp",
});
});
it("cleans up a partially created remote file after a write failure", async () => {
const { sftp } = fakeSftp({ writeError: new Error("write failed") });
const unlinked: string[] = [];
(sftp as ImageSftpClient).unlink = (remotePath, callback) => {
unlinked.push(remotePath);
callback();
};
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect(unlinked).toHaveLength(1);
expect(unlinked[0]).toMatch(
new RegExp(`${REMOTE_IMAGE_DIR}/[0-9a-f-]+\\.png`),
);
});
it("preserves the write error when partial-file cleanup stalls", async () => {
const { sftp } = fakeSftp({
writeError: new Error("write failed"),
stallUnlink: true,
});
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 5_000);
it("recovers a stale remote lock before writing", async () => {
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient;
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
let lockAttempt = 0;
const removed: string[] = [];
const originalMkdir = sftp.mkdir.bind(sftp);
sftp.mkdir = (dir, attrs, callback) => {
if (dir === lockPath && lockAttempt++ === 0) {
(typeof attrs === "function" ? attrs : callback!)(new Error("exists"));
return;
}
originalMkdir(dir, attrs, callback);
};
sftp.lstat = (_dir, callback) =>
callback(undefined, {
mode: 0o40700,
mtime: (Date.now() - 60_000) / 1000,
});
sftp.rmdir = (dir, callback) => {
removed.push(dir);
callback();
};
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
storage: "remote-sftp",
});
expect(removed).toContain(lockPath);
});
it("does not replace a successful write with an unlock failure", async () => {
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient;
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
const originalMkdir = sftp.mkdir.bind(sftp);
sftp.mkdir = (dir, attrs, callback) => originalMkdir(dir, attrs, callback);
sftp.rmdir = (dir, callback) => {
if (dir === lockPath) callback(new Error("unlock failed"));
else callback();
};
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
});
it("fails closed when remote lock acquisition stalls", async () => {
const { sftp } = fakeSftp({ stallLock: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 12_000);
it("fails closed when remote quota inspection stalls", async () => {
const { sftp } = fakeSftp({ stallReaddir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 10,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 7_000);
it("fails closed when remote expiry cleanup stalls", async () => {
const { sftp } = fakeSftp({ stallReaddir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
ttlMs: 1_000,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 7_000);
it("fails closed when remote lock release stalls", async () => {
const { sftp } = fakeSftp({ stallRmdir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 5_000);
it("maps SFTP failures to IMAGE_REMOTE_WRITE_FAILED", async () => {
const { sftp } = fakeSftp({ writeError: new Error("Permission denied") });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
expect((error as TerminalImageStorageError).message).toBe(
"Failed to write image to the remote host",
);
});
});
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
exceedsImageStorageLimit,
exceedsNormalizedImageSize,
imageExtensionForFormat,
createConcurrencyLimiter,
isExpiredImage,
isImageFilename,
} from "../../../src/backend/images/image-utils.js";
describe("terminal image utilities", () => {
it("accepts UUID-based image filenames", () => {
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506.jpeg")).toBe(
true,
);
});
it("rejects unsafe or unrelated filenames", () => {
expect(isImageFilename("../secrets.txt")).toBe(false);
expect(isImageFilename("not-an-image.jpeg")).toBe(false);
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506")).toBe(false);
});
it("maps decoded raster formats while excluding SVG", () => {
expect(imageExtensionForFormat("jpeg")).toBe("jpg");
expect(imageExtensionForFormat("heif")).toBe("heif");
expect(imageExtensionForFormat("tiff")).toBe("tiff");
expect(imageExtensionForFormat("svg")).toBeUndefined();
expect(imageExtensionForFormat(undefined)).toBeUndefined();
});
it("rejects normalized output beyond the byte ceiling", () => {
expect(exceedsNormalizedImageSize(10_000_001, 10_000_000)).toBe(true);
expect(exceedsNormalizedImageSize(10_000_000, 10_000_000)).toBe(false);
});
it("expires files older than the configured TTL", () => {
expect(isExpiredImage(1_000, 3_000, 1_000)).toBe(true);
expect(isExpiredImage(2_500, 3_000, 1_000)).toBe(false);
});
it("treats a zero TTL as retention disabled", () => {
expect(isExpiredImage(1_000, 999_999, 0)).toBe(false);
});
it("bounds the admission queue", async () => {
const limiter = createConcurrencyLimiter(1, 0);
const release = await limiter.acquire();
await expect(limiter.acquire()).rejects.toThrow("queue is full");
release();
});
it("rejects uploads that exceed the count or byte limit", () => {
expect(exceedsImageStorageLimit(100, 10, 1, 100, 1000)).toBe(true);
expect(exceedsImageStorageLimit(1, 900, 101, 100, 1000)).toBe(true);
expect(exceedsImageStorageLimit(1, 900, 100, 100, 1000)).toBe(false);
});
it("bounds concurrent work", async () => {
const limiter = createConcurrencyLimiter(1);
let active = 0;
let peak = 0;
const task = async () => {
const release = await limiter.acquire();
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
release();
};
await Promise.all([task(), task(), task()]);
expect(peak).toBe(1);
expect(limiter.active).toBe(0);
});
});
@@ -0,0 +1,298 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
PluginKeyboardInteractiveDecision,
PluginKeyboardInteractivePrompt,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import { SSHAuthManager } from "../../src/backend/keyboard-prompt.js";
/**
* Stands in for core's classifier (ctx.ssh.classifyKeyboardInteractive),
* which core's own tests cover. These tests are about what the terminal does
* with each decision: the socket messages, the answers and the timeouts.
*/
const PASSWORD = /password/i;
const FORTI = /type\s+['"]?push['"]?/i;
const PUSH =
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
const TOTP =
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i;
function classify(
round: {
name: string;
instructions: string;
prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
): PluginKeyboardInteractiveDecision {
// Stands in for a plugin's keyboard-interactive handler.
if (/gateway sign-in/i.test(round.name)) {
return {
kind: "browser",
id: "gateway",
label: "Gateway",
url: round.instructions.match(/https?:\/\/\S+/)?.[0] ?? null,
code: round.instructions.match(/Security key: (\S+)/)?.[1] ?? "",
instructions: round.instructions,
};
}
const texts = round.prompts.map((p) => p.prompt);
const isPush =
!texts.some((t) => FORTI.test(t)) && texts.some((t) => PUSH.test(t));
if (!isPush) {
const totp = round.prompts.findIndex((p) => TOTP.test(p.prompt));
if (totp !== -1) return { kind: "totp", promptIndex: totp };
}
const stored = !!host.password && host.authType !== "none";
const first = round.prompts.findIndex(
(p) => !(PASSWORD.test(p.prompt) && stored),
);
if (first === -1) {
return {
kind: "auto",
responses: round.prompts.map((p) =>
PASSWORD.test(p.prompt) ? String(host.password) : "",
),
};
}
return { kind: "input", promptIndex: first, isPush: PUSH.test(texts[first]) };
}
const log = { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() };
function createManager() {
const sent: Record<string, unknown>[] = [];
const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
const manager = new SSHAuthManager({
ssh: { classifyKeyboardInteractive: classify },
log,
userId: "user-1",
ws,
hostId: 1,
isKeyboardInteractive: false,
keyboardInteractiveResponded: false,
keyboardInteractiveFinish: null,
totpPromptSent: false,
browserSignInId: null,
totpTimeout: null,
browserSignInTimeout: null,
totpAttempts: 0,
});
return { manager, sent };
}
describe("SSHAuthManager.handleKeyboardInteractive", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("routes a TOTP verification prompt to the totp flow", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Verification code: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "TOTP verification required",
},
},
{ type: "totp_required", prompt: "Verification code: " },
]);
expect(finish).not.toHaveBeenCalled();
});
it("forwards echo:true for a JumpCloud-style push/TOTP menu prompt", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Password authentication required",
},
},
{
type: "password_required",
prompt: "Choose [1] Push, or [2] TOTP: ",
echo: true,
},
]);
});
it("silently auto-answers a plain password prompt when a stored password exists", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Password: ", echo: false }],
finish,
{ username: "root", password: "hunter2", authType: "password" },
);
expect(finish).toHaveBeenCalledWith(["hunter2"]);
expect(sent).toEqual([]);
});
it("prompts the user for a push-confirm prompt and accepts an empty response", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Press enter to send Push request: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Password authentication required",
},
},
{
type: "password_required",
prompt: "Press enter to send Push request: ",
echo: true,
},
]);
manager.context.keyboardInteractiveFinish?.([""]);
expect(finish).toHaveBeenCalledWith([""]);
});
it("uses a longer timeout for push-style prompts than generic prompts", () => {
vi.useFakeTimers();
try {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Press enter to send Push request: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
vi.advanceTimersByTime(180001);
expect(sent.some((m) => m.type === "error")).toBe(false);
vi.advanceTimersByTime(120000);
expect(sent.some((m) => m.type === "error")).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("routes a FortiToken prompt to the totp flow instead of the push-confirm flow", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[
{
prompt:
"Enter your Token or type 'push' to receive a push notification: ",
echo: true,
},
],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "TOTP verification required",
},
},
{
type: "totp_required",
prompt:
"Enter your Token or type 'push' to receive a push notification: ",
},
]);
manager.context.keyboardInteractiveFinish?.(["push"]);
expect(finish).toHaveBeenCalledWith(["push"]);
});
it("sends a claimed browser round under its handler's name", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"Gateway sign-in",
"Visit https://gateway.example.com/auth to continue. Security key: AB12",
"",
[{ prompt: "Press enter once done: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Gateway sign-in required",
},
},
{
type: "gateway_auth_required",
kind: "browser",
label: "Gateway",
url: "https://gateway.example.com/auth",
securityKey: "AB12",
instructions:
"Visit https://gateway.example.com/auth to continue. Security key: AB12",
},
]);
expect(manager.context.browserSignInId).toBe("gateway");
});
});
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from "vitest";
import { generateKeyPairSync } from "crypto";
import ssh2Pkg, { type ParsedKey } from "ssh2";
import { MemoryAgent } from "../../src/backend/helpers.js";
describe("MemoryAgent", () => {
it("serves identities and signatures over the agent protocol", async () => {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const parsed = ssh2Pkg.utils.parseKey(
privateKey.export({ type: "pkcs1", format: "pem" }),
);
expect(parsed).not.toBeInstanceOf(Error);
const agent = new MemoryAgent(parsed as ParsedKey);
const stream = await new Promise<NodeJS.ReadWriteStream>(
(resolve, reject) => {
agent.getStream((error, result) => {
if (error || !result)
reject(error ?? new Error("Missing agent stream"));
else resolve(result);
});
},
);
const client = new ssh2Pkg.AgentProtocol(true);
client.pipe(stream).pipe(client);
const identities = await new Promise<ParsedKey[]>((resolve, reject) => {
client.getIdentities((error, keys) => {
if (error || !keys) reject(error ?? new Error("Missing identities"));
else resolve(keys);
});
});
expect(identities).toHaveLength(1);
expect(identities[0].getPublicSSH()).toEqual(
(parsed as ParsedKey).getPublicSSH(),
);
const data = Buffer.from("forwarded-agent-test");
const signature = await new Promise<Buffer>((resolve, reject) => {
client.sign(identities[0], data, (error, result) => {
if (error || !result) reject(error ?? new Error("Missing signature"));
else resolve(result);
});
});
expect((parsed as ParsedKey).verify(data, signature)).toBe(true);
client.destroy();
(stream as unknown as { destroy: () => void }).destroy();
});
});
describe("MemoryAgent unsupported extension framing", () => {
it.each([false, true])(
"can enumerate keys after rejecting session-bind (fragmented=%s)",
async (fragmented) => {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
});
const key = ssh2Pkg.utils.parseKey(
privateKey.export({ type: "pkcs1", format: "pem" }),
) as ParsedKey;
const agent = new MemoryAgent(key);
const stream = await new Promise<NodeJS.ReadWriteStream>(
(resolve, reject) => {
agent.getStream((error, result) =>
error || !result ? reject(error) : resolve(result),
);
},
);
const replies: Buffer[] = [];
let buffered = Buffer.alloc(0);
stream.on("data", (data: Buffer) => {
buffered = Buffer.concat([buffered, data]);
while (
buffered.length >= 4 &&
buffered.length >= buffered.readUInt32BE(0) + 4
) {
const end = buffered.readUInt32BE(0) + 4;
replies.push(buffered.subarray(4, end));
buffered = buffered.subarray(end);
}
});
const extension = Buffer.from("session-bind@openssh.com");
const request = Buffer.alloc(4 + 1 + 4 + extension.length);
request.writeUInt32BE(request.length - 4, 0);
request[4] = 27;
request.writeUInt32BE(extension.length, 5);
extension.copy(request, 9);
const identities = Buffer.from([0, 0, 0, 1, 11]);
try {
const requests = Buffer.concat([request, identities]);
if (fragmented) {
stream.write(requests.subarray(0, 7));
stream.write(requests.subarray(7));
} else stream.write(requests);
await vi.waitFor(() => expect(replies).toHaveLength(2));
expect(replies[0][0]).toBe(5); // Unsupported extension remains refused.
expect(replies[1][0]).toBe(12); // The following identities request succeeds.
expect(replies[1].readUInt32BE(1)).toBe(1);
} finally {
(stream as unknown as { destroy: () => void }).destroy();
}
},
);
});
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// command_history as core's SQLite bootstrap created it before 2.9.0, plus the
// index performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
command TEXT NOT NULL,
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE INDEX idx_command_history_user_host ON command_history (user_id, host_id);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexesOn = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as { name: string }[]
).map((row) => row.name);
describe("adopting command_history", () => {
it("keeps every existing row and its index when the legacy table is there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
sqlite.exec(LEGACY_DDL);
sqlite.exec(`
INSERT INTO command_history (user_id, host_id, command, executed_at)
VALUES ('user-1', 1, 'ls -la', '2026-01-01T00:00:00.000Z'),
('user-2', 2, 'uptime', '2026-01-02T00:00:00.000Z');
`);
},
});
expect(db.applied).toEqual(["0001_adopt_command_history"]);
expect(tableExists("command_history")).toBe(false);
expect(
db.sqlite
.prepare(
"SELECT user_id, host_id, command, executed_at FROM p_ssh_terminal_command_history ORDER BY id",
)
.all(),
).toEqual([
{
user_id: "user-1",
host_id: 1,
command: "ls -la",
executed_at: "2026-01-01T00:00:00.000Z",
},
{
user_id: "user-2",
host_id: 2,
command: "uptime",
executed_at: "2026-01-02T00:00:00.000Z",
},
]);
expect(indexesOn("p_ssh_terminal_command_history")).toEqual([
"idx_command_history_user_host",
]);
});
it("creates a fresh table when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("p_ssh_terminal_command_history")).toBe(true);
expect(indexesOn("p_ssh_terminal_command_history")).toEqual([
"idx_command_history_user_host",
]);
});
it("cascades when the user or the host is deleted", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
},
});
db.sqlite.exec(`
INSERT INTO p_ssh_terminal_command_history (user_id, host_id, command)
VALUES ('user-1', 1, 'a'), ('user-2', 1, 'b'), ('user-2', 2, 'c');
`);
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
db.sqlite.exec("DELETE FROM ssh_data WHERE id = 2");
expect(
db.sqlite
.prepare("SELECT command FROM p_ssh_terminal_command_history")
.all(),
).toEqual([{ command: "b" }]);
});
});
@@ -0,0 +1,203 @@
import { afterEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
const history = (s: TestServer) =>
s.db.sqlite
.prepare(
"SELECT user_id, host_id, command FROM p_ssh_terminal_command_history ORDER BY id",
)
.all();
describe("command history routes", () => {
it("saves, lists newest first without duplicates, deletes and clears", async () => {
server = await startServer();
for (const command of ["ls", "pwd", "ls"]) {
const saved = await server.request("POST", "/command-history", {
body: { hostId: 1, command },
});
expect(saved.status).toBe(201);
}
await server.request("POST", "/command-history", {
user: "user-2",
body: { hostId: 1, command: "whoami" },
});
const listed = await server.request("GET", "/command-history/1");
expect(listed.body).toEqual(["ls", "pwd"]);
await server.request("POST", "/command-history/delete", {
body: { hostId: 1, command: "pwd" },
});
expect((await server.request("GET", "/command-history/1")).body).toEqual([
"ls",
]);
await server.request("DELETE", "/command-history/1");
expect(history(server)).toEqual([
{ user_id: "user-2", host_id: 1, command: "whoami" },
]);
expect(server.db.persisted).toBeGreaterThan(0);
});
it("acknowledges but never stores a command that looks like a secret", async () => {
server = await startServer();
const saved = await server.request("POST", "/command-history", {
body: { hostId: 1, command: "export API_TOKEN=abc" },
});
expect(saved.status).toBe(201);
expect(history(server)).toEqual([]);
});
it("stores nothing while command history is off globally", async () => {
server = await startServer({ settings: { commandHistoryEnabled: false } });
await server.request("POST", "/command-history", {
body: { hostId: 1, command: "ls" },
});
expect(history(server)).toEqual([]);
});
it("stores nothing for a host that turned command history off", async () => {
server = await startServer();
await server.mock.ctx.settings.setHost(2, "enableCommandHistory", false);
await server.request("POST", "/command-history", {
body: { hostId: 2, command: "ls" },
});
await server.request("POST", "/command-history", {
body: { hostId: 1, command: "ls" },
});
expect(history(server)).toEqual([
{ user_id: "user-1", host_id: 1, command: "ls" },
]);
});
it("rejects a request missing its fields", async () => {
server = await startServer();
const saved = await server.request("POST", "/command-history", {
body: { hostId: 1 },
});
expect(saved.status).toBe(400);
});
it("needs hosts.view for the recent list", async () => {
server = await startServer({ permissions: [] });
const denied = await server.request("GET", "/command-history/1/recent");
expect(denied.status).toBe(403);
});
it("lists recent commands with duplicates for a hosts.view holder", async () => {
server = await startServer({ permissions: ["hosts.view"] });
for (const command of ["uptime", "uptime"]) {
await server.request("POST", "/command-history", {
body: { hostId: 1, command },
});
}
const allowed = await server.request("GET", "/command-history/1/recent");
expect(allowed.body).toEqual(["uptime", "uptime"]);
});
});
describe("client settings and image storage", () => {
it("serves the terminal's settings to any signed-in user", async () => {
server = await startServer({
settings: { sessionTimeoutMinutes: 45, sessionPersistence: false },
});
const { body } = await server.request("GET", "/client-settings");
expect(body.sessionTimeoutMinutes).toBe(45);
expect(body.sessionPersistence).toBe(false);
expect(body.commandHistoryEnabled).toBe(true);
expect(body.touchInput.enabled).toBe(true);
});
it("refuses the image storage test to a non-admin", async () => {
server = await startServer({ permissions: [] });
const denied = await server.request("POST", "/image-storage/test", {
body: { instanceId: "tab-1" },
});
expect(denied.status).toBe(403);
});
it("reports an unconnected session to an admin", async () => {
server = await startServer({ permissions: ["admin.plugins.manage"] });
const result = await server.request("POST", "/image-storage/test", {
body: { instanceId: "tab-1" },
});
expect(result.status).toBe(200);
expect(result.body.connected).toBe(false);
});
});
describe("terminal settings routes", () => {
const host = (id: number, userId = "user-1") => ({
id,
userId,
name: `h${id}`,
ip: "10.0.0.1",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
});
it("hands every user their own terminal settings", async () => {
server = await startServer();
await server.mock.ctx.settings.setUser(
"user-1",
"commandAutocomplete",
true,
);
const { body } = await server.request("GET", "/client-settings");
expect(body.user).toMatchObject({
commandAutocomplete: true,
localEcho: "auto",
linkClickBehavior: "confirm",
});
expect(body).not.toHaveProperty("newHostDefaults");
const other = await server.request("GET", "/client-settings", {
user: "user-2",
});
expect(other.body.user.commandAutocomplete).toBe(false);
});
it("saves the browser preferences a user moves over, for that user only", async () => {
server = await startServer();
const saved = await server.request("PUT", "/user-settings", {
body: { localEcho: "on", linkClickBehavior: "direct", theme: "x" },
});
expect(saved.status).toBe(200);
expect(await server.mock.ctx.settings.getUser("user-1", "localEcho")).toBe(
"on",
);
expect(
await server.mock.ctx.settings.getUser("user-2", "localEcho"),
).not.toBe("on");
});
it("turns auto tmux on for a host the user can edit", async () => {
server = await startServer({ hosts: [host(1)] });
const saved = await server.request("PUT", "/hosts/1/auto-tmux", {
body: { enabled: true },
});
expect(saved.status).toBe(200);
expect(await server.mock.ctx.settings.getHost(1, "autoTmux")).toBe(true);
});
it("refuses auto tmux on a host the user cannot reach, or a bad body", async () => {
server = await startServer({ hosts: [host(1)] });
const unknown = await server.request("PUT", "/hosts/2/auto-tmux", {
body: { enabled: true },
});
expect(unknown.status).toBe(403);
const bad = await server.request("PUT", "/hosts/1/auto-tmux", {
body: { enabled: "yes" },
});
expect(bad.status).toBe(400);
});
});
@@ -0,0 +1,584 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
TerminalSessionManager,
isMessageAllowedForParticipant,
} from "../../src/backend/session-manager.js";
import type { RecordingSink } from "../../src/backend/services.js";
// A recordings.writer provider that keeps what it was given.
const appends = vi.fn<(chunk: string) => Promise<void>>();
const persists = vi.fn<RecordingSink["persist"]>();
const discards = vi.fn();
const opens = vi.fn();
const log = { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() };
const sessionManager = new TerminalSessionManager({
log,
getTimeoutMinutes: () => 30,
getRecordings: () => ({
open: async (meta) => {
opens(meta);
return { append: appends, persist: persists, discard: discards };
},
}),
});
// Minimal fake WebSocket - only the surface session-manager touches.
function makeFakeWs(readyState = 1 /* OPEN */) {
return {
readyState,
send: vi.fn(),
close: vi.fn(),
terminate: vi.fn(),
} as unknown as import("ws").WebSocket;
}
const WS_OPEN = 1;
const WS_CLOSED = 3;
describe("TerminalSessionManager - session logging", () => {
beforeEach(() => {
vi.clearAllMocks();
appends.mockResolvedValue(undefined);
persists.mockResolvedValue(undefined);
});
it("createSession stores sessionLoggingEnabled=true by default", () => {
const id = sessionManager.createSession("u1", 1, "host", 80, 24);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(true);
sessionManager.destroySession(id);
});
it("createSession stores sessionLoggingEnabled=false when passed", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(false);
sessionManager.destroySession(id);
});
it("does not record when sessionLoggingEnabled=false", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
sessionManager.bufferOutput(id, "some output");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(opens).not.toHaveBeenCalled();
expect(appends).not.toHaveBeenCalled();
});
it("hands the recording to the recordings service in one batch, then persists it", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.bufferOutput(id, "terminal output data");
sessionManager.bufferOutput(id, "more output");
sessionManager.bufferInput(id, "ls\r");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
// One write for the whole burst, never one per chunk (issue #1049).
expect(appends).toHaveBeenCalledOnce();
const chunk = appends.mock.calls[0][0];
expect(chunk.split("\n")[0]).toContain('"version":2');
expect(chunk).toContain("terminal output data");
expect(chunk).toContain("more output");
expect(persists).toHaveBeenCalledOnce();
expect(opens).toHaveBeenCalledWith(
expect.objectContaining({ hostId: 1, userId: "u1", protocol: "ssh" }),
);
});
it("does not write log file when buffer is empty", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(appends).not.toHaveBeenCalled();
expect(discards).toHaveBeenCalledOnce();
});
it("records nothing when no recordings service is running", async () => {
const withoutRecordings = new TerminalSessionManager({
log,
getTimeoutMinutes: () => 30,
getRecordings: () => null,
});
const id = withoutRecordings.createSession("u1", 1, "host", 80, 24);
expect(withoutRecordings.getSession(id)?.sessionLoggingEnabled).toBe(false);
withoutRecordings.bufferOutput(id, "output");
withoutRecordings.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(appends).not.toHaveBeenCalled();
withoutRecordings.destroyAll();
});
it("bufferOutput trims old data when exceeding 512KB", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const chunk = "x".repeat(300 * 1024);
sessionManager.bufferOutput(id, chunk);
sessionManager.bufferOutput(id, chunk);
const session = sessionManager.getSession(id);
expect(session!.outputBufferBytes).toBeLessThanOrEqual(512 * 1024);
sessionManager.destroySession(id);
});
});
describe("TerminalSessionManager - multiplayer participants", () => {
beforeEach(() => {
vi.clearAllMocks();
appends.mockResolvedValue(undefined);
persists.mockResolvedValue(undefined);
});
function createConnectedSession(): string {
const id = sessionManager.createSession(
"owner-1",
1,
"host",
80,
24,
undefined,
false,
);
// Mark connected without a real ssh2 stream - only isConnected is read
// by attachWs/joinAsParticipant.
const session = sessionManager.getSession(id)!;
session.isConnected = true;
return id;
}
it("sends the presenter dimensions before a participant replays output", () => {
const id = createConnectedSession();
const session = sessionManager.getSession(id)!;
session.cols = 132;
session.rows = 40;
const ws = makeFakeWs();
sessionManager.joinAsParticipant(id, ws, {
userId: "viewer",
permissionLevel: "read-only",
});
expect(JSON.parse(vi.mocked(ws.send).mock.calls[0][0] as string)).toEqual({
type: "resized",
cols: 132,
rows: 40,
});
sessionManager.destroySession(id);
});
it("broadcasts later size changes and retains them for new participants", () => {
const id = createConnectedSession();
const owner = makeFakeWs();
const viewer = makeFakeWs();
sessionManager.attachWs(id, "owner-1", owner);
sessionManager.joinAsParticipant(id, viewer, {
userId: "viewer",
permissionLevel: "read-only",
});
sessionManager.resizeSession(id, 100, 35);
const message = JSON.stringify({ type: "resized", cols: 100, rows: 35 });
expect(owner.send).toHaveBeenCalledWith(message);
expect(viewer.send).toHaveBeenCalledWith(message);
const newcomer = makeFakeWs();
sessionManager.joinAsParticipant(id, newcomer, {
userId: null,
permissionLevel: "read-only",
});
expect(vi.mocked(newcomer.send).mock.calls[0][0]).toBe(message);
sessionManager.destroySession(id);
});
it("joinAsParticipant adds a participant without evicting the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
const session = sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
guestLabel: "Guest",
});
expect(session).not.toBeNull();
expect(session!.participants.size).toBe(2);
const ownerParticipant = sessionManager.getParticipantForWs(
session!,
ownerWs,
);
expect(ownerParticipant?.isOwner).toBe(true);
// The join is announced to everyone already in the session - and that is
// the only unsolicited message the owner receives.
expect(ownerWs.send).toHaveBeenCalledTimes(1);
const announced = JSON.parse(
(ownerWs.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as string,
);
expect(announced.type).toBe("participants");
expect(announced.participants).toHaveLength(2);
sessionManager.destroySession(id);
});
it("keeps the roster from link guests and lists them per share", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
guestLabel: "Guest 1",
shareId: "share-a",
});
const memberWs = makeFakeWs();
sessionManager.joinAsParticipant(id, memberWs, {
userId: "alice",
permissionLevel: "read-only",
shareId: "share-a",
});
const sentTypes = (ws: ReturnType<typeof makeFakeWs>) =>
(ws.send as ReturnType<typeof vi.fn>).mock.calls.map(
(call) => JSON.parse(call[0] as string).type,
);
expect(sentTypes(guestWs)).not.toContain("participants");
expect(sentTypes(memberWs)).toContain("participants");
expect(sessionManager.listShareGuests(id, "share-a")).toEqual([
{ label: "Guest 1" },
]);
expect(sessionManager.listShareGuests(id, "share-b")).toEqual([]);
sessionManager.destroySession(id);
});
it("setRoomShareControl makes only the controller read-write and never touches the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const aliceWs = makeFakeWs();
const bobWs = makeFakeWs();
const session = sessionManager.joinAsParticipant(id, aliceWs, {
userId: "alice",
permissionLevel: "read-only",
shareId: "stage-share",
})!;
sessionManager.joinAsParticipant(id, bobWs, {
userId: "bob",
permissionLevel: "read-only",
shareId: "stage-share",
});
sessionManager.setRoomShareControl(id, "stage-share", "alice");
expect(
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
).toBe("read-write");
expect(
sessionManager.getParticipantForWs(session, bobWs)?.permissionLevel,
).toBe("read-only");
expect(
sessionManager.getParticipantForWs(session, ownerWs)?.permissionLevel,
).toBe("read-write");
sessionManager.setRoomShareControl(id, "stage-share", null);
expect(
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
).toBe("read-only");
sessionManager.destroySession(id);
});
it("disconnectShareParticipants revokes only the selected share participants", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
const aliceWs = makeFakeWs();
const guestWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const session = sessionManager.joinAsParticipant(id, aliceWs, {
userId: "alice",
permissionLevel: "read-only",
shareId: "stage-share",
})!;
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
shareId: "stage-share",
});
expect(
sessionManager.disconnectShareParticipants(id, "stage-share", {
userId: "alice",
reason: "Removed",
}),
).toBe(1);
expect(sessionManager.getParticipantForWs(session, aliceWs)).toBeNull();
expect(sessionManager.getParticipantForWs(session, guestWs)).not.toBeNull();
expect(sessionManager.getParticipantForWs(session, ownerWs)?.isOwner).toBe(
true,
);
expect(aliceWs.close).toHaveBeenCalledWith(1008, "Removed");
sessionManager.destroySession(id);
});
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
expect(
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
userId: null,
permissionLevel: "read-only",
}),
).toBeNull();
});
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs(WS_OPEN);
sessionManager.attachWs(id, "owner-1", ownerWs);
const openGuestWs = makeFakeWs(WS_OPEN);
const closedGuestWs = makeFakeWs(WS_CLOSED);
sessionManager.joinAsParticipant(id, openGuestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.joinAsParticipant(id, closedGuestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.broadcast(id, { type: "data", data: "hello" });
expect(ownerWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: "data", data: "hello" }),
);
expect(openGuestWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: "data", data: "hello" }),
);
expect(closedGuestWs.send).not.toHaveBeenCalled();
sessionManager.destroySession(id);
});
it("broadcast does not throw if a socket's send throws", () => {
const id = createConnectedSession();
const throwingWs = makeFakeWs(WS_OPEN);
(throwingWs.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("send failed");
});
sessionManager.attachWs(id, "owner-1", throwingWs);
expect(() =>
sessionManager.broadcast(id, { type: "data", data: "x" }),
).not.toThrow();
sessionManager.destroySession(id);
});
it("broadcast is a no-op for a nonexistent session", () => {
expect(() =>
sessionManager.broadcast("does-not-exist", { type: "data" }),
).not.toThrow();
});
it("owner detach arms the idle timeout (existing behavior)", () => {
vi.useFakeTimers();
try {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
sessionManager.detachWs(id);
const session = sessionManager.getSession(id);
expect(session?.detachTimeout).not.toBeNull();
expect(session?.lastDetachedAt).not.toBeNull();
sessionManager.destroySession(id);
} finally {
vi.useRealTimers();
}
});
it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-write",
});
sessionManager.removeParticipant(id, guestWs);
const session = sessionManager.getSession(id);
expect(session).not.toBeNull();
expect(session?.detachTimeout).toBeNull();
expect(session?.participants.size).toBe(1);
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
expect(
JSON.parse(
(ownerWs.send as ReturnType<typeof vi.fn>).mock.lastCall![0] as string,
),
).toMatchObject({
type: "participants",
participants: [{ isOwner: true }],
});
sessionManager.destroySession(id);
});
it("removeParticipant is a no-op when the ws belongs to the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
sessionManager.removeParticipant(id, ownerWs);
const session = sessionManager.getSession(id);
expect(session?.participants.size).toBe(1);
expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
true,
);
sessionManager.destroySession(id);
});
it("destroySession cleans up all participants, not just the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.destroySession(id);
expect(guestWs.send).toHaveBeenCalled();
expect(sessionManager.getSession(id)).toBeNull();
});
it("ownerEndSession notifies non-owner participants and destroys the session", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-write",
});
sessionManager.ownerEndSession(id, "owner ended the session");
expect(guestWs.send).toHaveBeenCalledWith(
JSON.stringify({
type: "sessionTerminatedByOwner",
reason: "owner ended the session",
}),
);
expect(sessionManager.getSession(id)).toBeNull();
});
});
describe("isMessageAllowedForParticipant", () => {
it("allows any message type for the owner or when there is no participant", () => {
expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
expect(
isMessageAllowedForParticipant(
{ isOwner: true, permissionLevel: "read-write" },
"resize",
),
).toBe(true);
});
it("drops input from a read-only participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"input",
),
).toBe(false);
});
it("allows input from a read-write non-owner participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-write" },
"input",
),
).toBe(true);
});
it("allows ping and disconnect for any non-owner participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"ping",
),
).toBe(true);
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"disconnect",
),
).toBe(true);
});
it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
for (const type of [
"resize",
"totp_response",
"password_response",
"tmux_attach",
"tmux_detach",
"get_cwd",
"vault_start_auth",
"opkssh_start_auth",
]) {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-write" },
type,
),
).toBe(false);
}
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
validateAdminSettings,
validateUserSettings,
} from "../../src/backend/settings-validation.js";
describe("validateAdminSettings", () => {
it("rejects relative image paths", () => {
expect(
Object.keys(
validateAdminSettings({
imageLocalDir: "images",
imageHostPath: "tmp/images",
}),
).sort(),
).toEqual(["imageHostPath", "imageLocalDir"]);
});
it("accepts absolute paths and empty values", () => {
expect(
validateAdminSettings({ imageLocalDir: "", imageHostPath: "/tmp/img" }),
).toEqual({});
});
});
describe("validateUserSettings", () => {
const macro = { id: "m", name: "M", steps: [] };
it("accepts bounded macros and a save without them", () => {
expect(validateUserSettings({ macros: [macro] })).toEqual({});
expect(validateUserSettings({ macros: JSON.stringify([macro]) })).toEqual(
{},
);
expect(validateUserSettings({ localEcho: "on" })).toEqual({});
});
it("refuses malformed or too many macros", () => {
expect(validateUserSettings({ macros: [{ id: 1 }] }).macros).toBeTruthy();
expect(validateUserSettings({ macros: "{" }).macros).toBeTruthy();
expect(
validateUserSettings({
macros: Array.from({ length: 101 }, (_, i) => ({
...macro,
id: `${i}`,
})),
}).macros,
).toBeTruthy();
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import {
pickSshAddress,
resolveHostForSshConnect,
} from "../../src/backend/helpers.js";
describe("resolveHostForSshConnect", () => {
it("prefers IPv4 over IPv6", async () => {
const lookup = vi.fn().mockResolvedValue([
{ address: "fe80::1", family: 6 },
{ address: "192.168.1.20", family: 4 },
]);
const result = await resolveHostForSshConnect("nas", lookup);
expect(result.host).toBe("192.168.1.20");
});
it("keeps an IPv6-only answer", () => {
expect(pickSshAddress({ address: "2001:db8::5", family: 6 })).toBe(
"2001:db8::5",
);
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_HOST_TERMINAL_SETTINGS,
hostSettingsFromTerminalConfig,
readHostTerminalSettings,
readUserSettings,
resolveTerminalSettings,
} from "../../src/shared/terminal-settings";
describe("hostSettingsFromTerminalConfig", () => {
it("keeps behavior and leaves a look-less host following the user", () => {
expect(
hostSettingsFromTerminalConfig({
autoMosh: true,
fastScrollModifier: "ctrl",
keepaliveInterval: 5,
}),
).toEqual({ autoMosh: true, fastScrollModifier: "ctrl" });
});
it("drops values a select field would refuse", () => {
expect(
hostSettingsFromTerminalConfig({ bellStyle: "loud", cursorStyle: "bar" }),
).toEqual({ inheritAppearance: false, cursorStyle: "bar" });
});
it("reads nothing from garbage", () => {
expect(hostSettingsFromTerminalConfig("{")).toEqual({});
});
});
describe("resolveTerminalSettings", () => {
it("runs with the host's values, which already follow its defaults", () => {
const host = readHostTerminalSettings({ fontSize: 30, autoTmux: true });
const resolved = resolveTerminalSettings(host);
expect(resolved.fontSize).toBe(30);
expect(resolved.autoTmux).toBe(true);
});
it("ignores the old follow-the-user switch", () => {
const host = readHostTerminalSettings({
inheritAppearance: true,
fontSize: 22,
});
expect(resolveTerminalSettings(host).fontSize).toBe(22);
});
it("defaults echo and link clicks to concrete modes", () => {
const resolved = resolveTerminalSettings(readHostTerminalSettings({}));
expect(resolved.localEcho).toBe("auto");
expect(resolved.linkClickBehavior).toBe("confirm");
});
it("falls back to the built-in defaults", () => {
expect(resolveTerminalSettings(null).theme).toBe(
DEFAULT_HOST_TERMINAL_SETTINGS.theme,
);
});
});
describe("readUserSettings", () => {
it("types what it reads and drops broken saved themes", () => {
expect(
readUserSettings({
terminalDefaults: { fontSize: "16", junk: 1 },
customThemes: [{ id: "a", name: "A", colors: {} }, { id: 1 }],
localEcho: "sideways",
linkClickBehavior: "direct",
}),
).toEqual({
customThemes: [{ id: "a", name: "A", colors: {} }],
commandAutocomplete: false,
localEcho: "auto",
linkClickBehavior: "direct",
});
});
});
@@ -0,0 +1,197 @@
import { afterEach, describe, expect, it } from "vitest";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const declared = (views?: Array<{ id: string }>) =>
(views ?? []).map((view) => view.id).sort();
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("activates and registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const contributes = manifest.contributes as unknown as Record<
string,
Array<{ id: string }> | undefined
>;
const tabs = declared(contributes.tabs);
// A rail panel may reuse one of the plugin's tab ids.
const panels = [...declared(contributes.panels), ...tabs];
const cards = declared(contributes.dashboardCards);
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
for (const id of rendered.registered.dashboardCards()) {
expect(cards).toContain(id);
}
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.hostActions()).toEqual([]);
expect(app.registered.hostEditorSections()).toEqual([]);
expect(app.registered.dashboardCards()).toEqual([]);
expect(app.registered.settingsComponents()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
});
it("registers the terminal session actions snippets and other plugins call into", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.actions()).toEqual(
expect.arrayContaining([
"terminal.listSessions",
"terminal.sendToActive",
"terminal.sendToSession",
"terminal.open",
]),
);
});
it("duplicates only the SSH connection configuration into a fresh tab", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const host = { id: 7, name: "server", ip: "192.0.2.1", port: 22 };
expect(rendered.registered.slot("tab.menu")).toContain(
"terminal.duplicateTab",
);
await rendered.app.invokeAction("terminal.duplicateTab", undefined, {
id: "old-tab",
type: "terminal",
host,
instanceId: "old-instance",
data: { initialPath: "/tmp", joinSharedSessionId: "shared-session" },
});
expect(rendered.shellCalls).toEqual([
{
method: "openTab",
args: [
{ ...host, pluginSettings: {} },
"terminal",
{ forceNewTab: true },
],
},
]);
await rendered.app.invokeAction("terminal.duplicateTab", undefined, {
id: "local",
type: "local-terminal",
});
expect(rendered.shellCalls).toHaveLength(1);
});
it("owns the host editor's Terminal tab in the SSH group", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.hostEditorSections()).toEqual(["terminal"]);
});
it("registers the command history panel and its rail item", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.panels()).toContain("history");
expect(rendered.registered.railItems().map((item) => item.id)).toContain(
"history",
);
});
it("offers the local terminal only in the desktop app", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.tabs()).not.toContain("local-terminal");
await rendered.deactivate();
(window as { IS_ELECTRON?: boolean }).IS_ELECTRON = true;
try {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.tabs()).toContain("local-terminal");
expect(rendered.registered.railItems().map((item) => item.id)).toContain(
"local-terminal",
);
} finally {
delete (window as { IS_ELECTRON?: boolean }).IS_ELECTRON;
}
});
it("terminal.sendToActive and terminal.listSessions report no session when none is registered", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
await expect(
rendered.app.invokeAction("terminal.listSessions"),
).resolves.toEqual([]);
await expect(
rendered.app.invokeAction("terminal.sendToActive", "echo hi", {
run: true,
}),
).resolves.toBe(false);
});
it("owns the SSH Tools panel and its rail item", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.panels()).toContain("ssh-tools");
expect(rendered.registered.railItems().map((item) => item.id)).toContain(
"ssh-tools",
);
});
it("registers its settings components", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.settingsComponents()).toEqual(
expect.arrayContaining(["touchInput", "imageStorageTest"]),
);
// Terminal defaults are host defaults now, set in the host editor.
expect(rendered.registered.settingsComponents()).not.toContain(
"terminalDefaults",
);
});
it("offers the terminal look to other plugins through actions", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: {
get: async () => ({ data: { user: { terminalDefaults: {} } } }),
} as never,
});
const look = (await rendered.app.invokeAction("terminal.resolveTheme", {
host: {
id: "1",
name: "h",
ip: "10.0.0.1",
port: 22,
pluginSettings: {
"ssh-terminal": { inheritAppearance: false, theme: "dracula" },
},
},
appTheme: "dark",
})) as { themeId: string; colors: { background: string } };
expect(look.themeId).toBe("dracula");
expect(look.colors.background).toBe("#282a36");
const themes = (await rendered.app.invokeAction("terminal.themes")) as {
id: string;
}[];
expect(themes.map((theme) => theme.id)).toContain("dracula");
expect(themes.map((theme) => theme.id)).not.toContain("termixDark");
});
it("adds the terminal font faces while active and removes them on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
expect(
document.head.querySelector("style[data-termix-terminal-styles]"),
).not.toBeNull();
await app.deactivate();
expect(
document.head.querySelector("style[data-termix-terminal-styles]"),
).toBeNull();
});
});
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
dispatchKeybindingAction,
isTerminalKeybindingAction,
validateSendControlCode,
validateSendText,
} from "../../../src/frontend/lib/keybinding-dispatch";
import type { Terminal } from "@xterm/xterm";
import type { KeybindingDispatchContext } from "../../../src/frontend/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(""),
sentData,
...overrides,
};
return ctx;
}
describe("dispatchKeybindingAction", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
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("leaves any other action to whoever registered it", () => {
const ctx = makeContext();
expect(
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx),
).toBe(false);
expect(dispatchKeybindingAction({ type: "nextTab" }, ctx)).toBe(false);
expect(dispatchKeybindingAction({ type: "copy" }, ctx)).toBe(true);
expect(ctx.sentData).toEqual([]);
});
it("knows which actions are its own", () => {
expect(isTerminalKeybindingAction("sendText")).toBe(true);
expect(isTerminalKeybindingAction("runSnippet")).toBe(false);
});
});
describe("keybinding validators", () => {
it("needs text to send", () => {
expect(validateSendText({ type: "sendText", text: " " })).toBe(
"keybindings.textRequired",
);
expect(validateSendText({ type: "sendText", text: "ls" })).toBeNull();
});
it("needs a single letter for a control code", () => {
expect(
validateSendControlCode({ type: "sendControlCode", controlCode: "ab" }),
).toBe("keybindings.controlCodeRequired");
expect(
validateSendControlCode({ type: "sendControlCode", controlCode: "w" }),
).toBeNull();
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import { getMacLineNavigationSequence } from "../../../src/frontend/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,101 @@
import { describe, expect, it } from "vitest";
import {
TerminalLocalEcho,
resolveLocalEchoMode,
} from "../../../src/frontend/lib/terminal-local-echo";
describe("TerminalLocalEcho", () => {
it("renders immediately and suppresses the matching remote echo", () => {
const echo = new TerminalLocalEcho("on");
expect(echo.handleInput("a")).toBe("a");
expect(echo.handleOutput("a")).toBe("");
});
it("rolls back a prediction when remote output differs", () => {
const echo = new TerminalLocalEcho("on");
echo.handleInput("a");
expect(echo.handleOutput("z")).toBe("\x1b[1D\x1b[1Xz");
});
it("only erases the predicted cells during rollback", () => {
const echo = new TerminalLocalEcho("on");
echo.handleInput("a");
echo.handleInput("b");
expect(echo.handleOutput("\x1b[C")).toBe("\x1b[2D\x1b[2X\x1b[C");
});
it.each([
["split prompt", ["Pass", "word: "]],
["sudo prompt", ["[sudo] password for alice: "]],
["ssh-keygen prompt", ["Enter passphrase (empty for no passphrase): "]],
["passwd prompt", ["New password: "]],
])("does not expose input after a %s", (_name, chunks) => {
const echo = new TerminalLocalEcho("on");
for (const chunk of chunks) expect(echo.handleOutput(chunk)).toBe(chunk);
expect(echo.handleInput("s")).toBe("");
});
it("does not treat ordinary password text as a hidden-input prompt", () => {
const echo = new TerminalLocalEcho("on");
expect(echo.handleOutput("Your password has expired\r\n$ ")).toBe(
"Your password has expired\r\n$ ",
);
expect(echo.handleInput("s")).toBe("s");
});
it("does not predict control input, paste, or wide characters", () => {
const echo = new TerminalLocalEcho("on");
expect(echo.handleInput("\t")).toBe("");
expect(echo.handleInput("paste")).toBe("");
expect(echo.handleInput("界")).toBe("");
});
it("enables automatic prediction after repeated slow echoes", () => {
let now = 0;
const echo = new TerminalLocalEcho("auto", () => now, 100);
expect(echo.handleInput("a")).toBe("");
now = 150;
expect(echo.handleOutput("a")).toBe("a");
expect(echo.handleInput("b")).toBe("");
now = 300;
expect(echo.handleOutput("b")).toBe("b");
expect(echo.handleInput("c")).toBe("c");
});
it("waits for outstanding unpredicted input before enabling auto prediction", () => {
let now = 0;
const echo = new TerminalLocalEcho("auto", () => now, 100);
expect(echo.handleInput("d")).toBe("");
now = 150;
expect(echo.handleOutput("d")).toBe("d");
expect(echo.handleInput("o")).toBe("");
expect(echo.handleInput("c")).toBe("");
now = 300;
expect(echo.handleOutput("o")).toBe("o");
expect(echo.handleInput("k")).toBe("");
expect(echo.handleOutput("ck")).toBe("ck");
expect(echo.handleInput("e")).toBe("e");
expect(echo.handleInput("r")).toBe("r");
expect(echo.handleOutput("er")).toBe("");
});
it("rolls back the unmatched suffix after a partially matching output chunk", () => {
const echo = new TerminalLocalEcho("on");
for (const character of "abc") echo.handleInput(character);
expect(echo.handleOutput("abZ")).toBe("\x1b[1D\x1b[1XZ");
expect(echo.handleOutput("c")).toBe("c");
});
it("keeps pending predictions across an empty output chunk", () => {
const echo = new TerminalLocalEcho("on");
expect(echo.handleInput("a")).toBe("a");
expect(echo.handleOutput("")).toBe("");
expect(echo.handleOutput("a")).toBe("");
});
it("uses an explicit host mode before the global mode", () => {
expect(resolveLocalEchoMode("off", "on")).toBe("off");
expect(resolveLocalEchoMode("default", "on")).toBe("on");
expect(resolveLocalEchoMode(undefined, null)).toBe("auto");
});
});
@@ -0,0 +1,53 @@
import { Terminal } from "@xterm/xterm";
import { afterEach, describe, expect, it } from "vitest";
import { TerminalLocalEcho } from "../../../src/frontend/lib/terminal-local-echo";
let terminal: Terminal | undefined;
afterEach(() => terminal?.dispose());
const write = (data: string) =>
new Promise<void>((resolve) => terminal!.write(data, resolve));
function line() {
return terminal!.buffer.active.getLine(0)?.translateToString(true);
}
describe("local echo with xterm", () => {
it("keeps fast typing in order when auto prediction activates with input in flight", async () => {
terminal = new Terminal({ cols: 80, rows: 5 });
let now = 0;
const echo = new TerminalLocalEcho("auto", () => now, 100);
const input = async (data: string) => {
for (const character of data) await write(echo.handleInput(character));
};
await input("d");
now = 150;
await write(echo.handleOutput("d"));
await input("oc");
now = 300;
await write(echo.handleOutput("o"));
await input("k");
await write(echo.handleOutput("ck"));
await input("er ps");
await write(echo.handleOutput("er ps"));
expect(line()).toBe("docker ps");
expect(terminal.buffer.active.cursorX).toBe(9);
});
it.each(["abZ", "ab\r\n$ "])(
"reconciles a matching prefix followed by remote output %j",
async (output) => {
terminal = new Terminal({ cols: 80, rows: 5 });
const echo = new TerminalLocalEcho("on");
for (const character of "abc") await write(echo.handleInput(character));
await write(echo.handleOutput(output));
expect(line()).toBe(output.startsWith("abZ") ? "abZ" : "ab");
if (output.includes("\n")) {
expect(terminal.buffer.active.getLine(1)?.translateToString(true)).toBe(
"$ ",
);
expect(terminal.buffer.active.cursorX).toBe(2);
}
},
);
});
@@ -0,0 +1,441 @@
import { describe, it, expect } from "vitest";
import {
highlightTerminalOutput,
updateControlStringMode,
} from "../../../src/frontend/lib/terminal-syntax-highlighter";
const ESC = "\x1b";
describe("highlightTerminalOutput", () => {
it("returns empty/whitespace text unchanged", () => {
expect(highlightTerminalOutput("")).toBe("");
expect(highlightTerminalOutput(" ")).toBe(" ");
});
it("wraps ERROR in bright red", () => {
const out = highlightTerminalOutput("Something ERROR happened");
expect(out).toContain(ESC + "[91m");
expect(out).toContain("ERROR");
});
it("wraps lowercase error in bright red (case-insensitive)", () => {
const out = highlightTerminalOutput("something error happened");
expect(out).toContain(ESC + "[91m");
});
it("wraps Error (mixed case) in bright red", () => {
const out = highlightTerminalOutput("Error: connection refused");
expect(out).toContain(ESC + "[91m");
});
it("wraps warn (lowercase) in bright yellow", () => {
const out = highlightTerminalOutput("warn: disk almost full");
expect(out).toContain(ESC + "[93m");
});
it("wraps warning (lowercase) in bright yellow", () => {
const out = highlightTerminalOutput("warning: something is off");
expect(out).toContain(ESC + "[93m");
});
it("highlights IPv4 addresses in magenta", () => {
const out = highlightTerminalOutput("connect to 192.168.1.10 now");
expect(out).toContain("192.168.1.10");
expect(out).toContain(ESC + "[35m");
});
it("leaves text with no matchable tokens unchanged", () => {
const plain = "just a normal sentence here";
expect(highlightTerminalOutput(plain)).toBe(plain);
});
it("leaves text ending in an incomplete ANSI escape untouched", () => {
const partial = `loading${ESC}[`;
expect(highlightTerminalOutput(partial)).toBe(partial);
});
it("skips TUI cursor-positioning frames (nano/vim protection)", () => {
const tuiFrame = `${ESC}[H${ESC}[2J some nano content`;
expect(highlightTerminalOutput(tuiFrame)).toBe(tuiFrame);
});
it("skips private TUI mode sequences", () => {
const tuiFrame = `${ESC}[?1049h${ESC}[?25lERROR at /var/log/app.log`;
expect(highlightTerminalOutput(tuiFrame)).toBe(tuiFrame);
});
it("skips bracketed paste mode sequences", () => {
const chunk = `${ESC}[?2004hERROR: enabled paste mode`;
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("skips TUI frames with box-drawing characters", () => {
const chunk = `┌─ /var/log ─┐\n│ ERROR file │`;
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("skips DEC line-drawing charset frames", () => {
const chunk = `${ESC}(0lqq /var/log qk${ESC}(B`;
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("skips OSC current-directory sequences used by fish prompts", () => {
const chunk = `${ESC}]7;file://server/home/user/docker/sonarr\x07user@server ~/docker/sonarr> `;
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("skips OSC title sequences", () => {
const chunk = `${ESC}]0;user@server:/var/log\x07ERROR after title`;
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("highlights text that already has ANSI codes", () => {
let heavy = "";
for (let i = 0; i < 12; i++) heavy += `${ESC}[32mgreen${ESC}[0m `;
heavy += "ERROR at line 5";
const out = highlightTerminalOutput(heavy);
expect(out).toContain(ESC + "[91m");
});
it("does not process lines exceeding MAX_LINE_LENGTH", () => {
const huge = "ERROR " + "x".repeat(3000);
expect(highlightTerminalOutput(huge)).toBe(huge);
});
it("highlights simple absolute paths in cyan", () => {
const out = highlightTerminalOutput("log at /var/log/nginx/access.log");
expect(out).toContain(ESC + "[36m");
expect(out).toContain("/var/log/nginx/access.log");
});
it("highlights absolute paths with glob wildcards in cyan", () => {
const out = highlightTerminalOutput("files: /usr/share/doc/*/copyright");
expect(out).toContain(ESC + "[36m");
expect(out).toContain("/usr/share/doc/*/copyright");
});
it("highlights home paths in cyan", () => {
const out = highlightTerminalOutput("file: ~/documents/notes.txt");
expect(out).toContain(ESC + "[36m");
expect(out).toContain("~/documents/notes.txt");
});
it("highlights bracket timestamps in bright black", () => {
const out = highlightTerminalOutput("[12:34:56] server started");
expect(out).toContain(ESC + "[90m");
expect(out).toContain("[12:34:56]");
});
it("does not highlight out-of-range bracket timestamps", () => {
// [99:99] is not a valid time
const out = highlightTerminalOutput("[99:99] something");
expect(out).not.toContain(ESC + "[90m");
});
it("highlights ISO date timestamps in bright black", () => {
const out = highlightTerminalOutput("2024-01-15 event occurred");
expect(out).toContain(ESC + "[90m");
expect(out).toContain("2024-01-15");
});
it("does not highlight version strings as ISO dates", () => {
// 2.4.0 or 1.2024.3 should not trigger ISO date
const out = highlightTerminalOutput("version 2.4.0 released");
expect(out).toBe("version 2.4.0 released");
});
it("highlights labeled numbers (port)", () => {
const out = highlightTerminalOutput("listening on port 8080");
expect(out).toContain(ESC + "[96m");
expect(out).toContain("8080");
});
it("highlights labeled numbers (exit)", () => {
const out = highlightTerminalOutput("process exited with exit 1");
expect(out).toContain(ESC + "[96m");
});
it("highlights labeled numbers (status)", () => {
const out = highlightTerminalOutput("returned status 404");
expect(out).toContain(ESC + "[96m");
});
it("does not highlight standalone numbers outside labeled context", () => {
const out = highlightTerminalOutput("there are 42 files here");
expect(out).toBe("there are 42 files here");
});
it("does not highlight 'up' or 'active' as success", () => {
const out = highlightTerminalOutput("service is up and running");
expect(out).not.toContain(ESC + "[92m");
});
it("highlights unambiguous success keywords (case-insensitive)", () => {
const out = highlightTerminalOutput("Test passed successfully");
expect(out).toContain(ESC + "[92m");
});
it("highlights URLs with blue+underline", () => {
const out = highlightTerminalOutput("visit https://example.com now");
expect(out).toContain(ESC + "[34m");
expect(out).toContain(ESC + "[4m");
});
it("preserves \\r in CRLF terminal output", () => {
const out = highlightTerminalOutput("ERROR occurred\r\n");
expect(out).toContain("\r\n");
});
it("processes multi-line text line by line", () => {
const text = "some output\nERROR: failed\n";
const out = highlightTerminalOutput(text);
expect(out).toContain(ESC + "[91m");
});
it("respects category options - disabling logLevels skips error highlight", () => {
const out = highlightTerminalOutput("ERROR occurred", { logLevels: false });
expect(out).not.toContain(ESC + "[91m");
});
it("respects category options - disabling paths skips path highlight", () => {
const out = highlightTerminalOutput("at /usr/share/doc/something/file", {
paths: false,
});
expect(out).not.toContain(ESC + "[36m");
});
it("respects category options - disabling urls skips url highlight", () => {
const out = highlightTerminalOutput("see https://example.com", {
urls: false,
});
expect(out).not.toContain(ESC + "[34m");
});
it("respects category options - disabling ipAddresses skips IP highlight", () => {
const out = highlightTerminalOutput("connect to 192.168.1.1", {
ipAddresses: false,
});
expect(out).not.toContain(ESC + "[35m");
});
it("respects category options - disabling timestamps skips ISO date highlight", () => {
const out = highlightTerminalOutput("2024-01-15 something", {
timestamps: false,
});
expect(out).not.toContain(ESC + "[90m");
});
it("respects category options - disabling numbers skips port highlight", () => {
const out = highlightTerminalOutput("listening on port 8080", {
numbers: false,
});
expect(out).not.toContain(ESC + "[96m");
});
it("skips highlighting when chunk contains a mid-line carriage return", () => {
// Progress bars and shell prompt redraws use \r to overwrite the current line
const chunk = "downloading...\rdownloading [====] 100%";
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("still processes CRLF line endings (\\r\\n is fine)", () => {
const out = highlightTerminalOutput("ERROR occurred\r\n");
expect(out).toContain(ESC + "[91m");
});
it("does not highlight shell prompt lines (user@host:path$)", () => {
const prompt = `${ESC}[01;32mpi@raspberrypi${ESC}[00m:${ESC}[01;34m/home/pi${ESC}[00m$ `;
const out = highlightTerminalOutput(prompt);
expect(out).toBe(prompt);
});
it("does not highlight plain-text shell prompt line", () => {
const prompt = "pi@raspberrypi:/home/pi$ ";
const out = highlightTerminalOutput(prompt);
expect(out).toBe(prompt);
});
it("does not highlight log keywords inside bracketed SSH headings", () => {
const out = highlightTerminalOutput("[info@archlinux] command output");
expect(out).toBe("[info@archlinux] command output");
});
it("still highlights log keywords after a bracketed SSH heading", () => {
const out = highlightTerminalOutput("[warning@host] command ERROR");
expect(out).not.toContain(`${ESC}[93mwarning`);
expect(out).toContain(`${ESC}[91mERROR`);
});
it("does not highlight a log-level-like username split across ANSI segments in a colored SSH heading", () => {
// Prompt themes often color the user and host portions of "[user@host]"
// separately, so the heading is not one contiguous plain-text segment.
const chunk = `[${ESC}[1;33mwarning${ESC}[0m@host] some command output`;
const out = highlightTerminalOutput(chunk);
expect(out).toBe(chunk);
});
it("does not highlight 'success' when immediately followed by a path (cd output)", () => {
// Some shells print "success~/new/dir" or "success/path" after a cd command
const out = highlightTerminalOutput("success~/home/user/projects");
expect(out).not.toContain(ESC + "[92m");
});
it("still highlights 'success' when not followed by a path separator", () => {
const out = highlightTerminalOutput("Build success: all tests passed");
expect(out).toContain(ESC + "[92m");
});
it("highlights output lines but not the prompt in a mixed chunk", () => {
const chunk = `ERROR: disk full\npi@raspberrypi:~$ `;
const out = highlightTerminalOutput(chunk);
// The error line should be highlighted
expect(out).toContain(ESC + "[91m");
// The prompt line should be unchanged
expect(out).toContain("pi@raspberrypi:~$ ");
const promptLine = out.split("\n")[1];
expect(promptLine).toBe("pi@raspberrypi:~$ ");
});
it("does not highlight paths inside a command-echo line (prompt + command)", () => {
// When the shell echoes the user's command, it prefixes the prompt.
// The path in the prompt portion (/home/user) must not be highlighted —
// the shell already colored it, and re-coloring it causes the doubled-path bug.
const echo = "user@host:/home/user$ cd /opt/app/bin/files";
const out = highlightTerminalOutput(echo);
expect(out).toBe(echo);
});
it("does not highlight paths in colored command-echo lines (root prompt)", () => {
const echo = `${ESC}[01;32mroot@host${ESC}[00m:${ESC}[01;34m/home/user${ESC}[00m# cd /opt/app/bin/files`;
const out = highlightTerminalOutput(echo);
expect(out).toBe(echo);
});
it("does not highlight the last line of a multi-line chunk with no trailing newline", () => {
// In a multi-line chunk, the trailing fragment without \n could be a live
// readline input line. Injecting ANSI bytes there breaks bash's cursor
// arithmetic (causes cursor jump / text shift when using arrow keys).
const chunk = "ERROR: disk full\nuser@host:/path$ cd /var/log";
const out = highlightTerminalOutput(chunk);
// First line (terminated by \n) gets highlighted
expect(out.split("\n")[0]).toContain(ESC + "[91m");
// Last unterminated fragment is left verbatim
expect(out.split("\n")[1]).toBe("user@host:/path$ cd /var/log");
});
it("still highlights a single-line chunk with no trailing newline", () => {
// A single-line chunk with no \n is plain command output, not a readline
// input fragment — highlight it normally.
const out = highlightTerminalOutput("ERROR: something failed");
expect(out).toContain(ESC + "[91m");
});
it("highlights all lines when the chunk ends with a newline", () => {
// When a chunk ends with \n every line is complete output — highlight them all.
const chunk = "ERROR: disk full\nconnect to /var/run/app.sock\n";
const out = highlightTerminalOutput(chunk);
expect(out.split("\n")[0]).toContain(ESC + "[91m");
expect(out.split("\n")[1]).toContain(ESC + "[36m");
});
});
describe("updateControlStringMode", () => {
const BEL = "\x07";
it("stays inactive for output with no control string", () => {
expect(updateControlStringMode("total 4\nfile.txt\n", false)).toEqual({
isActive: false,
wasActive: false,
});
});
it("goes active when a control string is left open at the chunk end", () => {
// What PROMPT_COMMAND emits: ESC ] 0 ; <title>, terminator not yet sent.
const chunk = `${ESC}]0;user@host:~/path/current-dir`;
expect(updateControlStringMode(chunk, false)).toEqual({
isActive: true,
wasActive: false,
});
});
it("clears on the continuation chunk that carries the terminator", () => {
// The continuation has no escape byte at all, which is why every
// single-chunk guard misses it.
const continuation = `${BEL}[user@host current-dir]$ `;
expect(updateControlStringMode(continuation, true)).toEqual({
isActive: false,
wasActive: true,
});
});
it("reports a chunk that opens and closes a control string as inactive", () => {
const chunk = `${ESC}]0;title${BEL}ready\n`;
expect(updateControlStringMode(chunk, false)).toEqual({
isActive: false,
wasActive: false,
});
});
it("accepts ST as a terminator", () => {
expect(
updateControlStringMode(`${ESC}]7;file:///tmp${ESC}\\`, false),
).toEqual({
isActive: false,
wasActive: false,
});
});
it("treats a trailing lone ESC as active, since its meaning is in the next chunk", () => {
expect(updateControlStringMode(`done${ESC}`, false)).toEqual({
isActive: true,
wasActive: false,
});
});
it("recognises DCS, APC and PM openers too", () => {
for (const opener of ["P", "^", "_"]) {
expect(
updateControlStringMode(`${ESC}${opener}payload`, false).isActive,
).toBe(true);
}
});
it("does not treat CSI as a control string", () => {
expect(updateControlStringMode(`${ESC}[31mred${ESC}[0m`, false)).toEqual({
isActive: false,
wasActive: false,
});
});
it("leaves a continuation chunk unhighlighted", () => {
// The bug: this chunk highlights cleanly on its own, and injecting SGR
// bytes into an open OSC string dumps the payload onto the screen.
const continuation = `${BEL}connect to 10.0.0.11 failed`;
expect(highlightTerminalOutput(continuation)).not.toBe(continuation);
const state = updateControlStringMode(continuation, true);
expect(state.wasActive).toBe(true);
});
it("passes through SGR mouse-tracking reports untouched (issue #1023)", () => {
// ESC[<Cb;Cx;CyM is the SGR (1006) mouse-report format. The `<` marker
// byte was missing from the parameter-byte character class, so these
// sequences weren't recognised as ANSI escapes and their raw digits
// leaked onto the screen as visible text inside `screen`/tmux sessions.
const report = `${ESC}[<35;191;1M`;
expect(highlightTerminalOutput(report)).toBe(report);
});
it("passes through a burst of SGR mouse reports untouched", () => {
const burst = `${ESC}[<35;191;1M${ESC}[<35;186;2M${ESC}[<35;46;21M`;
expect(highlightTerminalOutput(burst)).toBe(burst);
});
it("treats a chunk ending mid SGR-mouse-report as incomplete", () => {
const partial = `foo${ESC}[<35;191`;
expect(highlightTerminalOutput(partial)).toBe(partial);
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
getNextTerminalFontSize,
getTerminalFontZoomDirection,
} from "../../../src/frontend/look/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);
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import {
ensureTerminalFontsLoaded,
installTerminalGlobalStyles,
} from "../../../src/frontend/look/terminal-global-styles";
describe("installTerminalGlobalStyles", () => {
it("adds the font faces while the plugin runs and removes them after", () => {
const dispose = installTerminalGlobalStyles();
const style = document.head.querySelector(
"style[data-termix-terminal-styles]",
);
expect(style?.innerHTML).toContain("Caskaydia Cove Nerd Font Mono");
dispose();
expect(
document.head.querySelector("style[data-termix-terminal-styles]"),
).toBeNull();
});
});
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,
});
}
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { resolveTermixThemeColors } from "../../../src/frontend/look/terminal-theme";
import { TERMINAL_THEMES } from "../../../src/frontend/look/terminal-themes";
describe("resolveTermixThemeColors", () => {
it("uses the light selection color with the light UI theme", () => {
const colors = resolveTermixThemeColors("termix", "light");
expect(colors.background).toBe("#ffffff");
expect(colors.selectionBackground).toBe(
TERMINAL_THEMES.termixLight.colors.selectionBackground,
);
});
it("keeps the dark selection color with dark UI themes", () => {
const colors = resolveTermixThemeColors("termix", "dracula");
expect(colors.background).toBe("#282a36");
expect(colors.selectionBackground).toBe(
TERMINAL_THEMES.termixDark.colors.selectionBackground,
);
});
});
@@ -0,0 +1,232 @@
import { describe, expect, it, vi } from "vitest";
import {
mergeLocalMacros,
parseTerminalMacros,
sanitizeTerminalMacros,
runTerminalMacro,
type MacroStep,
type TerminalMacro,
} from "../../../src/frontend/macros/terminal-macros";
function macro(steps: MacroStep[]): TerminalMacro {
return {
id: "macro-1",
name: "Test",
steps,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
describe("terminal macro runner", () => {
it("runs send, delay, regex wait, condition, and repeat steps", async () => {
vi.useFakeTimers();
const sent: string[] = [];
let listener = (_data: string) => {};
const run = runTerminalMacro(
macro([
{ id: "send", type: "send", text: "status", pressEnter: true },
{
id: "wait",
type: "wait",
pattern: "READY\\s+42",
timeoutMs: 1000,
onTimeout: "stop",
},
{
id: "if",
type: "if",
pattern: "READY",
then: [
{
id: "repeat",
type: "repeat",
count: 2,
steps: [
{ id: "ok", type: "send", text: "ok", pressEnter: false },
],
},
],
else: [],
},
]),
{
send: (data) => sent.push(data),
subscribe: (next) => {
listener = next;
return () => {};
},
},
);
await vi.advanceTimersByTimeAsync(1);
listener("READY 42");
await run;
expect(sent).toEqual(["status\r", "ok", "ok"]);
vi.useRealTimers();
});
it("stops when a required regex times out", async () => {
vi.useFakeTimers();
const run = runTerminalMacro(
macro([
{
id: "wait",
type: "wait",
pattern: "never",
timeoutMs: 100,
onTimeout: "stop",
},
]),
{ send: () => {}, subscribe: () => () => {} },
);
const rejection = expect(run).rejects.toThrow("Timed out");
await vi.advanceTimersByTimeAsync(101);
await rejection;
vi.useRealTimers();
});
it("sanitizes persisted macro bounds and drops unknown steps", () => {
const parsed = parseTerminalMacros(
JSON.stringify([
{
id: "m",
name: "Macro",
steps: [
{ id: "d", type: "delay", milliseconds: 999_999 },
{ id: "r", type: "repeat", count: 1000, steps: [] },
{ id: "bad", type: "execute-javascript", code: "nope" },
],
},
]),
);
expect(parsed[0].steps).toEqual([
{ id: "d", type: "delay", milliseconds: 300_000 },
{ id: "r", type: "repeat", count: 100, steps: [] },
]);
});
it("matches literal text without regex escaping when isRegex is false", async () => {
vi.useFakeTimers();
const sent: string[] = [];
let listener = (_data: string) => {};
const run = runTerminalMacro(
macro([
{
id: "wait",
type: "wait",
pattern: "progress (1/2)",
isRegex: false,
timeoutMs: 1000,
onTimeout: "stop",
},
{ id: "send", type: "send", text: "done", pressEnter: false },
]),
{
send: (data) => sent.push(data),
subscribe: (next) => {
listener = next;
return () => {};
},
},
);
await vi.advanceTimersByTimeAsync(1);
listener("progress (1/2)");
await run;
expect(sent).toEqual(["done"]);
vi.useRealTimers();
});
it("falls back to literal matching when a regex is invalid", async () => {
vi.useFakeTimers();
const sent: string[] = [];
let listener = (_data: string) => {};
const run = runTerminalMacro(
macro([
{
id: "wait",
type: "wait",
pattern: "oops(",
isRegex: true,
timeoutMs: 1000,
onTimeout: "stop",
},
{ id: "send", type: "send", text: "ok", pressEnter: false },
]),
{
send: (data) => sent.push(data),
subscribe: (next) => {
listener = next;
return () => {};
},
},
);
await vi.advanceTimersByTimeAsync(1);
listener("oops(");
await run;
expect(sent).toEqual(["ok"]);
vi.useRealTimers();
});
it("branches on literal if-step text", async () => {
const sent: string[] = [];
await runTerminalMacro(
macro([
{
id: "if",
type: "if",
pattern: "a.b",
isRegex: false,
then: [{ id: "y", type: "send", text: "yes", pressEnter: false }],
else: [{ id: "n", type: "send", text: "no", pressEnter: false }],
},
]),
{ send: (data) => sent.push(data), subscribe: () => () => {} },
);
// "a.b" as a literal must not match "axb"
expect(sent).toEqual(["no"]);
});
});
describe("mergeLocalMacros", () => {
function storage(value: string | null) {
const store = new Map<string, string>();
if (value !== null) store.set("terminalMacros", value);
return {
store,
getItem: (key: string) => store.get(key) ?? null,
removeItem: (key: string) => void store.delete(key),
};
}
const saved = (id: string): TerminalMacro => ({ ...macro([]), id });
it("returns null when the browser kept nothing", () => {
expect(mergeLocalMacros([saved("a")], storage(null))).toBeNull();
});
it("adds the browser's macros the saved list does not have", () => {
const local = storage(
JSON.stringify([
{ id: "a", name: "Dupe", steps: [] },
{ id: "b", name: "Local", steps: [] },
]),
);
const merged = mergeLocalMacros([saved("a")], local);
expect(merged?.macros.map((m) => m.id)).toEqual(["a", "b"]);
expect(local.store.has("terminalMacros")).toBe(true);
merged?.clear();
expect(local.store.has("terminalMacros")).toBe(false);
});
it("clears the key when everything is already saved", () => {
const local = storage(JSON.stringify([{ id: "a", name: "A", steps: [] }]));
expect(mergeLocalMacros([saved("a")], local)).toBeNull();
expect(local.store.has("terminalMacros")).toBe(false);
});
it("reads a stored array as well as its JSON", () => {
expect(
sanitizeTerminalMacros([{ id: "x", name: "X", steps: [] }]).map(
(m) => m.id,
),
).toEqual(["x"]);
});
});
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { HostTerminalSettings } from "../../../src/frontend/settings/HostTerminalSettings";
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
afterEach(cleanup);
type Form = Record<string, unknown>;
/** The switch on the setting row labelled with this key. */
function switchFor(label: string): HTMLElement {
const row = screen.getByText(label).closest("div.justify-between")!;
return row.querySelector("button")!;
}
function renderSettings(form: Form = { pluginSettings: {} }) {
let current: Form = form;
const setField = vi.fn();
const updateForm = vi.fn((patch: (form: Form) => Form) => {
current = patch(current);
});
render(
<HostTerminalSettings
form={form}
setField={setField}
updateForm={updateForm}
host={{ id: "1", name: "box", ip: "10.0.0.1", port: 22 }}
/>,
);
return { setField, updateForm, current: () => current };
}
describe("HostTerminalSettings", () => {
it("renders the appearance and behavior cards", () => {
renderSettings();
expect(screen.getByText("hosts.terminalAppearance")).toBeTruthy();
expect(screen.getByText("hosts.behaviorAndAdvanced")).toBeTruthy();
});
it("leaves the SSH connection options to the core SSH tab", () => {
renderSettings();
expect(screen.queryByText("hosts.keepaliveIntervalLabel")).toBeNull();
expect(screen.queryByText("hosts.environmentVariablesLabel")).toBeNull();
expect(screen.queryByText("hosts.sshAgentForwardingLabel")).toBeNull();
});
it("draws the auto-fill switches that left the SSH tab", () => {
renderSettings();
expect(screen.getByText("hosts.passwordPromptAutoFillLabel")).toBeTruthy();
expect(screen.getByText("hosts.sudoPasswordAutoFillLabel")).toBeTruthy();
});
it("no longer draws the startup snippet, which is the snippets plugin's", () => {
renderSettings();
expect(screen.queryByText("hosts.startupSnippetLabel")).toBeNull();
});
it("writes its values into the host's ssh-terminal settings", () => {
const { current } = renderSettings({
pluginSettings: { other: { keep: true } },
});
fireEvent.click(switchFor("hosts.enableAutoMosh"));
expect(current().pluginSettings).toEqual({
other: { keep: true },
"ssh-terminal": { autoMosh: true },
});
});
it("takes the host off the user's look when the look changes", () => {
const { current } = renderSettings({
pluginSettings: { "ssh-terminal": { inheritAppearance: false } },
});
fireEvent.click(switchFor("hosts.cursorBlinking"));
expect(
(current().pluginSettings as Record<string, Form>)["ssh-terminal"],
).toMatchObject({ cursorBlink: false, inheritAppearance: false });
});
});
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TouchInputSettings } from "../../../src/frontend/settings/TouchInputSettings";
import { TOUCH_INPUT_DEFAULTS } from "../../../src/shared/touch-input-settings";
function renderSettings(values: Record<string, unknown> = {}) {
const setValue = vi.fn();
render(
<TouchInputSettings
pluginId="ssh-terminal"
values={values}
setValue={setValue}
running
/>,
);
return setValue;
}
describe("TouchInputSettings", () => {
it("shows simple defaults, keeps tunables collapsed, and resets", async () => {
const user = userEvent.setup();
const setValue = renderSettings({
touchInput: { ...TOUCH_INPUT_DEFAULTS, dragThresholdPx: 20 },
});
expect(screen.getByText("touchInput.enabled")).toBeTruthy();
expect(screen.queryByLabelText("touchInput.dragThreshold")).toBeNull();
await user.click(screen.getByText("touchInput.advanced"));
expect(
(screen.getByLabelText("touchInput.dragThreshold") as HTMLInputElement)
.value,
).toBe("20");
expect(
(
screen.getByLabelText(
"touchInput.maximumTicksPerFrame",
) as HTMLInputElement
).value,
).toBe("4");
await user.click(screen.getByText("touchInput.resetDefaults"));
expect(setValue).toHaveBeenLastCalledWith("touchInput", {
...TOUCH_INPUT_DEFAULTS,
});
});
it("falls back to the defaults when nothing is stored", () => {
renderSettings();
expect(screen.getByText("touchInput.momentumEnabled")).toBeTruthy();
});
});
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { moveLocalTerminalPreferences } from "../../../src/frontend/settings/local-preferences-migration";
import { resetTerminalClientSettings } from "../../../src/frontend/terminal-settings";
function api(user: Record<string, unknown>) {
return {
get: vi.fn(async () => ({ data: { user } })),
put: vi.fn(async () => ({ data: {} })),
};
}
beforeEach(() => {
localStorage.clear();
resetTerminalClientSettings();
});
afterEach(() => resetTerminalClientSettings());
describe("moveLocalTerminalPreferences", () => {
it("does nothing when the browser kept nothing", async () => {
const client = api({});
await moveLocalTerminalPreferences({ api: client as never });
expect(client.get).not.toHaveBeenCalled();
});
it("moves the browser's values into settings still at their default, once", async () => {
localStorage.setItem("terminalLocalEchoMode", "on");
localStorage.setItem("terminalLinkClickBehavior", "direct");
localStorage.setItem("commandAutocomplete", "true");
const client = api({ commandAutocomplete: false });
await moveLocalTerminalPreferences({ api: client as never });
expect(client.put).toHaveBeenCalledWith("/user-settings", {
localEcho: "on",
linkClickBehavior: "direct",
commandAutocomplete: true,
});
expect(localStorage.getItem("terminalLocalEchoMode")).toBeNull();
expect(localStorage.getItem("commandAutocomplete")).toBeNull();
});
it("keeps a value the user already changed on the server", async () => {
localStorage.setItem("terminalLocalEchoMode", "off");
const client = api({ localEcho: "on" });
await moveLocalTerminalPreferences({ api: client as never });
expect(client.put).not.toHaveBeenCalled();
expect(localStorage.getItem("terminalLocalEchoMode")).toBeNull();
});
it("keeps the browser values for next time when the server is unreachable", async () => {
localStorage.setItem("terminalLocalEchoMode", "on");
const client = {
get: vi.fn(async () => {
throw new Error("offline");
}),
put: vi.fn(),
};
await moveLocalTerminalPreferences({ api: client as never });
expect(localStorage.getItem("terminalLocalEchoMode")).toBe("on");
});
});
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { SshToolsPanel } from "../../../src/frontend/ssh-tools/SshToolsPanel";
import { registerSession } from "../../../src/frontend/session-registry";
import type { TerminalHandle } from "../../../src/frontend/terminal/terminal-types";
const disposers: Array<() => void> = [];
afterEach(() => {
cleanup();
for (const dispose of disposers.splice(0)) dispose();
});
function openSession(id: string, label: string) {
const handle = { sendInput: vi.fn() } as unknown as TerminalHandle;
act(() => {
disposers.push(registerSession({ id, hostId: 1, label }, handle));
});
return handle;
}
function renderPanel(targetTab?: { id: string }) {
return render(
<SshToolsPanel
targetTab={targetTab as never}
active
shell={{} as never}
setEditing={() => {}}
placement="left"
/>,
);
}
describe("SshToolsPanel", () => {
it("says so when no terminal is open", () => {
renderPanel();
expect(screen.getByText("sshTools.noTerminalTabsOpen")).toBeTruthy();
});
it("lists open terminals and follows sessions opening later", () => {
openSession("tab-1", "web-01");
renderPanel();
expect(screen.getByText("web-01")).toBeTruthy();
openSession("tab-2", "db-01");
expect(screen.getByText("db-01")).toBeTruthy();
});
it("types into the selected terminals only", () => {
const first = openSession("tab-1", "web-01");
const second = openSession("tab-2", "db-01");
renderPanel({ id: "tab-1" });
fireEvent.click(screen.getByText(/sshTools.startRecording/));
fireEvent.keyDown(
screen.getByPlaceholderText("sshTools.broadcastInputPlaceholder"),
{ key: "a" },
);
expect(first.sendInput).toHaveBeenCalledWith("a");
expect(second.sendInput).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,66 @@
import { cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import type { TabProps } from "@termix/plugin-sdk/frontend";
const captured = vi.hoisted(() => ({ props: [] as Record<string, unknown>[] }));
vi.mock("../../../src/frontend/terminal/Terminal", () => ({
Terminal: (props: Record<string, unknown>) => {
captured.props.push(props);
return null;
},
}));
vi.mock(
"../../../src/frontend/terminal/command-history/CommandHistoryContext",
() => ({
CommandHistoryProvider: ({ children }: { children: ReactNode }) => (
<>{children}</>
),
}),
);
vi.mock("@termix/plugin-sdk/ui", () => ({
useIsMobile: () => false,
}));
import { TerminalTabContent } from "../../../src/frontend/terminal/TerminalTabContent";
afterEach(() => {
cleanup();
captured.props = [];
});
function renderTab(inSplit?: boolean) {
const props = {
tab: { id: "t1", instanceId: "i1", type: "terminal", label: "web-01" },
host: { id: "1", name: "web-01", ip: "10.0.0.1", port: 22 },
sshHost: { id: 1 },
label: "web-01",
isVisible: true,
isFocusedPane: true,
inSplit,
handleRef: null,
shell: {
closeTab: vi.fn(),
renameTab: vi.fn(),
openTab: vi.fn(),
},
} as unknown as TabProps;
render(<TerminalTabContent {...props} />);
}
describe("TerminalTabContent in a split", () => {
it("tells the terminal it is in a split, so it does not grab focus", async () => {
renderTab(true);
await waitFor(() => expect(captured.props.length).toBeGreaterThan(0));
expect(captured.props.at(-1)?.splitScreen).toBe(true);
});
it("lets a terminal on its own take focus as before", async () => {
renderTab(undefined);
await waitFor(() => expect(captured.props.length).toBeGreaterThan(0));
expect(captured.props.at(-1)?.splitScreen).toBe(false);
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { getAndroidHardwareKeySequence } from "../../../src/frontend/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();
});
});
@@ -0,0 +1,76 @@
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");
});
});
@@ -0,0 +1,64 @@
// @vitest-environment node
import fs from "node:fs";
import vm from "node:vm";
import { describe, expect, it, vi } from "vitest";
const source = fs.readFileSync(
new URL("../../../src/frontend/terminal/Terminal.tsx", import.meta.url),
"utf8",
);
const attempt = source.slice(
source.indexOf(" function attemptReconnection()"),
source.indexOf(" // A persisted session that timed out"),
);
function run(keepScrollback: boolean, attempts = 0) {
const timers: Array<() => void> = [];
const context = {
terminal: { cols: 100, rows: 30, clear: vi.fn() },
hostConfig: { id: 1 },
isUnmountingRef: { current: false },
shouldNotReconnectRef: { current: false },
isReconnectingRef: { current: false },
isConnectingRef: { current: false },
isAttachingSessionRef: { current: false },
wasDisconnectedBySSH: { current: false },
reconnectTimeoutRef: { current: null as unknown },
reconnectAttempts: { current: attempts },
keepScrollbackRef: { current: keepScrollback },
maxReconnectAttempts: 8,
setIsConnecting: vi.fn(),
setShowDisconnectedOverlay: vi.fn(),
addLog: vi.fn(),
t: (key: string) => key,
connectToHost: vi.fn(),
setTimeout: (fn: () => void) => {
timers.push(fn);
return 1;
},
Math,
};
vm.runInNewContext(`${attempt}\nattemptReconnection();`, context);
timers.forEach((fn) => fn());
return context;
}
describe("terminal auto reconnect", () => {
it("keeps the scrollback while reconnecting after a drop", () => {
const context = run(true);
expect(context.terminal.clear).not.toHaveBeenCalled();
expect(context.connectToHost).toHaveBeenCalledWith(100, 30);
});
it("still clears the screen on an ordinary reconnect", () => {
const context = run(false);
expect(context.terminal.clear).toHaveBeenCalled();
});
it("gives up after the last attempt and stops keeping scrollback", () => {
const context = run(true, 8);
expect(context.connectToHost).not.toHaveBeenCalled();
expect(context.setShowDisconnectedOverlay).toHaveBeenCalledWith(true);
expect(context.keepScrollbackRef.current).toBe(false);
});
});
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
fontSizeStorageKey,
readFontSize,
saveFontSize,
} from "../../../src/frontend/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();
});
});
@@ -0,0 +1,60 @@
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");
});
});
@@ -0,0 +1,72 @@
// @vitest-environment node
import fs from "node:fs";
import vm from "node:vm";
import { describe, expect, it, vi } from "vitest";
const source = fs.readFileSync(
new URL("../../../src/frontend/terminal/Terminal.tsx", import.meta.url),
"utf8",
);
const reconnect = source.slice(
source.indexOf(" function reconnectTerminal()"),
source.indexOf(" useImperativeHandle("),
);
const methods = source.slice(
source.indexOf(" reconnect: reconnectTerminal,"),
source.indexOf(" isConnected: () => isConnected,"),
);
function createHandle(overrides: Record<string, unknown> = {}) {
const context = {
terminal: { cols: 80, rows: 24, clear: vi.fn() },
isConnected: false,
isUnmountingRef: { current: false },
shouldNotReconnectRef: { current: true },
isReconnectingRef: { current: false },
isConnectingRef: { current: false },
reconnectTimeoutRef: { current: null },
reconnectAttempts: { current: 8 },
wasDisconnectedBySSH: { current: true },
wasConnectedRef: { current: false },
updateConnectionError: vi.fn(),
setShowDisconnectedOverlay: vi.fn(),
connectToHost: vi.fn(() => {
context.isConnectingRef.current = true;
}),
...overrides,
};
const handle = vm.runInNewContext(
reconnect + `\n({${methods}})`,
context,
) as { reconnectIfDisconnected: () => boolean; reconnect: () => void };
return { handle, context };
}
describe("terminal bulk reconnect handle", () => {
it("uses the manual reconnect flow after a dropped SSH session and rejects duplicate requests", () => {
const { handle, context } = createHandle();
expect(handle.reconnectIfDisconnected()).toBe(true);
expect(context.connectToHost).toHaveBeenCalledWith(80, 24);
expect(context.shouldNotReconnectRef.current).toBe(false);
expect(context.reconnectAttempts.current).toBe(0);
expect(context.wasDisconnectedBySSH.current).toBe(false);
expect(handle.reconnectIfDisconnected()).toBe(false);
expect(context.connectToHost).toHaveBeenCalledOnce();
});
it.each([
{ isConnected: true },
{ isConnectingRef: { current: true } },
{ isReconnectingRef: { current: true } },
{ reconnectTimeoutRef: { current: 1 } },
{ isUnmountingRef: { current: true } },
{ terminal: null },
])(
"does not interrupt a connected, busy or unavailable terminal: %j",
(overrides) => {
const { handle, context } = createHandle(overrides);
expect(handle.reconnectIfDisconnected()).toBe(false);
expect(context.connectToHost).not.toHaveBeenCalled();
expect(context.shouldNotReconnectRef.current).toBe(true);
},
);
});
@@ -0,0 +1,110 @@
/// <reference types="vite/client" />
import ts from "typescript";
import { describe, expect, it, vi } from "vitest";
import sourceText from "../../../src/frontend/terminal/Terminal.tsx?raw";
const source = ts.createSourceFile(
"Terminal.tsx",
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const names = [
"setupWebSocketListeners",
"applySharedSize",
"fitTerminal",
"scheduleNotify",
];
const functions: string[] = [];
function visit(node: ts.Node) {
if (
ts.isFunctionDeclaration(node) &&
node.name &&
names.includes(node.name.text)
)
functions.push(node.getText(source));
ts.forEachChild(node, visit);
}
visit(source);
if (functions.length !== names.length)
throw new Error("Missing terminal size handlers");
const create = new Function(
"state",
ts.transpileModule(
`
const { terminal, hostConfig, sharedSizeRef, fitAddonRef, webSocketRef, emitSessionMessage } = state;
const connectionAttemptIdRef = { current: 1 };
${functions.join("\n")}
setupWebSocketListeners(webSocketRef.current, 80, 24, hostConfig);
return { fit: fitTerminal, notify: scheduleNotify };
`,
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
).outputText,
) as (state: Record<string, unknown>) => {
fit: () => void;
notify: (cols: number, rows: number) => void;
};
function fixture(shared = true) {
const events = new Map<string, (event: { data: string }) => void>();
const terminal = {
cols: 80,
rows: 24,
resize: vi.fn((cols: number, rows: number) => {
terminal.cols = cols;
terminal.rows = rows;
}),
};
const fit = vi.fn(() => {
terminal.cols = 80;
terminal.rows = 24;
});
const socket = {
addEventListener: (name: string, fn: (event: { data: string }) => void) =>
events.set(name, fn),
send: vi.fn(),
};
const handlers = create({
terminal,
hostConfig: shared ? { joinShareId: "share" } : {},
sharedSizeRef: { current: null },
fitAddonRef: { current: { fit } },
webSocketRef: { current: socket },
emitSessionMessage: vi.fn(),
});
const resize = (cols: unknown, rows: unknown) =>
events.get("message")!({
data: JSON.stringify({ type: "resized", cols, rows }),
});
return { terminal, fit, handlers, socket, resize };
}
describe("in-app shared terminal sizing", () => {
it("uses presenter sizes and does not refit or resize the remote shell from the viewer", () => {
const f = fixture();
f.resize(132, 40);
expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
f.handlers.fit();
expect(f.fit).not.toHaveBeenCalled();
expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
f.handlers.notify(80, 24);
expect(f.socket.send).not.toHaveBeenCalled();
f.resize(100, 30);
expect([f.terminal.cols, f.terminal.rows]).toEqual([100, 30]);
});
it("continues fitting the owner's terminal normally", () => {
const f = fixture(false);
f.resize(132, 40);
expect(f.terminal.resize).not.toHaveBeenCalled();
f.handlers.fit();
expect(f.fit).toHaveBeenCalledOnce();
});
it.each([0, -1, 1.5, "132"])("rejects an invalid column count %s", (cols) => {
const f = fixture();
f.resize(cols, 24);
expect(f.terminal.resize).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type { Terminal } from "@xterm/xterm";
import { getTerminalBufferText } from "../../../src/frontend/terminal/terminal-buffer-text";
interface FakeLine {
text: string;
wrapped?: boolean;
}
/** Minimal stand-in for the parts of xterm this reads. */
function fakeTerminal(lines: FakeLine[], rows = lines.length): Terminal {
return {
rows,
buffer: {
active: {
baseY: Math.max(0, lines.length - rows),
getLine: (i: number) => {
const line = lines[i];
if (!line) return undefined;
return {
isWrapped: !!line.wrapped,
translateToString: () => line.text,
};
},
},
},
} as unknown as Terminal;
}
describe("getTerminalBufferText", () => {
it("returns an empty string for a missing terminal", () => {
expect(getTerminalBufferText(null)).toBe("");
expect(getTerminalBufferText(undefined)).toBe("");
});
it("joins buffer rows with newlines", () => {
const terminal = fakeTerminal([{ text: "$ whoami" }, { text: "root" }]);
expect(getTerminalBufferText(terminal)).toBe("$ whoami\nroot");
});
it("joins a wrapped row onto the line it continues", () => {
const terminal = fakeTerminal([
{ text: "$ echo start" },
{ text: "a-very-long-command " },
{ text: "that-wrapped", wrapped: true },
]);
expect(getTerminalBufferText(terminal)).toBe(
"$ echo start\na-very-long-command that-wrapped",
);
});
it("drops trailing blank lines", () => {
const terminal = fakeTerminal([
{ text: "output" },
{ text: "" },
{ text: "" },
]);
expect(getTerminalBufferText(terminal)).toBe("output");
});
it("keeps blank lines in the middle", () => {
const terminal = fakeTerminal([
{ text: "one" },
{ text: "" },
{ text: "two" },
]);
expect(getTerminalBufferText(terminal)).toBe("one\n\ntwo");
});
it("bounds the result to maxLines, keeping the tail", () => {
const lines = Array.from({ length: 50 }, (_, i) => ({ text: `line ${i}` }));
const terminal = fakeTerminal(lines, 50);
const result = getTerminalBufferText(terminal, 3);
expect(result).toBe("line 47\nline 48\nline 49");
});
it("returns an empty string for a non-positive maxLines", () => {
const terminal = fakeTerminal([{ text: "anything" }]);
expect(getTerminalBufferText(terminal, 0)).toBe("");
});
it("returns an empty string when the buffer throws", () => {
const disposed = {
rows: 1,
get buffer(): never {
throw new Error("terminal disposed");
},
} as unknown as Terminal;
expect(getTerminalBufferText(disposed)).toBe("");
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
resolveTerminalContextMenuAction,
selectedTextToCopy,
} from "../../../src/frontend/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");
});
});
@@ -0,0 +1,332 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Terminal } from "@xterm/xterm";
const getCookieMock = vi.fn<(name: string) => string | undefined>();
vi.mock("@termix/plugin-sdk/frontend", () => ({
getClientPreference: (name: string) => getCookieMock(name),
}));
import {
getUseRightClickCopyPaste,
handleTerminalClipboardKeyEvent,
createTerminalContextMenuHandler,
} from "../../../src/frontend/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");
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { quoteTerminalImagePath } from "../../../src/frontend/terminal/terminal-image-path";
describe("quoteTerminalImagePath", () => {
it("preserves spaces as one shell argument", () => {
expect(quoteTerminalImagePath("/mnt/my images/clipboard.png")).toBe(
"'/mnt/my images/clipboard.png'",
);
});
it("does not expose shell metacharacters as syntax", () => {
expect(
quoteTerminalImagePath("/tmp/$(touch /tmp/pwned);`id`; 'quoted'.png"),
).toBe("'/tmp/$(touch /tmp/pwned);`id`; '\\''quoted'\\''.png'");
});
it("quotes an empty path instead of returning executable input", () => {
expect(quoteTerminalImagePath("")).toBe("''");
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { buildImageUploadFormData } from "../../../src/frontend/terminal/terminal-image-upload";
describe("buildImageUploadFormData", () => {
it("tags a picked-file upload with source=file and a client timestamp", () => {
const file = new File(["png-bytes"], "photo.png", { type: "image/png" });
const form = buildImageUploadFormData(file, "tab-1", "file");
expect(form.get("image")).toBe(file);
expect(form.get("instanceId")).toBe("tab-1");
expect(form.get("source")).toBe("file");
const timestamp = form.get("clientUploadTimestamp");
expect(typeof timestamp).toBe("string");
expect(Number.isNaN(Date.parse(timestamp as string))).toBe(false);
});
it("tags a clipboard upload with source=clipboard", () => {
const file = new File(["png-bytes"], "clipboard-image.png", {
type: "image/png",
});
const form = buildImageUploadFormData(file, "tab-1", "clipboard");
expect(form.get("source")).toBe("clipboard");
});
it("uses the caller-provided client timestamp unchanged", () => {
const file = new File(["png-bytes"], "photo.png", { type: "image/png" });
const form = buildImageUploadFormData(
file,
"tab-1",
"file",
"2026-08-15T12:00:00.000Z",
);
expect(form.get("clientUploadTimestamp")).toBe("2026-08-15T12:00:00.000Z");
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
isPhysicalShortcutKey,
isTabKeyEvent,
} from "../../../src/frontend/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,
);
});
});
@@ -0,0 +1,76 @@
/// <reference types="vite/client" />
import ts from "typescript";
import { afterEach, describe, expect, it, vi } from "vitest";
import sourceText from "../../../src/frontend/terminal/Terminal.tsx?raw";
const source = ts.createSourceFile(
"Terminal.tsx",
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
let effect: string | undefined;
function visit(node: ts.Node) {
if (
ts.isCallExpression(node) &&
node.expression.getText(source) === "useEffect" &&
node.arguments[0]?.getText(source).includes("isMountedRef.current = true")
) {
effect = node.arguments[0].getText(source);
}
ts.forEachChild(node, visit);
}
visit(source);
if (!effect) throw new Error("Missing terminal lifecycle effect");
const mount = new Function(
"state",
ts.transpileModule(
`
const { isMountedRef, hostConfig, currentHostIdRef, isUnmountingRef,
shouldNotReconnectRef, isReconnectingRef, setIsConnecting,
reconnectTimeoutRef, connectionTimeoutRef, totpTimeoutRef,
pingIntervalRef, pongTimeoutRef, webSocketRef } = state;
return (${effect})();
`,
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
).outputText,
) as (state: Record<string, unknown>) => () => void;
afterEach(() => vi.useRealTimers());
describe("terminal unmount", () => {
it.each([1, 2, null])(
"closes the socket when current host is %s",
(currentHost) => {
vi.useFakeTimers();
const timeout = () => ({ current: setTimeout(() => {}, 10000) });
const state = {
isMountedRef: { current: false },
hostConfig: { id: 1, instanceId: "collab-present-room" },
currentHostIdRef: { current: currentHost },
isUnmountingRef: { current: false },
shouldNotReconnectRef: { current: false },
isReconnectingRef: { current: true },
setIsConnecting: vi.fn(),
reconnectTimeoutRef: timeout(),
connectionTimeoutRef: timeout(),
totpTimeoutRef: timeout(),
pongTimeoutRef: timeout(),
pingIntervalRef: { current: setInterval(() => {}, 1000) },
webSocketRef: { current: { close: vi.fn() } },
};
const unmount = mount(state);
expect(state.isMountedRef.current).toBe(true);
unmount();
expect(state.webSocketRef.current.close).toHaveBeenCalledOnce();
expect(state.shouldNotReconnectRef.current).toBe(true);
expect(state.isUnmountingRef.current).toBe(true);
expect(state.isReconnectingRef.current).toBe(false);
expect(state.isMountedRef.current).toBe(false);
expect(vi.getTimerCount()).toBe(0);
unmount();
expect(state.webSocketRef.current.close).toHaveBeenCalledOnce();
},
);
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_TOOLBAR_SETTINGS,
readToolbarSettings,
toolbarAnchorClasses,
} from "../../../src/frontend/terminal/toolbar-settings";
import { toolbarPositionStorageKey } from "../../../src/frontend/terminal/toolbar-geometry";
const hostWith = (values: Record<string, unknown>) => ({
pluginSettings: { "ssh-terminal": values },
});
describe("readToolbarSettings", () => {
it("falls back to defaults for a host without settings", () => {
expect(readToolbarSettings({})).toEqual(DEFAULT_TOOLBAR_SETTINGS);
expect(readToolbarSettings(null)).toEqual(DEFAULT_TOOLBAR_SETTINGS);
});
it("reads saved values", () => {
expect(
readToolbarSettings(
hostWith({
terminalToolbarPosition: "top-left",
terminalToolbarStartState: "collapsed",
terminalToolbarDisplay: "expanded",
terminalToolbarShowStatus: false,
terminalToolbarFade: false,
}),
),
).toEqual({
anchor: "top-left",
startCollapsed: true,
density: "expanded",
showStatus: false,
fadeWhenIdle: false,
});
});
it("ignores unknown values", () => {
const settings = readToolbarSettings(
hostWith({
terminalToolbarPosition: "middle",
terminalToolbarDisplay: "remember",
terminalToolbarShowStatus: "no",
}),
);
expect(settings.anchor).toBe("bottom");
expect(settings.density).toBeNull();
expect(settings.showStatus).toBe(true);
});
});
describe("toolbar anchors", () => {
it("maps anchors to flex placement", () => {
expect(toolbarAnchorClasses("bottom")).toBe(
"items-end pb-2 justify-center",
);
expect(toolbarAnchorClasses("top-left")).toBe(
"items-start pt-2 justify-start pl-2",
);
expect(toolbarAnchorClasses("bottom-right")).toBe(
"items-end pb-2 justify-end pr-2",
);
});
it("keeps the original storage key for the bottom anchor", () => {
expect(toolbarPositionStorageKey()).toBe(
"termix-terminal-toolbar-position-v2",
);
expect(toolbarPositionStorageKey("top")).toBe(
"termix-terminal-toolbar-position-v2-top",
);
});
});
@@ -0,0 +1,417 @@
import { describe, expect, it, vi } from "vitest";
import {
installTouchWheelCoordinator,
TouchWheelCoordinator,
} from "../../../src/frontend/terminal/touch-wheel-coordinator";
import { TOUCH_INPUT_DEFAULTS } from "../../../src/shared/touch-input-settings";
const point = (clientY: number, clientX = 10) => ({ clientX, clientY });
const touchEvent = (type: string, clientYValues: number[]) => {
const event = new Event(type, { bubbles: true, cancelable: true });
const touches = clientYValues.map((clientY) => ({ clientX: 10, clientY }));
Object.defineProperty(event, "touches", {
value: Object.assign(touches, {
item: (index: number) => touches[index] ?? null,
}),
});
return event;
};
const createTiming = (momentumAllowed = true) => {
let now = 0;
let nextHandle = 1;
const callbacks = new Map<number, FrameRequestCallback>();
return {
timing: {
now: () => now,
requestFrame: (callback: FrameRequestCallback) => {
const handle = nextHandle++;
callbacks.set(handle, callback);
return handle;
},
cancelFrame: (handle: number) => callbacks.delete(handle),
momentumAllowed: () => momentumAllowed,
},
advance(milliseconds: number) {
now += milliseconds;
const pending = [...callbacks.values()];
callbacks.clear();
pending.forEach((callback) => callback(now));
},
pendingFrames: () => callbacks.size,
};
};
describe("TouchWheelCoordinator", () => {
it("consumes configured drag and wheel limits", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit, 2, 3, undefined, {
...TOUCH_INPUT_DEFAULTS,
dragThresholdPx: 2,
maxWheelDeltaPx: 3,
});
coordinator.start(point(20));
coordinator.move(point(10));
expect(emit.mock.calls.map(([event]) => event.deltaY)).toEqual([
3, 3, 3, 1,
]);
});
it("accumulates the drag threshold and preserves delta direction", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
expect(coordinator.move(point(97.5))).toBe(true);
expect(coordinator.move(point(93))).toBe(true);
coordinator.move(point(101));
expect(emit.mock.calls.map(([event]) => event.deltaY)).toEqual([7, -8]);
});
it("keeps movement below the tap threshold from scrolling", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
expect(coordinator.move(point(95))).toBe(true);
coordinator.end();
expect(emit).not.toHaveBeenCalled();
});
it("cancels the gesture when more than one touch is represented", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
coordinator.move(undefined);
expect(coordinator.move(point(80))).toBe(false);
expect(emit).not.toHaveBeenCalled();
});
it("cleans up cancelled state before the next gesture", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
coordinator.cancel();
coordinator.start(point(50));
expect(coordinator.move(point(43.5))).toBe(true);
expect(emit).toHaveBeenCalledOnce();
expect(emit.mock.calls[0][0].deltaY).toBe(6.5);
});
it.each([
{ start: 100, end: 20, direction: 1 },
{ start: 20, end: 100, direction: -1 },
])(
"emits multiple $direction line ticks after release",
({ start, end, direction }) => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(start));
clock.advance(20);
coordinator.move(point((start + end) / 2));
clock.advance(20);
coordinator.move(point(end));
coordinator.end();
const dragCallCount = emit.mock.calls.length;
for (let index = 0; index < 12; index++) clock.advance(16);
const momentum = emit.mock.calls
.slice(dragCallCount)
.map(([event]) => event);
expect(momentum.length).toBeGreaterThan(3);
expect(
momentum.every(
(event) =>
event.deltaMode === WheelEvent.DOM_DELTA_LINE &&
event.deltaY === direction,
),
).toBe(true);
},
);
it("decays the number of line ticks emitted over time", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(200));
clock.advance(20);
coordinator.move(point(100));
coordinator.end();
const dragCalls = emit.mock.calls.length;
const frameCounts: number[] = [];
for (let index = 0; index < 24; index++) {
const before = emit.mock.calls.length;
clock.advance(16);
frameCounts.push(emit.mock.calls.length - before);
}
expect(emit.mock.calls.length - dragCalls).toBeGreaterThan(3);
expect(frameCounts.slice(0, 8).reduce((a, b) => a + b, 0)).toBeGreaterThan(
frameCounts.slice(-8).reduce((a, b) => a + b, 0),
);
});
it("uses the recent movement window despite a short touchend delay", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
clock.advance(80);
coordinator.end();
expect(clock.pendingFrames()).toBe(1);
});
it("bounds momentum velocity, duration, and total travel", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(1000));
clock.advance(1);
coordinator.move(point(0));
coordinator.end();
const dragCallCount = emit.mock.calls.length;
clock.advance(200);
const firstFrameTicks = emit.mock.calls.length - dragCallCount;
for (let index = 0; index < 30; index++) clock.advance(20);
expect(clock.pendingFrames()).toBe(1);
for (let index = 0; index < 10; index++) clock.advance(20);
const totalTicks = emit.mock.calls.length - dragCallCount;
expect(firstFrameTicks).toBeLessThanOrEqual(4);
expect(totalTicks).toBeLessThanOrEqual(Math.floor(720 / 12));
expect(clock.pendingFrames()).toBe(0);
});
it("cancels momentum on a new touch or cancellation", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
coordinator.end();
expect(clock.pendingFrames()).toBe(1);
coordinator.start(point(60));
expect(clock.pendingFrames()).toBe(0);
clock.advance(20);
coordinator.move(point(20));
coordinator.end();
expect(clock.pendingFrames()).toBe(1);
coordinator.cancel();
expect(clock.pendingFrames()).toBe(0);
});
it("disables momentum when reduced motion is requested", () => {
const emit = vi.fn();
const clock = createTiming(false);
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
coordinator.end();
expect(clock.pendingFrames()).toBe(0);
expect(emit).toHaveBeenCalledOnce();
});
it("stops momentum if the active terminal mode changes", () => {
const emit = vi.fn();
let momentumAllowed = true;
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(emit, undefined, undefined, {
...clock.timing,
momentumAllowed: () => momentumAllowed,
});
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
coordinator.end();
momentumAllowed = false;
clock.advance(20);
expect(emit).toHaveBeenCalledOnce();
expect(clock.pendingFrames()).toBe(0);
});
it("retains fractional pixel distance until it forms a whole line tick", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(100);
coordinator.move(point(80));
coordinator.end();
const dragCallCount = emit.mock.calls.length;
for (let index = 0; index < 4; index++) clock.advance(16);
expect(emit.mock.calls.length).toBe(dragCallCount);
clock.advance(16);
expect(emit.mock.calls.length).toBeGreaterThan(dragCallCount);
expect(emit.mock.calls[dragCallCount][0]).toMatchObject({
deltaY: 1,
deltaMode: WheelEvent.DOM_DELTA_LINE,
});
});
});
describe("installTouchWheelCoordinator", () => {
it("claims the first single-touch move but preserves taps and multi-touch", () => {
const terminalElement = document.createElement("div");
const scrollableElement = document.createElement("div");
scrollableElement.className = "xterm-scrollable-element";
const screenElement = document.createElement("div");
screenElement.className = "xterm-screen";
scrollableElement.append(screenElement);
terminalElement.append(scrollableElement);
const dispose = installTouchWheelCoordinator(terminalElement);
terminalElement.dispatchEvent(touchEvent("touchstart", [100]));
const earlyMove = touchEvent("touchmove", [99]);
terminalElement.dispatchEvent(earlyMove);
expect(earlyMove.defaultPrevented).toBe(true);
terminalElement.dispatchEvent(touchEvent("touchend", []));
const tapEnd = touchEvent("touchend", []);
terminalElement.dispatchEvent(tapEnd);
expect(tapEnd.defaultPrevented).toBe(false);
terminalElement.dispatchEvent(touchEvent("touchstart", [100]));
const multiTouchMove = touchEvent("touchmove", [90, 80]);
terminalElement.dispatchEvent(multiTouchMove);
expect(multiTouchMove.defaultPrevented).toBe(false);
dispose();
});
it("dispatches wheel events at xterm's normal scrollback seam", () => {
const terminalElement = document.createElement("div");
const scrollableElement = document.createElement("div");
scrollableElement.className = "xterm-scrollable-element";
const screenElement = document.createElement("div");
const canvas = document.createElement("canvas");
screenElement.className = "xterm-screen";
screenElement.append(canvas);
scrollableElement.append(screenElement);
terminalElement.append(scrollableElement);
const scrollableWheel = vi.fn();
const canvasWheel = vi.fn();
scrollableElement.addEventListener("wheel", scrollableWheel);
canvas.addEventListener("wheel", canvasWheel);
const dispose = installTouchWheelCoordinator(terminalElement);
canvas.dispatchEvent(touchEvent("touchstart", [100]));
canvas.dispatchEvent(touchEvent("touchmove", [90]));
expect(scrollableWheel).toHaveBeenCalledOnce();
expect(canvasWheel).not.toHaveBeenCalled();
expect((scrollableWheel.mock.calls[0][0] as WheelEvent).deltaY).toBe(10);
expect((scrollableWheel.mock.calls[0][0] as WheelEvent).deltaMode).toBe(
WheelEvent.DOM_DELTA_PIXEL,
);
dispose();
});
it("dispatches momentum as line-mode wheel ticks from the scrollback seam", () => {
let now = 0;
let nextHandle = 1;
const callbacks = new Map<number, FrameRequestCallback>();
vi.spyOn(performance, "now").mockImplementation(() => now);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
const handle = nextHandle++;
callbacks.set(handle, callback);
return handle;
});
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
callbacks.delete(handle);
});
const terminalElement = document.createElement("div");
const scrollableElement = document.createElement("div");
scrollableElement.className = "xterm-scrollable-element";
const screenElement = document.createElement("div");
const canvas = document.createElement("canvas");
screenElement.className = "xterm-screen";
screenElement.append(canvas);
scrollableElement.append(screenElement);
terminalElement.append(scrollableElement);
const wheels: WheelEvent[] = [];
scrollableElement.addEventListener("wheel", (event) =>
wheels.push(event as WheelEvent),
);
const dispose = installTouchWheelCoordinator(terminalElement);
canvas.dispatchEvent(touchEvent("touchstart", [100]));
now = 20;
canvas.dispatchEvent(touchEvent("touchmove", [40]));
canvas.dispatchEvent(touchEvent("touchend", []));
for (let index = 0; index < 8; index++) {
now += 16;
const pending = [...callbacks.values()];
callbacks.clear();
pending.forEach((callback) => callback(now));
}
const momentum = wheels.filter(
(event) => event.deltaMode === WheelEvent.DOM_DELTA_LINE,
);
expect(momentum.length).toBeGreaterThan(1);
expect(momentum.every((event) => event.deltaY === 1)).toBe(true);
expect(wheels.every((event) => event.currentTarget === null)).toBe(true);
dispose();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
});
@@ -0,0 +1,140 @@
import { Terminal } from "@xterm/xterm";
import { afterEach, describe, expect, it, vi } from "vitest";
import { installTouchWheelCoordinator } from "../../../src/frontend/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();
});
});