feat(html): add interactive browser renderer (#8)
* feat(html): add transactional frame renderer * feat(html): add interactive browser adapter * docs(html): document renderer and browser contracts * ci(html): verify browser frontend matrix * fix(html): enforce cross-runtime invariants * fix(html): harden browser input lifecycle * fix(html): address final review findings * fix(html): address CodeRabbit review findings * perf(html): coalesce blank cells while preserving wide-space geometry * fix(ci): grant reusable checks required read permissions * fix(browser): support soft keyboard editing and reduce accessibility churn * perf(html): reduce default cell styling and contain row layout * perf(html): reuse cells for text-only terminal updates * fix(html): address Copilot review findings * fix(test): couple Playwright Docker image to lockfile * fix(html): address Copilot retained-limit and compatibility findings Bound maxUpdateBytes on the projected retained terminal text instead of the current delta, flush pending pointer moves before discrete down/up/ cancel events, accept core.CellSource in Prepare with a Frame fast path, enforce MaxGeneratedBytes on the exact encoded size, normalize the wrap-pending one-past-end cursor, and alternate rows in the row-replacement performance measurement.
- commit
- 31cc24fed8ad6d5e69d55e42588ffba52940d0e8
- author
- bnema · · 2026-09-09 14:11 UTC
- committer
- GitHub · · 2026-09-09 14:11 UTC
- parent
- 3c9eb0e
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
30 files · +3491 -5
M .github/dependabot.yml +4 -0
| @@ -4,3 +4,7 @@ updates: | |||
| 4 | 4 | directory: / | |
| 5 | 5 | schedule: | |
| 6 | 6 | interval: weekly | |
| 7 | + | - package-ecosystem: npm | |
| 8 | + | directory: / | |
| 9 | + | schedule: | |
| 10 | + | interval: weekly | |
| @@ -4,3 +4,7 @@ updates: | |||||
| 4 | directory: / | 4 | directory: / | ||
| 5 | schedule: | 5 | schedule: | ||
| 6 | interval: weekly | 6 | interval: weekly | ||
| 7 | + | - package-ecosystem: npm | |||
| 8 | + | directory: / | |||
| 9 | + | schedule: | |||
| 10 | + | interval: weekly | |||
M .github/workflows/ci.yml +56 -5
| @@ -5,15 +5,25 @@ on: | |||
| 5 | 5 | branches: [main] | |
| 6 | 6 | paths: | |
| 7 | 7 | - '**.go' | |
| 8 | + | - '**.js' | |
| 9 | + | - '**.css' | |
| 8 | 10 | - 'go.mod' | |
| 9 | 11 | - 'go.sum' | |
| 12 | + | - 'package.json' | |
| 13 | + | - 'package-lock.json' | |
| 14 | + | - 'internal/htmlharness/**' | |
| 10 | 15 | - '.golangci.yml' | |
| 11 | 16 | - '.github/workflows/**' | |
| 12 | 17 | pull_request: | |
| 13 | 18 | paths: | |
| 14 | 19 | - '**.go' | |
| 20 | + | - '**.js' | |
| 21 | + | - '**.css' | |
| 15 | 22 | - 'go.mod' | |
| 16 | 23 | - 'go.sum' | |
| 24 | + | - 'package.json' | |
| 25 | + | - 'package-lock.json' | |
| 26 | + | - 'internal/htmlharness/**' | |
| 17 | 27 | - '.golangci.yml' | |
| 18 | 28 | - '.github/workflows/**' | |
| 19 | 29 | ||
| @@ -22,17 +32,43 @@ permissions: | |||
| 22 | 32 | ||
| 23 | 33 | jobs: | |
| 24 | 34 | ci: | |
| 25 | - | uses: bnema/gh-actions/.github/workflows/go-ci.yml@main | |
| 35 | + | permissions: | |
| 36 | + | contents: read | |
| 37 | + | pull-requests: read | |
| 38 | + | uses: bnema/gh-actions/.github/workflows/go-ci.yml@bdc22c9d0e0527bd1c369d4304034cdf2cde9ade # main | |
| 26 | 39 | with: | |
| 27 | 40 | go-version: stable | |
| 28 | 41 | test-flags: '-race -v ./...' | |
| 29 | 42 | ||
| 43 | + | browser: | |
| 44 | + | runs-on: ubuntu-latest | |
| 45 | + | timeout-minutes: 20 | |
| 46 | + | steps: | |
| 47 | + | - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| 48 | + | with: | |
| 49 | + | persist-credentials: false | |
| 50 | + | - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 | |
| 51 | + | with: | |
| 52 | + | node-version: '24' | |
| 53 | + | cache: npm | |
| 54 | + | - run: npm ci | |
| 55 | + | - run: npx playwright install --with-deps chromium firefox webkit | |
| 56 | + | - run: npm run test:browser | |
| 57 | + | - if: always() | |
| 58 | + | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 | |
| 59 | + | with: | |
| 60 | + | name: browser-evidence | |
| 61 | + | path: test-results/ | |
| 62 | + | retention-days: 14 | |
| 63 | + | ||
| 30 | 64 | quality: | |
| 31 | - | needs: ci | |
| 65 | + | needs: [ci, browser] | |
| 32 | 66 | runs-on: ubuntu-latest | |
| 33 | 67 | steps: | |
| 34 | - | - uses: actions/checkout@v7 | |
| 35 | - | - uses: actions/setup-go@v7 | |
| 68 | + | - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| 69 | + | with: | |
| 70 | + | persist-credentials: false | |
| 71 | + | - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 | |
| 36 | 72 | with: | |
| 37 | 73 | go-version: stable | |
| 38 | 74 | - name: Check production import direction | |
| @@ -46,13 +82,28 @@ jobs: | |||
| 46 | 82 | echo 'the ANSI package must consume the core model' | |
| 47 | 83 | exit 1 | |
| 48 | 84 | fi | |
| 85 | + | if ! go list -f '{{join .Imports "\n"}}' ./html | grep -q '/core'; then | |
| 86 | + | echo 'the HTML package must consume the core model' | |
| 87 | + | exit 1 | |
| 88 | + | fi | |
| 89 | + | if ! go list -f '{{join .Imports "\n"}}' ./html/browser | grep -q '/html'; then | |
| 90 | + | echo 'the browser adapter must consume the HTML package' | |
| 91 | + | exit 1 | |
| 92 | + | fi | |
| 93 | + | forbidden="$(go list -deps ./html/... | grep -E '^(github\.com/bnema/vev-vt$|github\.com/bnema/vev($|/)|net/http$|.*/websocket($|/))' || true)" | |
| 94 | + | if [ -n "$forbidden" ]; then | |
| 95 | + | echo "$forbidden" | |
| 96 | + | exit 1 | |
| 97 | + | fi | |
| 49 | 98 | - name: VT fuzz smoke | |
| 50 | 99 | run: go test . -run '^$' -fuzz FuzzVTInputDoesNotPanic -fuzztime=10s | |
| 51 | 100 | - name: ANSI fuzz smoke | |
| 52 | 101 | run: go test . -run '^$' -fuzz FuzzANSIRenderingDoesNotPanic -fuzztime=10s | |
| 53 | 102 | - name: VTH3 fuzz smoke | |
| 54 | 103 | run: go test . -run '^$' -fuzz FuzzHistoryPreflightMatchesUnmarshal -fuzztime=10s | |
| 104 | + | - name: HTML event fuzz smoke | |
| 105 | + | run: go test ./html/browser -run '^$' -fuzz FuzzDecodeEvent -fuzztime=10s | |
| 55 | 106 | - name: Benchmark smoke | |
| 56 | - | run: go test ./... -run '^$' -bench='(VT|History|Snapshot|ANSI)' -benchmem -benchtime=1x | |
| 107 | + | run: go test ./... -run '^$' -bench='(VT|History|Snapshot|ANSI|Renderer)' -benchmem -benchtime=1x | |
| 57 | 108 | - name: External consumer smoke | |
| 58 | 109 | run: scripts/consumer-smoke.sh | |
| @@ -5,15 +5,25 @@ on: | |||||
| 5 | branches: [main] | 5 | branches: [main] | ||
| 6 | paths: | 6 | paths: | ||
| 7 | - '**.go' | 7 | - '**.go' | ||
| 8 | + | - '**.js' | |||
| 9 | + | - '**.css' | |||
| 8 | - 'go.mod' | 10 | - 'go.mod' | ||
| 9 | - 'go.sum' | 11 | - 'go.sum' | ||
| 12 | + | - 'package.json' | |||
| 13 | + | - 'package-lock.json' | |||
| 14 | + | - 'internal/htmlharness/**' | |||
| 10 | - '.golangci.yml' | 15 | - '.golangci.yml' | ||
| 11 | - '.github/workflows/**' | 16 | - '.github/workflows/**' | ||
| 12 | pull_request: | 17 | pull_request: | ||
| 13 | paths: | 18 | paths: | ||
| 14 | - '**.go' | 19 | - '**.go' | ||
| 20 | + | - '**.js' | |||
| 21 | + | - '**.css' | |||
| 15 | - 'go.mod' | 22 | - 'go.mod' | ||
| 16 | - 'go.sum' | 23 | - 'go.sum' | ||
| 24 | + | - 'package.json' | |||
| 25 | + | - 'package-lock.json' | |||
| 26 | + | - 'internal/htmlharness/**' | |||
| 17 | - '.golangci.yml' | 27 | - '.golangci.yml' | ||
| 18 | - '.github/workflows/**' | 28 | - '.github/workflows/**' | ||
| 19 | 29 | ||||
| @@ -22,17 +32,43 @@ permissions: | |||||
| 22 | 32 | ||||
| 23 | jobs: | 33 | jobs: | ||
| 24 | ci: | 34 | ci: | ||
| 25 | - | uses: bnema/gh-actions/.github/workflows/go-ci.yml@main | 35 | + | permissions: |
| 36 | + | contents: read | |||
| 37 | + | pull-requests: read | |||
| 38 | + | uses: bnema/gh-actions/.github/workflows/go-ci.yml@bdc22c9d0e0527bd1c369d4304034cdf2cde9ade # main | |||
| 26 | with: | 39 | with: | ||
| 27 | go-version: stable | 40 | go-version: stable | ||
| 28 | test-flags: '-race -v ./...' | 41 | test-flags: '-race -v ./...' | ||
| 29 | 42 | ||||
| 43 | + | browser: | |||
| 44 | + | runs-on: ubuntu-latest | |||
| 45 | + | timeout-minutes: 20 | |||
| 46 | + | steps: | |||
| 47 | + | - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |||
| 48 | + | with: | |||
| 49 | + | persist-credentials: false | |||
| 50 | + | - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 | |||
| 51 | + | with: | |||
| 52 | + | node-version: '24' | |||
| 53 | + | cache: npm | |||
| 54 | + | - run: npm ci | |||
| 55 | + | - run: npx playwright install --with-deps chromium firefox webkit | |||
| 56 | + | - run: npm run test:browser | |||
| 57 | + | - if: always() | |||
| 58 | + | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 | |||
| 59 | + | with: | |||
| 60 | + | name: browser-evidence | |||
| 61 | + | path: test-results/ | |||
| 62 | + | retention-days: 14 | |||
| 63 | + | ||||
| 30 | quality: | 64 | quality: | ||
| 31 | - | needs: ci | 65 | + | needs: [ci, browser] |
| 32 | runs-on: ubuntu-latest | 66 | runs-on: ubuntu-latest | ||
| 33 | steps: | 67 | steps: | ||
| 34 | - | - uses: actions/checkout@v7 | 68 | + | - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 |
| 35 | - | - uses: actions/setup-go@v7 | 69 | + | with: |
| 70 | + | persist-credentials: false | |||
| 71 | + | - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 | |||
| 36 | with: | 72 | with: | ||
| 37 | go-version: stable | 73 | go-version: stable | ||
| 38 | - name: Check production import direction | 74 | - name: Check production import direction | ||
| @@ -46,13 +82,28 @@ jobs: | |||||
| 46 | echo 'the ANSI package must consume the core model' | 82 | echo 'the ANSI package must consume the core model' | ||
| 47 | exit 1 | 83 | exit 1 | ||
| 48 | fi | 84 | fi | ||
| 85 | + | if ! go list -f '{{join .Imports "\n"}}' ./html | grep -q '/core'; then | |||
| 86 | + | echo 'the HTML package must consume the core model' | |||
| 87 | + | exit 1 | |||
| 88 | + | fi | |||
| 89 | + | if ! go list -f '{{join .Imports "\n"}}' ./html/browser | grep -q '/html'; then | |||
| 90 | + | echo 'the browser adapter must consume the HTML package' | |||
| 91 | + | exit 1 | |||
| 92 | + | fi | |||
| 93 | + | forbidden="$(go list -deps ./html/... | grep -E '^(github\.com/bnema/vev-vt$|github\.com/bnema/vev($|/)|net/http$|.*/websocket($|/))' || true)" | |||
| 94 | + | if [ -n "$forbidden" ]; then | |||
| 95 | + | echo "$forbidden" | |||
| 96 | + | exit 1 | |||
| 97 | + | fi | |||
| 49 | - name: VT fuzz smoke | 98 | - name: VT fuzz smoke | ||
| 50 | run: go test . -run '^$' -fuzz FuzzVTInputDoesNotPanic -fuzztime=10s | 99 | run: go test . -run '^$' -fuzz FuzzVTInputDoesNotPanic -fuzztime=10s | ||
| 51 | - name: ANSI fuzz smoke | 100 | - name: ANSI fuzz smoke | ||
| 52 | run: go test . -run '^$' -fuzz FuzzANSIRenderingDoesNotPanic -fuzztime=10s | 101 | run: go test . -run '^$' -fuzz FuzzANSIRenderingDoesNotPanic -fuzztime=10s | ||
| 53 | - name: VTH3 fuzz smoke | 102 | - name: VTH3 fuzz smoke | ||
| 54 | run: go test . -run '^$' -fuzz FuzzHistoryPreflightMatchesUnmarshal -fuzztime=10s | 103 | run: go test . -run '^$' -fuzz FuzzHistoryPreflightMatchesUnmarshal -fuzztime=10s | ||
| 104 | + | - name: HTML event fuzz smoke | |||
| 105 | + | run: go test ./html/browser -run '^$' -fuzz FuzzDecodeEvent -fuzztime=10s | |||
| 55 | - name: Benchmark smoke | 106 | - name: Benchmark smoke | ||
| 56 | - | run: go test ./... -run '^$' -bench='(VT|History|Snapshot|ANSI)' -benchmem -benchtime=1x | 107 | + | run: go test ./... -run '^$' -bench='(VT|History|Snapshot|ANSI|Renderer)' -benchmem -benchtime=1x |
| 57 | - name: External consumer smoke | 108 | - name: External consumer smoke | ||
| 58 | run: scripts/consumer-smoke.sh | 109 | run: scripts/consumer-smoke.sh | ||
M .gitignore +3 -0
| @@ -2,6 +2,9 @@ | |||
| 2 | 2 | /vev-vt | |
| 3 | 3 | /coverage.out | |
| 4 | 4 | /testdata/generated/ | |
| 5 | + | /node_modules/ | |
| 6 | + | /playwright-report/ | |
| 7 | + | /test-results/ | |
| 5 | 8 | ||
| 6 | 9 | # Local agent review notes | |
| 7 | 10 | /.agent-reviews/ | |
| @@ -2,6 +2,9 @@ | |||||
| 2 | /vev-vt | 2 | /vev-vt | ||
| 3 | /coverage.out | 3 | /coverage.out | ||
| 4 | /testdata/generated/ | 4 | /testdata/generated/ | ||
| 5 | + | /node_modules/ | |||
| 6 | + | /playwright-report/ | |||
| 7 | + | /test-results/ | |||
| 5 | 8 | ||||
| 6 | # Local agent review notes | 9 | # Local agent review notes | ||
| 7 | /.agent-reviews/ | 10 | /.agent-reviews/ | ||
M README.md +61 -0
| @@ -6,6 +6,8 @@ on screen. It powers [vev](https://github.com/bnema/vev). | |||
| 6 | 6 | Feed it the bytes from a shell or command. It handles text, colors, cursor | |
| 7 | 7 | movement, scrolling and resizing. You can then read the screen, save its history, | |
| 8 | 8 | or use the `ansi` package to draw it in a terminal. | |
| 9 | + | The `html` package prepares typed browser updates, and `html/browser` provides a | |
| 10 | + | safe interactive DOM adapter without owning transport or terminal-input policy. | |
| 9 | 11 | ||
| 10 | 12 | This library does **not** start processes or manage a PTY. Your application does | |
| 11 | 13 | that and passes the output to vev-vt. | |
| @@ -87,16 +89,75 @@ optional compression during idle time. | |||
| 87 | 89 | | `vev-vt` | Parse terminal output, read the screen and manage history. | | |
| 88 | 90 | | `vev-vt/core` | Work with cells, styles and writable grids. | | |
| 89 | 91 | | `vev-vt/ansi` | Render a screen or grid as ANSI terminal output. | | |
| 92 | + | | `vev-vt/html` | Prepare transactional browser updates, structural CSS and terminal themes. | | |
| 93 | + | | `vev-vt/html/browser` | Embed the DOM runtime and decode neutral browser events. | | |
| 90 | 94 | | `vev-vt/graphics` | Read supported terminal images and their positions. | | |
| 91 | 95 | ||
| 92 | 96 | [Supported image features →](docs/graphics.md) | |
| 93 | 97 | ||
| 98 | + | ## HTML frontend | |
| 99 | + | ||
| 100 | + | The HTML renderer compares every row with its committed shadow; damage values are | |
| 101 | + | non-authoritative hints. `Prepare` permits one outstanding draw and requires an | |
| 102 | + | explicit `Commit` or `Abort`. `Reset` invalidates retained prepared draws. | |
| 103 | + | Updates are immutable, schema-versioned JSON-compatible values. Complete-row | |
| 104 | + | replacement preserves wide-cell atomicity, and scroll damage uses a safe | |
| 105 | + | snapshot fallback. `html.DefaultLimits()` documents the default 1,000,000-cell, | |
| 106 | + | 10,000-row, 64 MiB generated-update, and 65,536-style bounds. | |
| 107 | + | ||
| 108 | + | The browser adapter builds DOM nodes with `textContent` and fixed classes. It | |
| 109 | + | provides a labeled input proxy, synchronized plain-text accessible output, | |
| 110 | + | typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and | |
| 111 | + | focus events. A synchronous consumer callback decides default prevention. | |
| 112 | + | Consumers remain responsible for transport and mapping events to terminal bytes | |
| 113 | + | or application actions. Clipboard text is preserved unchanged, including | |
| 114 | + | control bytes, so consumers forwarding paste events to a PTY must apply their | |
| 115 | + | required framing or filtering policy. `browser.DefaultEventLimits()` documents | |
| 116 | + | the default 8 MiB event, 64 KiB text, 1 MiB paste, and 10,000×10,000 geometry | |
| 117 | + | bounds. | |
| 118 | + | ||
| 119 | + | ```go | |
| 120 | + | renderer, err := html.New(html.Options{}) | |
| 121 | + | if err != nil { | |
| 122 | + | return err | |
| 123 | + | } | |
| 124 | + | prepared, err := renderer.Prepare(frame, damage, reset, html.Cursor{ | |
| 125 | + | Row: cursorRow, Column: cursorColumn, Visible: true, | |
| 126 | + | }) | |
| 127 | + | if err != nil { | |
| 128 | + | return err | |
| 129 | + | } | |
| 130 | + | if err := send(prepared.JSON()); err != nil { | |
| 131 | + | _ = prepared.Abort() | |
| 132 | + | return err | |
| 133 | + | } | |
| 134 | + | return prepared.Commit() | |
| 135 | + | ``` | |
| 136 | + | ||
| 137 | + | Embed or serve `html.Stylesheet()` and `browser.JavaScript()` from a | |
| 138 | + | consumer-owned application. The runtime supports a self-only `style-src` and | |
| 139 | + | `script-src` CSP in the pinned browser harness; dynamic colors are set through | |
| 140 | + | validated numeric DOM style properties. The current core model drops combining | |
| 141 | + | marks and does not coalesce ZWJ sequences, so rendered output inherits those | |
| 142 | + | limits even though browser IME input remains composition-aware. | |
| 143 | + | ||
| 94 | 144 | ## Development | |
| 95 | 145 | ||
| 96 | 146 | ```sh | |
| 97 | 147 | go test ./... | |
| 98 | 148 | go test ./... -race | |
| 99 | 149 | go vet ./... | |
| 150 | + | npm ci | |
| 151 | + | npm run test:browser:install | |
| 152 | + | npm run test:browser | |
| 153 | + | # Reproducible three-engine fallback on unsupported Linux hosts: | |
| 154 | + | npm run test:browser:docker | |
| 100 | 155 | ``` | |
| 101 | 156 | ||
| 157 | + | Playwright 1.62.1 and its Chromium, Firefox, and WebKit revisions are pinned by | |
| 158 | + | `package-lock.json`. The matching Playwright container provides the reproducible | |
| 159 | + | fallback when host libraries cannot run one of those browser builds. Production | |
| 160 | + | Go packages retain the module's standard-library-only dependency boundary apart | |
| 161 | + | from `vev-vt/core`. | |
| 162 | + | ||
| 102 | 163 | [Storage benchmarks and design decisions →](docs/storage-optimization.md) | |
| @@ -6,6 +6,8 @@ on screen. It powers [vev](https://github.com/bnema/vev). | |||||
| 6 | Feed it the bytes from a shell or command. It handles text, colors, cursor | 6 | Feed it the bytes from a shell or command. It handles text, colors, cursor | ||
| 7 | movement, scrolling and resizing. You can then read the screen, save its history, | 7 | movement, scrolling and resizing. You can then read the screen, save its history, | ||
| 8 | or use the `ansi` package to draw it in a terminal. | 8 | or use the `ansi` package to draw it in a terminal. | ||
| 9 | + | The `html` package prepares typed browser updates, and `html/browser` provides a | |||
| 10 | + | safe interactive DOM adapter without owning transport or terminal-input policy. | |||
| 9 | 11 | ||||
| 10 | This library does **not** start processes or manage a PTY. Your application does | 12 | This library does **not** start processes or manage a PTY. Your application does | ||
| 11 | that and passes the output to vev-vt. | 13 | that and passes the output to vev-vt. | ||
| @@ -87,16 +89,75 @@ optional compression during idle time. | |||||
| 87 | | `vev-vt` | Parse terminal output, read the screen and manage history. | | 89 | | `vev-vt` | Parse terminal output, read the screen and manage history. | | ||
| 88 | | `vev-vt/core` | Work with cells, styles and writable grids. | | 90 | | `vev-vt/core` | Work with cells, styles and writable grids. | | ||
| 89 | | `vev-vt/ansi` | Render a screen or grid as ANSI terminal output. | | 91 | | `vev-vt/ansi` | Render a screen or grid as ANSI terminal output. | | ||
| 92 | + | | `vev-vt/html` | Prepare transactional browser updates, structural CSS and terminal themes. | | |||
| 93 | + | | `vev-vt/html/browser` | Embed the DOM runtime and decode neutral browser events. | | |||
| 90 | | `vev-vt/graphics` | Read supported terminal images and their positions. | | 94 | | `vev-vt/graphics` | Read supported terminal images and their positions. | | ||
| 91 | 95 | ||||
| 92 | [Supported image features →](docs/graphics.md) | 96 | [Supported image features →](docs/graphics.md) | ||
| 93 | 97 | ||||
| 98 | + | ## HTML frontend | |||
| 99 | + | ||||
| 100 | + | The HTML renderer compares every row with its committed shadow; damage values are | |||
| 101 | + | non-authoritative hints. `Prepare` permits one outstanding draw and requires an | |||
| 102 | + | explicit `Commit` or `Abort`. `Reset` invalidates retained prepared draws. | |||
| 103 | + | Updates are immutable, schema-versioned JSON-compatible values. Complete-row | |||
| 104 | + | replacement preserves wide-cell atomicity, and scroll damage uses a safe | |||
| 105 | + | snapshot fallback. `html.DefaultLimits()` documents the default 1,000,000-cell, | |||
| 106 | + | 10,000-row, 64 MiB generated-update, and 65,536-style bounds. | |||
| 107 | + | ||||
| 108 | + | The browser adapter builds DOM nodes with `textContent` and fixed classes. It | |||
| 109 | + | provides a labeled input proxy, synchronized plain-text accessible output, | |||
| 110 | + | typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and | |||
| 111 | + | focus events. A synchronous consumer callback decides default prevention. | |||
| 112 | + | Consumers remain responsible for transport and mapping events to terminal bytes | |||
| 113 | + | or application actions. Clipboard text is preserved unchanged, including | |||
| 114 | + | control bytes, so consumers forwarding paste events to a PTY must apply their | |||
| 115 | + | required framing or filtering policy. `browser.DefaultEventLimits()` documents | |||
| 116 | + | the default 8 MiB event, 64 KiB text, 1 MiB paste, and 10,000×10,000 geometry | |||
| 117 | + | bounds. | |||
| 118 | + | ||||
| 119 | + | ```go | |||
| 120 | + | renderer, err := html.New(html.Options{}) | |||
| 121 | + | if err != nil { | |||
| 122 | + | return err | |||
| 123 | + | } | |||
| 124 | + | prepared, err := renderer.Prepare(frame, damage, reset, html.Cursor{ | |||
| 125 | + | Row: cursorRow, Column: cursorColumn, Visible: true, | |||
| 126 | + | }) | |||
| 127 | + | if err != nil { | |||
| 128 | + | return err | |||
| 129 | + | } | |||
| 130 | + | if err := send(prepared.JSON()); err != nil { | |||
| 131 | + | _ = prepared.Abort() | |||
| 132 | + | return err | |||
| 133 | + | } | |||
| 134 | + | return prepared.Commit() | |||
| 135 | + | ``` | |||
| 136 | + | ||||
| 137 | + | Embed or serve `html.Stylesheet()` and `browser.JavaScript()` from a | |||
| 138 | + | consumer-owned application. The runtime supports a self-only `style-src` and | |||
| 139 | + | `script-src` CSP in the pinned browser harness; dynamic colors are set through | |||
| 140 | + | validated numeric DOM style properties. The current core model drops combining | |||
| 141 | + | marks and does not coalesce ZWJ sequences, so rendered output inherits those | |||
| 142 | + | limits even though browser IME input remains composition-aware. | |||
| 143 | + | ||||
| 94 | ## Development | 144 | ## Development | ||
| 95 | 145 | ||||
| 96 | ```sh | 146 | ```sh | ||
| 97 | go test ./... | 147 | go test ./... | ||
| 98 | go test ./... -race | 148 | go test ./... -race | ||
| 99 | go vet ./... | 149 | go vet ./... | ||
| 150 | + | npm ci | |||
| 151 | + | npm run test:browser:install | |||
| 152 | + | npm run test:browser | |||
| 153 | + | # Reproducible three-engine fallback on unsupported Linux hosts: | |||
| 154 | + | npm run test:browser:docker | |||
| 100 | ``` | 155 | ``` | ||
| 101 | 156 | ||||
| 157 | + | Playwright 1.62.1 and its Chromium, Firefox, and WebKit revisions are pinned by | |||
| 158 | + | `package-lock.json`. The matching Playwright container provides the reproducible | |||
| 159 | + | fallback when host libraries cannot run one of those browser builds. Production | |||
| 160 | + | Go packages retain the module's standard-library-only dependency boundary apart | |||
| 161 | + | from `vev-vt/core`. | |||
| 162 | + | ||||
| 102 | [Storage benchmarks and design decisions →](docs/storage-optimization.md) | 163 | [Storage benchmarks and design decisions →](docs/storage-optimization.md) | ||
A html/assets.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import _ "embed" | |
| 4 | + | ||
| 5 | + | //go:embed terminal.css | |
| 6 | + | var stylesheet string | |
| 7 | + | ||
| 8 | + | // Stylesheet returns the static structural terminal CSS. | |
| 9 | + | func Stylesheet() string { return stylesheet } | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import _ "embed" | |||
| 4 | + | ||||
| 5 | + | //go:embed terminal.css | |||
| 6 | + | var stylesheet string | |||
| 7 | + | ||||
| 8 | + | // Stylesheet returns the static structural terminal CSS. | |||
| 9 | + | func Stylesheet() string { return stylesheet } | |||
A html/benchmark_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev-vt/core" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func BenchmarkRenderer(b *testing.B) { | |
| 11 | + | for _, size := range []struct{ width, height int }{{80, 24}, {120, 40}, {240, 80}} { | |
| 12 | + | frame := core.NewFrame(size.width, size.height) | |
| 13 | + | for y := range size.height { | |
| 14 | + | for x := range size.width { | |
| 15 | + | frame.Set(x, y, core.Cell{Rune: rune('A' + (x+y)%26), Style: core.DefaultStyle()}) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | b.Run(fmt.Sprintf("snapshot-%dx%d", size.width, size.height), func(b *testing.B) { | |
| 19 | + | b.ReportAllocs() | |
| 20 | + | for range b.N { | |
| 21 | + | renderer, err := New(Options{}) | |
| 22 | + | if err != nil { | |
| 23 | + | b.Fatal(err) | |
| 24 | + | } | |
| 25 | + | prepared, err := renderer.Prepare(frame, nil, true, Cursor{}) | |
| 26 | + | if err != nil { | |
| 27 | + | b.Fatal(err) | |
| 28 | + | } | |
| 29 | + | if err := prepared.Commit(); err != nil { | |
| 30 | + | b.Fatal(err) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | b.Run(fmt.Sprintf("one-row-%dx%d", size.width, size.height), func(b *testing.B) { | |
| 35 | + | renderer, err := New(Options{}) | |
| 36 | + | if err != nil { | |
| 37 | + | b.Fatal(err) | |
| 38 | + | } | |
| 39 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 40 | + | if err != nil { | |
| 41 | + | b.Fatal(err) | |
| 42 | + | } | |
| 43 | + | if err := first.Commit(); err != nil { | |
| 44 | + | b.Fatal(err) | |
| 45 | + | } | |
| 46 | + | left := frame.Clone() | |
| 47 | + | right := frame.Clone() | |
| 48 | + | right.Set(0, size.height/2, core.Cell{Rune: 'Z', Style: core.DefaultStyle()}) | |
| 49 | + | b.ReportAllocs() | |
| 50 | + | b.ResetTimer() | |
| 51 | + | for index := range b.N { | |
| 52 | + | candidate := left | |
| 53 | + | if index%2 == 0 { | |
| 54 | + | candidate = right | |
| 55 | + | } | |
| 56 | + | prepared, err := renderer.Prepare(candidate, nil, false, Cursor{}) | |
| 57 | + | if err != nil { | |
| 58 | + | b.Fatal(err) | |
| 59 | + | } | |
| 60 | + | if err := prepared.Commit(); err != nil { | |
| 61 | + | b.Fatal(err) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | } | |
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev-vt/core" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func BenchmarkRenderer(b *testing.B) { | |||
| 11 | + | for _, size := range []struct{ width, height int }{{80, 24}, {120, 40}, {240, 80}} { | |||
| 12 | + | frame := core.NewFrame(size.width, size.height) | |||
| 13 | + | for y := range size.height { | |||
| 14 | + | for x := range size.width { | |||
| 15 | + | frame.Set(x, y, core.Cell{Rune: rune('A' + (x+y)%26), Style: core.DefaultStyle()}) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | b.Run(fmt.Sprintf("snapshot-%dx%d", size.width, size.height), func(b *testing.B) { | |||
| 19 | + | b.ReportAllocs() | |||
| 20 | + | for range b.N { | |||
| 21 | + | renderer, err := New(Options{}) | |||
| 22 | + | if err != nil { | |||
| 23 | + | b.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | prepared, err := renderer.Prepare(frame, nil, true, Cursor{}) | |||
| 26 | + | if err != nil { | |||
| 27 | + | b.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | if err := prepared.Commit(); err != nil { | |||
| 30 | + | b.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | }) | |||
| 34 | + | b.Run(fmt.Sprintf("one-row-%dx%d", size.width, size.height), func(b *testing.B) { | |||
| 35 | + | renderer, err := New(Options{}) | |||
| 36 | + | if err != nil { | |||
| 37 | + | b.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 40 | + | if err != nil { | |||
| 41 | + | b.Fatal(err) | |||
| 42 | + | } | |||
| 43 | + | if err := first.Commit(); err != nil { | |||
| 44 | + | b.Fatal(err) | |||
| 45 | + | } | |||
| 46 | + | left := frame.Clone() | |||
| 47 | + | right := frame.Clone() | |||
| 48 | + | right.Set(0, size.height/2, core.Cell{Rune: 'Z', Style: core.DefaultStyle()}) | |||
| 49 | + | b.ReportAllocs() | |||
| 50 | + | b.ResetTimer() | |||
| 51 | + | for index := range b.N { | |||
| 52 | + | candidate := left | |||
| 53 | + | if index%2 == 0 { | |||
| 54 | + | candidate = right | |||
| 55 | + | } | |||
| 56 | + | prepared, err := renderer.Prepare(candidate, nil, false, Cursor{}) | |||
| 57 | + | if err != nil { | |||
| 58 | + | b.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | if err := prepared.Commit(); err != nil { | |||
| 61 | + | b.Fatal(err) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | } | |||
A html/browser/assets.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | package browser | |
| 2 | + | ||
| 3 | + | import _ "embed" | |
| 4 | + | ||
| 5 | + | //go:embed terminal.js | |
| 6 | + | var javascript string | |
| 7 | + | ||
| 8 | + | // JavaScript returns the static dependency-free browser runtime. | |
| 9 | + | func JavaScript() string { return javascript } | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | package browser | |||
| 2 | + | ||||
| 3 | + | import _ "embed" | |||
| 4 | + | ||||
| 5 | + | //go:embed terminal.js | |||
| 6 | + | var javascript string | |||
| 7 | + | ||||
| 8 | + | // JavaScript returns the static dependency-free browser runtime. | |||
| 9 | + | func JavaScript() string { return javascript } | |||
A html/browser/consumer_external_test.go +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | package browser_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev-vt/html/browser" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestExternalBrowserConsumerUsesEmbeddedRuntimeAndNeutralEvents(t *testing.T) { | |
| 10 | + | if browser.JavaScript() == "" { | |
| 11 | + | t.Fatal("embedded browser runtime is empty") | |
| 12 | + | } | |
| 13 | + | event, err := browser.DecodeEvent([]byte(`{"schemaVersion":1,"type":"focus","focused":true}`), browser.EventLimits{}) | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | if event.Focus == nil || !event.Focus.Focused { | |
| 18 | + | t.Fatalf("decoded event = %#v", event) | |
| 19 | + | } | |
| 20 | + | } | |
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | package browser_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev-vt/html/browser" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestExternalBrowserConsumerUsesEmbeddedRuntimeAndNeutralEvents(t *testing.T) { | |||
| 10 | + | if browser.JavaScript() == "" { | |||
| 11 | + | t.Fatal("embedded browser runtime is empty") | |||
| 12 | + | } | |||
| 13 | + | event, err := browser.DecodeEvent([]byte(`{"schemaVersion":1,"type":"focus","focused":true}`), browser.EventLimits{}) | |||
| 14 | + | if err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | if event.Focus == nil || !event.Focus.Focused { | |||
| 18 | + | t.Fatalf("decoded event = %#v", event) | |||
| 19 | + | } | |||
| 20 | + | } | |||
A html/browser/event.go +377 -0
| @@ -0,0 +1,377 @@ | |||
| 1 | + | // Package browser provides the static DOM runtime and strictly validated, | |
| 2 | + | // transport-neutral browser event values for github.com/bnema/vev-vt/html. | |
| 3 | + | package browser | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "math" | |
| 12 | + | "unicode/utf8" | |
| 13 | + | ||
| 14 | + | htmlrenderer "github.com/bnema/vev-vt/html" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // EventSchemaVersion identifies the runtime-to-Go event contract. | |
| 18 | + | const EventSchemaVersion = htmlrenderer.UpdateSchemaVersion | |
| 19 | + | ||
| 20 | + | var ( | |
| 21 | + | // ErrEventLimit reports that an event exceeded a configured resource bound. | |
| 22 | + | ErrEventLimit = errors.New("html/browser: event resource limit exceeded") | |
| 23 | + | // ErrInvalidEventLimits reports incompatible configured event bounds. | |
| 24 | + | ErrInvalidEventLimits = errors.New("html/browser: invalid event limits") | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | const ( | |
| 28 | + | maxJSONEscapedByteLength = 6 | |
| 29 | + | eventEnvelopeOverhead = 64 | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // EventLimits bounds untrusted browser event decoding. Zero fields select safe | |
| 33 | + | // defaults. | |
| 34 | + | type EventLimits struct { | |
| 35 | + | MaxEventBytes int | |
| 36 | + | MaxTextBytes int | |
| 37 | + | MaxPasteBytes int | |
| 38 | + | MaxColumns int | |
| 39 | + | MaxRows int | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // DefaultEventLimits returns the bounds used for zero-valued event limits. | |
| 43 | + | func DefaultEventLimits() EventLimits { | |
| 44 | + | return EventLimits{ | |
| 45 | + | MaxEventBytes: 8 << 20, | |
| 46 | + | MaxTextBytes: 64 << 10, | |
| 47 | + | MaxPasteBytes: 1 << 20, | |
| 48 | + | MaxColumns: 10_000, | |
| 49 | + | MaxRows: 10_000, | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func normalizeEventLimits(limits EventLimits) (EventLimits, error) { | |
| 54 | + | if limits.MaxEventBytes < 0 || limits.MaxTextBytes < 0 || limits.MaxPasteBytes < 0 || limits.MaxColumns < 0 || limits.MaxRows < 0 { | |
| 55 | + | return EventLimits{}, fmt.Errorf("%w: event limits must not be negative", ErrInvalidEventLimits) | |
| 56 | + | } | |
| 57 | + | defaults := DefaultEventLimits() | |
| 58 | + | if limits.MaxEventBytes == 0 { | |
| 59 | + | limits.MaxEventBytes = defaults.MaxEventBytes | |
| 60 | + | } | |
| 61 | + | if limits.MaxTextBytes == 0 { | |
| 62 | + | limits.MaxTextBytes = defaults.MaxTextBytes | |
| 63 | + | } | |
| 64 | + | if limits.MaxPasteBytes == 0 { | |
| 65 | + | limits.MaxPasteBytes = defaults.MaxPasteBytes | |
| 66 | + | } | |
| 67 | + | if limits.MaxColumns == 0 { | |
| 68 | + | limits.MaxColumns = defaults.MaxColumns | |
| 69 | + | } | |
| 70 | + | if limits.MaxRows == 0 { | |
| 71 | + | limits.MaxRows = defaults.MaxRows | |
| 72 | + | } | |
| 73 | + | requiredEventBytes, representable := minimumEventBytes(max(limits.MaxTextBytes, limits.MaxPasteBytes)) | |
| 74 | + | if !representable || limits.MaxEventBytes < requiredEventBytes { | |
| 75 | + | return EventLimits{}, fmt.Errorf("%w: MaxEventBytes %d cannot contain the largest payload limit", ErrInvalidEventLimits, limits.MaxEventBytes) | |
| 76 | + | } | |
| 77 | + | return limits, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func minimumEventBytes(payloadBytes int) (int, bool) { | |
| 81 | + | maxInt := int(^uint(0) >> 1) | |
| 82 | + | if payloadBytes > (maxInt-eventEnvelopeOverhead)/maxJSONEscapedByteLength { | |
| 83 | + | return 0, false | |
| 84 | + | } | |
| 85 | + | return payloadBytes*maxJSONEscapedByteLength + eventEnvelopeOverhead, true | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // EventKind identifies one closed browser event payload. | |
| 89 | + | type EventKind string | |
| 90 | + | ||
| 91 | + | // Supported browser event kinds. | |
| 92 | + | const ( | |
| 93 | + | EventText EventKind = "text" | |
| 94 | + | EventKey EventKind = "key" | |
| 95 | + | EventPaste EventKind = "paste" | |
| 96 | + | EventPointer EventKind = "pointer" | |
| 97 | + | EventWheel EventKind = "wheel" | |
| 98 | + | EventResize EventKind = "resize" | |
| 99 | + | EventFocus EventKind = "focus" | |
| 100 | + | ) | |
| 101 | + | ||
| 102 | + | // Modifiers is the normalized browser modifier-key state. | |
| 103 | + | type Modifiers struct { | |
| 104 | + | Alt bool `json:"alt"` | |
| 105 | + | Ctrl bool `json:"ctrl"` | |
| 106 | + | Meta bool `json:"meta"` | |
| 107 | + | Shift bool `json:"shift"` | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // TextEvent carries composed text input. | |
| 111 | + | type TextEvent struct{ Text string } | |
| 112 | + | ||
| 113 | + | // PasteEvent carries bounded plain-text clipboard input. DecodeEvent preserves | |
| 114 | + | // Text unchanged, including control bytes such as ESC and CR. Consumers that | |
| 115 | + | // forward it to a PTY must apply their required framing or filtering policy. | |
| 116 | + | type PasteEvent struct{ Text string } | |
| 117 | + | ||
| 118 | + | // KeyEvent carries a non-text key or modified key press. | |
| 119 | + | type KeyEvent struct { | |
| 120 | + | Key string | |
| 121 | + | Code string | |
| 122 | + | Modifiers Modifiers | |
| 123 | + | Repeat bool | |
| 124 | + | Location int | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // PointerEvent carries a pointer action mapped to terminal coordinates. | |
| 128 | + | type PointerEvent struct { | |
| 129 | + | Action string | |
| 130 | + | Button int | |
| 131 | + | Buttons uint16 | |
| 132 | + | Row int | |
| 133 | + | Column int | |
| 134 | + | X float64 | |
| 135 | + | Y float64 | |
| 136 | + | Modifiers Modifiers | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // WheelEvent carries browser wheel deltas and terminal coordinates. | |
| 140 | + | type WheelEvent struct { | |
| 141 | + | DeltaX float64 | |
| 142 | + | DeltaY float64 | |
| 143 | + | DeltaMode int | |
| 144 | + | Row int | |
| 145 | + | Column int | |
| 146 | + | Modifiers Modifiers | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // ResizeEvent carries measured grid and CSS-pixel geometry. | |
| 150 | + | type ResizeEvent struct { | |
| 151 | + | Columns int | |
| 152 | + | Rows int | |
| 153 | + | PixelWidth float64 | |
| 154 | + | PixelHeight float64 | |
| 155 | + | CellWidth float64 | |
| 156 | + | CellHeight float64 | |
| 157 | + | DevicePixelRatio float64 | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // FocusEvent reports input-proxy focus changes. | |
| 161 | + | type FocusEvent struct{ Focused bool } | |
| 162 | + | ||
| 163 | + | // Event is a closed tagged union. Exactly one payload matching Kind is set. | |
| 164 | + | type Event struct { | |
| 165 | + | SchemaVersion uint16 | |
| 166 | + | Kind EventKind | |
| 167 | + | Text *TextEvent | |
| 168 | + | Key *KeyEvent | |
| 169 | + | Paste *PasteEvent | |
| 170 | + | Pointer *PointerEvent | |
| 171 | + | Wheel *WheelEvent | |
| 172 | + | Resize *ResizeEvent | |
| 173 | + | Focus *FocusEvent | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | type envelope struct { | |
| 177 | + | SchemaVersion uint16 `json:"schemaVersion"` | |
| 178 | + | Kind EventKind `json:"type"` | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | type textWire struct { | |
| 182 | + | SchemaVersion uint16 `json:"schemaVersion"` | |
| 183 | + | Kind EventKind `json:"type"` | |
| 184 | + | Text *string `json:"text"` | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | type keyWire struct { | |
| 188 | + | SchemaVersion uint16 `json:"schemaVersion"` | |
| 189 | + | Kind EventKind `json:"type"` | |
| 190 | + | Key *string `json:"key"` | |
| 191 | + | Code *string `json:"code"` | |
| 192 | + | Alt bool `json:"alt"` | |
| 193 | + | Ctrl bool `json:"ctrl"` | |
| 194 | + | Meta bool `json:"meta"` | |
| 195 | + | Shift bool `json:"shift"` | |
| 196 | + | Repeat bool `json:"repeat"` | |
| 197 | + | Location int `json:"location"` | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | type pointerWire struct { | |
| 201 | + | SchemaVersion uint16 `json:"schemaVersion"` | |
| 202 | + | Kind EventKind `json:"type"` | |
| 203 | + | Action *string `json:"action"` | |
| 204 | + | Button *int `json:"button"` | |
| 205 | + | Buttons *uint16 `json:"buttons"` | |
| 206 | + | Row *int `json:"row"` | |
| 207 | + | Column *int `json:"column"` | |
| 208 | + | X *float64 `json:"x"` | |
| 209 | + | Y *float64 `json:"y"` | |
| 210 | + | Alt bool `json:"alt"` | |
| 211 | + | Ctrl bool `json:"ctrl"` | |
| 212 | + | Meta bool `json:"meta"` | |
| 213 | + | Shift bool `json:"shift"` | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | type wheelWire struct { | |
| 217 | + | SchemaVersion uint16 `json:"schemaVersion"` | |
| 218 | + | Kind EventKind `json:"type"` | |
| 219 | + | DeltaX *float64 `json:"deltaX"` | |
| 220 | + | DeltaY *float64 `json:"deltaY"` | |
| 221 | + | DeltaMode *int `json:"deltaMode"` | |
| 222 | + | Row *int `json:"row"` | |
| 223 | + | Column *int `json:"column"` | |
| 224 | + | Alt bool `json:"alt"` | |
| 225 | + | Ctrl bool `json:"ctrl"` | |
| 226 | + | Meta bool `json:"meta"` | |
| 227 | + | Shift bool `json:"shift"` | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | type resizeWire struct { | |
| 231 | + | SchemaVersion uint16 `json:"schemaVersion"` | |
| 232 | + | Kind EventKind `json:"type"` | |
| 233 | + | Columns *int `json:"columns"` | |
| 234 | + | Rows *int `json:"rows"` | |
| 235 | + | PixelWidth *float64 `json:"pixelWidth"` | |
| 236 | + | PixelHeight *float64 `json:"pixelHeight"` | |
| 237 | + | CellWidth *float64 `json:"cellWidth"` | |
| 238 | + | CellHeight *float64 `json:"cellHeight"` | |
| 239 | + | DevicePixelRatio *float64 `json:"devicePixelRatio"` | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | type focusWire struct { | |
| 243 | + | SchemaVersion uint16 `json:"schemaVersion"` | |
| 244 | + | Kind EventKind `json:"type"` | |
| 245 | + | Focused *bool `json:"focused"` | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // DecodeEvent strictly decodes one complete untrusted browser event. | |
| 249 | + | func DecodeEvent(data []byte, requested EventLimits) (Event, error) { | |
| 250 | + | limits, err := normalizeEventLimits(requested) | |
| 251 | + | if err != nil { | |
| 252 | + | return Event{}, err | |
| 253 | + | } | |
| 254 | + | if len(data) > limits.MaxEventBytes { | |
| 255 | + | return Event{}, fmt.Errorf("%w: event has %d bytes, limit is %d", ErrEventLimit, len(data), limits.MaxEventBytes) | |
| 256 | + | } | |
| 257 | + | if !utf8.Valid(data) { | |
| 258 | + | return Event{}, fmt.Errorf("html/browser: event is not valid UTF-8") | |
| 259 | + | } | |
| 260 | + | var header envelope | |
| 261 | + | if err := json.Unmarshal(data, &header); err != nil { | |
| 262 | + | return Event{}, fmt.Errorf("html/browser: decode event envelope: %w", err) | |
| 263 | + | } | |
| 264 | + | if header.SchemaVersion != EventSchemaVersion { | |
| 265 | + | return Event{}, fmt.Errorf("html/browser: unsupported event schema %d", header.SchemaVersion) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | event := Event{SchemaVersion: header.SchemaVersion, Kind: header.Kind} | |
| 269 | + | switch header.Kind { | |
| 270 | + | case EventText, EventPaste: | |
| 271 | + | var wire textWire | |
| 272 | + | if err := decodeStrict(data, &wire); err != nil { | |
| 273 | + | return Event{}, err | |
| 274 | + | } | |
| 275 | + | if wire.Text == nil { | |
| 276 | + | return Event{}, fmt.Errorf("html/browser: event text is required") | |
| 277 | + | } | |
| 278 | + | if !utf8.ValidString(*wire.Text) { | |
| 279 | + | return Event{}, fmt.Errorf("html/browser: event text is not valid UTF-8") | |
| 280 | + | } | |
| 281 | + | limit := limits.MaxTextBytes | |
| 282 | + | if header.Kind == EventPaste { | |
| 283 | + | limit = limits.MaxPasteBytes | |
| 284 | + | } | |
| 285 | + | if len(*wire.Text) > limit { | |
| 286 | + | return Event{}, fmt.Errorf("%w: %s has %d bytes, limit is %d", ErrEventLimit, header.Kind, len(*wire.Text), limit) | |
| 287 | + | } | |
| 288 | + | if header.Kind == EventText { | |
| 289 | + | event.Text = &TextEvent{Text: *wire.Text} | |
| 290 | + | } else { | |
| 291 | + | event.Paste = &PasteEvent{Text: *wire.Text} | |
| 292 | + | } | |
| 293 | + | case EventKey: | |
| 294 | + | var wire keyWire | |
| 295 | + | if err := decodeStrict(data, &wire); err != nil { | |
| 296 | + | return Event{}, err | |
| 297 | + | } | |
| 298 | + | if wire.Key == nil || wire.Code == nil || *wire.Key == "" || *wire.Code == "" || len(*wire.Key) > 128 || len(*wire.Code) > 128 || wire.Location < 0 || wire.Location > 3 { | |
| 299 | + | return Event{}, fmt.Errorf("html/browser: invalid key event") | |
| 300 | + | } | |
| 301 | + | event.Key = &KeyEvent{Key: *wire.Key, Code: *wire.Code, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift), Repeat: wire.Repeat, Location: wire.Location} | |
| 302 | + | case EventPointer: | |
| 303 | + | var wire pointerWire | |
| 304 | + | if err := decodeStrict(data, &wire); err != nil { | |
| 305 | + | return Event{}, err | |
| 306 | + | } | |
| 307 | + | if wire.Action == nil || wire.Button == nil || wire.Buttons == nil || wire.Row == nil || wire.Column == nil || wire.X == nil || wire.Y == nil || !oneOf(*wire.Action, "down", "up", "move", "cancel") || *wire.Button < -1 || *wire.Button > 4 || *wire.Buttons > 31 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.X) || !finiteBounded(*wire.Y) { | |
| 308 | + | return Event{}, fmt.Errorf("html/browser: invalid pointer event") | |
| 309 | + | } | |
| 310 | + | event.Pointer = &PointerEvent{Action: *wire.Action, Button: *wire.Button, Buttons: *wire.Buttons, Row: *wire.Row, Column: *wire.Column, X: *wire.X, Y: *wire.Y, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)} | |
| 311 | + | case EventWheel: | |
| 312 | + | var wire wheelWire | |
| 313 | + | if err := decodeStrict(data, &wire); err != nil { | |
| 314 | + | return Event{}, err | |
| 315 | + | } | |
| 316 | + | if wire.DeltaX == nil || wire.DeltaY == nil || wire.DeltaMode == nil || wire.Row == nil || wire.Column == nil || *wire.DeltaMode < 0 || *wire.DeltaMode > 2 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.DeltaX) || !finiteBounded(*wire.DeltaY) { | |
| 317 | + | return Event{}, fmt.Errorf("html/browser: invalid wheel event") | |
| 318 | + | } | |
| 319 | + | event.Wheel = &WheelEvent{DeltaX: *wire.DeltaX, DeltaY: *wire.DeltaY, DeltaMode: *wire.DeltaMode, Row: *wire.Row, Column: *wire.Column, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)} | |
| 320 | + | case EventResize: | |
| 321 | + | var wire resizeWire | |
| 322 | + | if err := decodeStrict(data, &wire); err != nil { | |
| 323 | + | return Event{}, err | |
| 324 | + | } | |
| 325 | + | if wire.Columns == nil || wire.Rows == nil || wire.PixelWidth == nil || wire.PixelHeight == nil || wire.CellWidth == nil || wire.CellHeight == nil || wire.DevicePixelRatio == nil || *wire.Columns <= 0 || *wire.Columns > limits.MaxColumns || *wire.Rows <= 0 || *wire.Rows > limits.MaxRows || !positiveFinite(*wire.PixelWidth) || !positiveFinite(*wire.PixelHeight) || !positiveFinite(*wire.CellWidth) || !positiveFinite(*wire.CellHeight) || !positiveFinite(*wire.DevicePixelRatio) { | |
| 326 | + | return Event{}, fmt.Errorf("html/browser: invalid resize event") | |
| 327 | + | } | |
| 328 | + | event.Resize = &ResizeEvent{Columns: *wire.Columns, Rows: *wire.Rows, PixelWidth: *wire.PixelWidth, PixelHeight: *wire.PixelHeight, CellWidth: *wire.CellWidth, CellHeight: *wire.CellHeight, DevicePixelRatio: *wire.DevicePixelRatio} | |
| 329 | + | case EventFocus: | |
| 330 | + | var wire focusWire | |
| 331 | + | if err := decodeStrict(data, &wire); err != nil { | |
| 332 | + | return Event{}, err | |
| 333 | + | } | |
| 334 | + | if wire.Focused == nil { | |
| 335 | + | return Event{}, fmt.Errorf("html/browser: invalid focus event") | |
| 336 | + | } | |
| 337 | + | event.Focus = &FocusEvent{Focused: *wire.Focused} | |
| 338 | + | default: | |
| 339 | + | return Event{}, fmt.Errorf("html/browser: unknown event kind %q", header.Kind) | |
| 340 | + | } | |
| 341 | + | return event, nil | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func decodeStrict(data []byte, dst any) error { | |
| 345 | + | decoder := json.NewDecoder(bytes.NewReader(data)) | |
| 346 | + | decoder.DisallowUnknownFields() | |
| 347 | + | if err := decoder.Decode(dst); err != nil { | |
| 348 | + | return fmt.Errorf("html/browser: decode event: %w", err) | |
| 349 | + | } | |
| 350 | + | if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { | |
| 351 | + | return fmt.Errorf("html/browser: event must contain one JSON value") | |
| 352 | + | } | |
| 353 | + | return nil | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func modifiers(alt, ctrl, meta, shift bool) Modifiers { | |
| 357 | + | return Modifiers{Alt: alt, Ctrl: ctrl, Meta: meta, Shift: shift} | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func validCell(row, column int, limits EventLimits) bool { | |
| 361 | + | return row >= 0 && row < limits.MaxRows && column >= 0 && column < limits.MaxColumns | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func finiteBounded(value float64) bool { | |
| 365 | + | return !math.IsNaN(value) && !math.IsInf(value, 0) && math.Abs(value) <= 10_000_000 | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | func positiveFinite(value float64) bool { return value > 0 && finiteBounded(value) } | |
| 369 | + | ||
| 370 | + | func oneOf(value string, allowed ...string) bool { | |
| 371 | + | for _, candidate := range allowed { | |
| 372 | + | if value == candidate { | |
| 373 | + | return true | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | return false | |
| 377 | + | } | |
| @@ -0,0 +1,377 @@ | |||||
| 1 | + | // Package browser provides the static DOM runtime and strictly validated, | |||
| 2 | + | // transport-neutral browser event values for github.com/bnema/vev-vt/html. | |||
| 3 | + | package browser | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io" | |||
| 11 | + | "math" | |||
| 12 | + | "unicode/utf8" | |||
| 13 | + | ||||
| 14 | + | htmlrenderer "github.com/bnema/vev-vt/html" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // EventSchemaVersion identifies the runtime-to-Go event contract. | |||
| 18 | + | const EventSchemaVersion = htmlrenderer.UpdateSchemaVersion | |||
| 19 | + | ||||
| 20 | + | var ( | |||
| 21 | + | // ErrEventLimit reports that an event exceeded a configured resource bound. | |||
| 22 | + | ErrEventLimit = errors.New("html/browser: event resource limit exceeded") | |||
| 23 | + | // ErrInvalidEventLimits reports incompatible configured event bounds. | |||
| 24 | + | ErrInvalidEventLimits = errors.New("html/browser: invalid event limits") | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | const ( | |||
| 28 | + | maxJSONEscapedByteLength = 6 | |||
| 29 | + | eventEnvelopeOverhead = 64 | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // EventLimits bounds untrusted browser event decoding. Zero fields select safe | |||
| 33 | + | // defaults. | |||
| 34 | + | type EventLimits struct { | |||
| 35 | + | MaxEventBytes int | |||
| 36 | + | MaxTextBytes int | |||
| 37 | + | MaxPasteBytes int | |||
| 38 | + | MaxColumns int | |||
| 39 | + | MaxRows int | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // DefaultEventLimits returns the bounds used for zero-valued event limits. | |||
| 43 | + | func DefaultEventLimits() EventLimits { | |||
| 44 | + | return EventLimits{ | |||
| 45 | + | MaxEventBytes: 8 << 20, | |||
| 46 | + | MaxTextBytes: 64 << 10, | |||
| 47 | + | MaxPasteBytes: 1 << 20, | |||
| 48 | + | MaxColumns: 10_000, | |||
| 49 | + | MaxRows: 10_000, | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func normalizeEventLimits(limits EventLimits) (EventLimits, error) { | |||
| 54 | + | if limits.MaxEventBytes < 0 || limits.MaxTextBytes < 0 || limits.MaxPasteBytes < 0 || limits.MaxColumns < 0 || limits.MaxRows < 0 { | |||
| 55 | + | return EventLimits{}, fmt.Errorf("%w: event limits must not be negative", ErrInvalidEventLimits) | |||
| 56 | + | } | |||
| 57 | + | defaults := DefaultEventLimits() | |||
| 58 | + | if limits.MaxEventBytes == 0 { | |||
| 59 | + | limits.MaxEventBytes = defaults.MaxEventBytes | |||
| 60 | + | } | |||
| 61 | + | if limits.MaxTextBytes == 0 { | |||
| 62 | + | limits.MaxTextBytes = defaults.MaxTextBytes | |||
| 63 | + | } | |||
| 64 | + | if limits.MaxPasteBytes == 0 { | |||
| 65 | + | limits.MaxPasteBytes = defaults.MaxPasteBytes | |||
| 66 | + | } | |||
| 67 | + | if limits.MaxColumns == 0 { | |||
| 68 | + | limits.MaxColumns = defaults.MaxColumns | |||
| 69 | + | } | |||
| 70 | + | if limits.MaxRows == 0 { | |||
| 71 | + | limits.MaxRows = defaults.MaxRows | |||
| 72 | + | } | |||
| 73 | + | requiredEventBytes, representable := minimumEventBytes(max(limits.MaxTextBytes, limits.MaxPasteBytes)) | |||
| 74 | + | if !representable || limits.MaxEventBytes < requiredEventBytes { | |||
| 75 | + | return EventLimits{}, fmt.Errorf("%w: MaxEventBytes %d cannot contain the largest payload limit", ErrInvalidEventLimits, limits.MaxEventBytes) | |||
| 76 | + | } | |||
| 77 | + | return limits, nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func minimumEventBytes(payloadBytes int) (int, bool) { | |||
| 81 | + | maxInt := int(^uint(0) >> 1) | |||
| 82 | + | if payloadBytes > (maxInt-eventEnvelopeOverhead)/maxJSONEscapedByteLength { | |||
| 83 | + | return 0, false | |||
| 84 | + | } | |||
| 85 | + | return payloadBytes*maxJSONEscapedByteLength + eventEnvelopeOverhead, true | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // EventKind identifies one closed browser event payload. | |||
| 89 | + | type EventKind string | |||
| 90 | + | ||||
| 91 | + | // Supported browser event kinds. | |||
| 92 | + | const ( | |||
| 93 | + | EventText EventKind = "text" | |||
| 94 | + | EventKey EventKind = "key" | |||
| 95 | + | EventPaste EventKind = "paste" | |||
| 96 | + | EventPointer EventKind = "pointer" | |||
| 97 | + | EventWheel EventKind = "wheel" | |||
| 98 | + | EventResize EventKind = "resize" | |||
| 99 | + | EventFocus EventKind = "focus" | |||
| 100 | + | ) | |||
| 101 | + | ||||
| 102 | + | // Modifiers is the normalized browser modifier-key state. | |||
| 103 | + | type Modifiers struct { | |||
| 104 | + | Alt bool `json:"alt"` | |||
| 105 | + | Ctrl bool `json:"ctrl"` | |||
| 106 | + | Meta bool `json:"meta"` | |||
| 107 | + | Shift bool `json:"shift"` | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // TextEvent carries composed text input. | |||
| 111 | + | type TextEvent struct{ Text string } | |||
| 112 | + | ||||
| 113 | + | // PasteEvent carries bounded plain-text clipboard input. DecodeEvent preserves | |||
| 114 | + | // Text unchanged, including control bytes such as ESC and CR. Consumers that | |||
| 115 | + | // forward it to a PTY must apply their required framing or filtering policy. | |||
| 116 | + | type PasteEvent struct{ Text string } | |||
| 117 | + | ||||
| 118 | + | // KeyEvent carries a non-text key or modified key press. | |||
| 119 | + | type KeyEvent struct { | |||
| 120 | + | Key string | |||
| 121 | + | Code string | |||
| 122 | + | Modifiers Modifiers | |||
| 123 | + | Repeat bool | |||
| 124 | + | Location int | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // PointerEvent carries a pointer action mapped to terminal coordinates. | |||
| 128 | + | type PointerEvent struct { | |||
| 129 | + | Action string | |||
| 130 | + | Button int | |||
| 131 | + | Buttons uint16 | |||
| 132 | + | Row int | |||
| 133 | + | Column int | |||
| 134 | + | X float64 | |||
| 135 | + | Y float64 | |||
| 136 | + | Modifiers Modifiers | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // WheelEvent carries browser wheel deltas and terminal coordinates. | |||
| 140 | + | type WheelEvent struct { | |||
| 141 | + | DeltaX float64 | |||
| 142 | + | DeltaY float64 | |||
| 143 | + | DeltaMode int | |||
| 144 | + | Row int | |||
| 145 | + | Column int | |||
| 146 | + | Modifiers Modifiers | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // ResizeEvent carries measured grid and CSS-pixel geometry. | |||
| 150 | + | type ResizeEvent struct { | |||
| 151 | + | Columns int | |||
| 152 | + | Rows int | |||
| 153 | + | PixelWidth float64 | |||
| 154 | + | PixelHeight float64 | |||
| 155 | + | CellWidth float64 | |||
| 156 | + | CellHeight float64 | |||
| 157 | + | DevicePixelRatio float64 | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // FocusEvent reports input-proxy focus changes. | |||
| 161 | + | type FocusEvent struct{ Focused bool } | |||
| 162 | + | ||||
| 163 | + | // Event is a closed tagged union. Exactly one payload matching Kind is set. | |||
| 164 | + | type Event struct { | |||
| 165 | + | SchemaVersion uint16 | |||
| 166 | + | Kind EventKind | |||
| 167 | + | Text *TextEvent | |||
| 168 | + | Key *KeyEvent | |||
| 169 | + | Paste *PasteEvent | |||
| 170 | + | Pointer *PointerEvent | |||
| 171 | + | Wheel *WheelEvent | |||
| 172 | + | Resize *ResizeEvent | |||
| 173 | + | Focus *FocusEvent | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | type envelope struct { | |||
| 177 | + | SchemaVersion uint16 `json:"schemaVersion"` | |||
| 178 | + | Kind EventKind `json:"type"` | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | type textWire struct { | |||
| 182 | + | SchemaVersion uint16 `json:"schemaVersion"` | |||
| 183 | + | Kind EventKind `json:"type"` | |||
| 184 | + | Text *string `json:"text"` | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | type keyWire struct { | |||
| 188 | + | SchemaVersion uint16 `json:"schemaVersion"` | |||
| 189 | + | Kind EventKind `json:"type"` | |||
| 190 | + | Key *string `json:"key"` | |||
| 191 | + | Code *string `json:"code"` | |||
| 192 | + | Alt bool `json:"alt"` | |||
| 193 | + | Ctrl bool `json:"ctrl"` | |||
| 194 | + | Meta bool `json:"meta"` | |||
| 195 | + | Shift bool `json:"shift"` | |||
| 196 | + | Repeat bool `json:"repeat"` | |||
| 197 | + | Location int `json:"location"` | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | type pointerWire struct { | |||
| 201 | + | SchemaVersion uint16 `json:"schemaVersion"` | |||
| 202 | + | Kind EventKind `json:"type"` | |||
| 203 | + | Action *string `json:"action"` | |||
| 204 | + | Button *int `json:"button"` | |||
| 205 | + | Buttons *uint16 `json:"buttons"` | |||
| 206 | + | Row *int `json:"row"` | |||
| 207 | + | Column *int `json:"column"` | |||
| 208 | + | X *float64 `json:"x"` | |||
| 209 | + | Y *float64 `json:"y"` | |||
| 210 | + | Alt bool `json:"alt"` | |||
| 211 | + | Ctrl bool `json:"ctrl"` | |||
| 212 | + | Meta bool `json:"meta"` | |||
| 213 | + | Shift bool `json:"shift"` | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | type wheelWire struct { | |||
| 217 | + | SchemaVersion uint16 `json:"schemaVersion"` | |||
| 218 | + | Kind EventKind `json:"type"` | |||
| 219 | + | DeltaX *float64 `json:"deltaX"` | |||
| 220 | + | DeltaY *float64 `json:"deltaY"` | |||
| 221 | + | DeltaMode *int `json:"deltaMode"` | |||
| 222 | + | Row *int `json:"row"` | |||
| 223 | + | Column *int `json:"column"` | |||
| 224 | + | Alt bool `json:"alt"` | |||
| 225 | + | Ctrl bool `json:"ctrl"` | |||
| 226 | + | Meta bool `json:"meta"` | |||
| 227 | + | Shift bool `json:"shift"` | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | type resizeWire struct { | |||
| 231 | + | SchemaVersion uint16 `json:"schemaVersion"` | |||
| 232 | + | Kind EventKind `json:"type"` | |||
| 233 | + | Columns *int `json:"columns"` | |||
| 234 | + | Rows *int `json:"rows"` | |||
| 235 | + | PixelWidth *float64 `json:"pixelWidth"` | |||
| 236 | + | PixelHeight *float64 `json:"pixelHeight"` | |||
| 237 | + | CellWidth *float64 `json:"cellWidth"` | |||
| 238 | + | CellHeight *float64 `json:"cellHeight"` | |||
| 239 | + | DevicePixelRatio *float64 `json:"devicePixelRatio"` | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | type focusWire struct { | |||
| 243 | + | SchemaVersion uint16 `json:"schemaVersion"` | |||
| 244 | + | Kind EventKind `json:"type"` | |||
| 245 | + | Focused *bool `json:"focused"` | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // DecodeEvent strictly decodes one complete untrusted browser event. | |||
| 249 | + | func DecodeEvent(data []byte, requested EventLimits) (Event, error) { | |||
| 250 | + | limits, err := normalizeEventLimits(requested) | |||
| 251 | + | if err != nil { | |||
| 252 | + | return Event{}, err | |||
| 253 | + | } | |||
| 254 | + | if len(data) > limits.MaxEventBytes { | |||
| 255 | + | return Event{}, fmt.Errorf("%w: event has %d bytes, limit is %d", ErrEventLimit, len(data), limits.MaxEventBytes) | |||
| 256 | + | } | |||
| 257 | + | if !utf8.Valid(data) { | |||
| 258 | + | return Event{}, fmt.Errorf("html/browser: event is not valid UTF-8") | |||
| 259 | + | } | |||
| 260 | + | var header envelope | |||
| 261 | + | if err := json.Unmarshal(data, &header); err != nil { | |||
| 262 | + | return Event{}, fmt.Errorf("html/browser: decode event envelope: %w", err) | |||
| 263 | + | } | |||
| 264 | + | if header.SchemaVersion != EventSchemaVersion { | |||
| 265 | + | return Event{}, fmt.Errorf("html/browser: unsupported event schema %d", header.SchemaVersion) | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | event := Event{SchemaVersion: header.SchemaVersion, Kind: header.Kind} | |||
| 269 | + | switch header.Kind { | |||
| 270 | + | case EventText, EventPaste: | |||
| 271 | + | var wire textWire | |||
| 272 | + | if err := decodeStrict(data, &wire); err != nil { | |||
| 273 | + | return Event{}, err | |||
| 274 | + | } | |||
| 275 | + | if wire.Text == nil { | |||
| 276 | + | return Event{}, fmt.Errorf("html/browser: event text is required") | |||
| 277 | + | } | |||
| 278 | + | if !utf8.ValidString(*wire.Text) { | |||
| 279 | + | return Event{}, fmt.Errorf("html/browser: event text is not valid UTF-8") | |||
| 280 | + | } | |||
| 281 | + | limit := limits.MaxTextBytes | |||
| 282 | + | if header.Kind == EventPaste { | |||
| 283 | + | limit = limits.MaxPasteBytes | |||
| 284 | + | } | |||
| 285 | + | if len(*wire.Text) > limit { | |||
| 286 | + | return Event{}, fmt.Errorf("%w: %s has %d bytes, limit is %d", ErrEventLimit, header.Kind, len(*wire.Text), limit) | |||
| 287 | + | } | |||
| 288 | + | if header.Kind == EventText { | |||
| 289 | + | event.Text = &TextEvent{Text: *wire.Text} | |||
| 290 | + | } else { | |||
| 291 | + | event.Paste = &PasteEvent{Text: *wire.Text} | |||
| 292 | + | } | |||
| 293 | + | case EventKey: | |||
| 294 | + | var wire keyWire | |||
| 295 | + | if err := decodeStrict(data, &wire); err != nil { | |||
| 296 | + | return Event{}, err | |||
| 297 | + | } | |||
| 298 | + | if wire.Key == nil || wire.Code == nil || *wire.Key == "" || *wire.Code == "" || len(*wire.Key) > 128 || len(*wire.Code) > 128 || wire.Location < 0 || wire.Location > 3 { | |||
| 299 | + | return Event{}, fmt.Errorf("html/browser: invalid key event") | |||
| 300 | + | } | |||
| 301 | + | event.Key = &KeyEvent{Key: *wire.Key, Code: *wire.Code, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift), Repeat: wire.Repeat, Location: wire.Location} | |||
| 302 | + | case EventPointer: | |||
| 303 | + | var wire pointerWire | |||
| 304 | + | if err := decodeStrict(data, &wire); err != nil { | |||
| 305 | + | return Event{}, err | |||
| 306 | + | } | |||
| 307 | + | if wire.Action == nil || wire.Button == nil || wire.Buttons == nil || wire.Row == nil || wire.Column == nil || wire.X == nil || wire.Y == nil || !oneOf(*wire.Action, "down", "up", "move", "cancel") || *wire.Button < -1 || *wire.Button > 4 || *wire.Buttons > 31 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.X) || !finiteBounded(*wire.Y) { | |||
| 308 | + | return Event{}, fmt.Errorf("html/browser: invalid pointer event") | |||
| 309 | + | } | |||
| 310 | + | event.Pointer = &PointerEvent{Action: *wire.Action, Button: *wire.Button, Buttons: *wire.Buttons, Row: *wire.Row, Column: *wire.Column, X: *wire.X, Y: *wire.Y, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)} | |||
| 311 | + | case EventWheel: | |||
| 312 | + | var wire wheelWire | |||
| 313 | + | if err := decodeStrict(data, &wire); err != nil { | |||
| 314 | + | return Event{}, err | |||
| 315 | + | } | |||
| 316 | + | if wire.DeltaX == nil || wire.DeltaY == nil || wire.DeltaMode == nil || wire.Row == nil || wire.Column == nil || *wire.DeltaMode < 0 || *wire.DeltaMode > 2 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.DeltaX) || !finiteBounded(*wire.DeltaY) { | |||
| 317 | + | return Event{}, fmt.Errorf("html/browser: invalid wheel event") | |||
| 318 | + | } | |||
| 319 | + | event.Wheel = &WheelEvent{DeltaX: *wire.DeltaX, DeltaY: *wire.DeltaY, DeltaMode: *wire.DeltaMode, Row: *wire.Row, Column: *wire.Column, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)} | |||
| 320 | + | case EventResize: | |||
| 321 | + | var wire resizeWire | |||
| 322 | + | if err := decodeStrict(data, &wire); err != nil { | |||
| 323 | + | return Event{}, err | |||
| 324 | + | } | |||
| 325 | + | if wire.Columns == nil || wire.Rows == nil || wire.PixelWidth == nil || wire.PixelHeight == nil || wire.CellWidth == nil || wire.CellHeight == nil || wire.DevicePixelRatio == nil || *wire.Columns <= 0 || *wire.Columns > limits.MaxColumns || *wire.Rows <= 0 || *wire.Rows > limits.MaxRows || !positiveFinite(*wire.PixelWidth) || !positiveFinite(*wire.PixelHeight) || !positiveFinite(*wire.CellWidth) || !positiveFinite(*wire.CellHeight) || !positiveFinite(*wire.DevicePixelRatio) { | |||
| 326 | + | return Event{}, fmt.Errorf("html/browser: invalid resize event") | |||
| 327 | + | } | |||
| 328 | + | event.Resize = &ResizeEvent{Columns: *wire.Columns, Rows: *wire.Rows, PixelWidth: *wire.PixelWidth, PixelHeight: *wire.PixelHeight, CellWidth: *wire.CellWidth, CellHeight: *wire.CellHeight, DevicePixelRatio: *wire.DevicePixelRatio} | |||
| 329 | + | case EventFocus: | |||
| 330 | + | var wire focusWire | |||
| 331 | + | if err := decodeStrict(data, &wire); err != nil { | |||
| 332 | + | return Event{}, err | |||
| 333 | + | } | |||
| 334 | + | if wire.Focused == nil { | |||
| 335 | + | return Event{}, fmt.Errorf("html/browser: invalid focus event") | |||
| 336 | + | } | |||
| 337 | + | event.Focus = &FocusEvent{Focused: *wire.Focused} | |||
| 338 | + | default: | |||
| 339 | + | return Event{}, fmt.Errorf("html/browser: unknown event kind %q", header.Kind) | |||
| 340 | + | } | |||
| 341 | + | return event, nil | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func decodeStrict(data []byte, dst any) error { | |||
| 345 | + | decoder := json.NewDecoder(bytes.NewReader(data)) | |||
| 346 | + | decoder.DisallowUnknownFields() | |||
| 347 | + | if err := decoder.Decode(dst); err != nil { | |||
| 348 | + | return fmt.Errorf("html/browser: decode event: %w", err) | |||
| 349 | + | } | |||
| 350 | + | if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { | |||
| 351 | + | return fmt.Errorf("html/browser: event must contain one JSON value") | |||
| 352 | + | } | |||
| 353 | + | return nil | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func modifiers(alt, ctrl, meta, shift bool) Modifiers { | |||
| 357 | + | return Modifiers{Alt: alt, Ctrl: ctrl, Meta: meta, Shift: shift} | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func validCell(row, column int, limits EventLimits) bool { | |||
| 361 | + | return row >= 0 && row < limits.MaxRows && column >= 0 && column < limits.MaxColumns | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | func finiteBounded(value float64) bool { | |||
| 365 | + | return !math.IsNaN(value) && !math.IsInf(value, 0) && math.Abs(value) <= 10_000_000 | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | func positiveFinite(value float64) bool { return value > 0 && finiteBounded(value) } | |||
| 369 | + | ||||
| 370 | + | func oneOf(value string, allowed ...string) bool { | |||
| 371 | + | for _, candidate := range allowed { | |||
| 372 | + | if value == candidate { | |||
| 373 | + | return true | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | return false | |||
| 377 | + | } | |||
A html/browser/event_test.go +151 -0
| @@ -0,0 +1,151 @@ | |||
| 1 | + | package browser | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestDecodeEventValidatesTheClosedSchema(t *testing.T) { | |
| 12 | + | event, err := DecodeEvent([]byte(`{"schemaVersion":1,"type":"text","text":"é"}`), EventLimits{}) | |
| 13 | + | require.NoError(t, err) | |
| 14 | + | require.Equal(t, EventText, event.Kind) | |
| 15 | + | require.Equal(t, "é", event.Text.Text) | |
| 16 | + | ||
| 17 | + | _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"text","text":"x","unknown":true}`), EventLimits{}) | |
| 18 | + | require.Error(t, err) | |
| 19 | + | ||
| 20 | + | _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"paste","text":"toolong"}`), EventLimits{MaxPasteBytes: 3}) | |
| 21 | + | require.ErrorIs(t, err, ErrEventLimit) | |
| 22 | + | ||
| 23 | + | _, err = DecodeEvent([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, EventLimits{}) | |
| 24 | + | require.ErrorContains(t, err, "not valid UTF-8") | |
| 25 | + | ||
| 26 | + | _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"resize","columns":80,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`), EventLimits{}) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | ||
| 29 | + | for _, data := range []string{ | |
| 30 | + | `{"schemaVersion":1,"type":"focus"}`, | |
| 31 | + | `{"schemaVersion":1,"type":"wheel"}`, | |
| 32 | + | } { | |
| 33 | + | _, err = DecodeEvent([]byte(data), EventLimits{}) | |
| 34 | + | require.Error(t, err) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestDecodeEventAcceptsRuntimeEventKinds(t *testing.T) { | |
| 39 | + | tests := []struct { | |
| 40 | + | kind EventKind | |
| 41 | + | json string | |
| 42 | + | }{ | |
| 43 | + | {EventKey, `{"schemaVersion":1,"type":"key","key":"ArrowUp","code":"ArrowUp","alt":false,"ctrl":false,"meta":false,"shift":false,"repeat":false,"location":0}`}, | |
| 44 | + | {EventPaste, `{"schemaVersion":1,"type":"paste","text":"hello"}`}, | |
| 45 | + | {EventPointer, `{"schemaVersion":1,"type":"pointer","action":"down","button":0,"buttons":1,"row":0,"column":0,"x":1,"y":2,"alt":false,"ctrl":false,"meta":false,"shift":false}`}, | |
| 46 | + | {EventWheel, `{"schemaVersion":1,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":0,"row":0,"column":0,"alt":false,"ctrl":false,"meta":false,"shift":false}`}, | |
| 47 | + | {EventFocus, `{"schemaVersion":1,"type":"focus","focused":true}`}, | |
| 48 | + | } | |
| 49 | + | for _, test := range tests { | |
| 50 | + | t.Run(string(test.kind), func(t *testing.T) { | |
| 51 | + | event, err := DecodeEvent([]byte(test.json), EventLimits{}) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | require.Equal(t, test.kind, event.Kind) | |
| 54 | + | if test.kind == EventKey { | |
| 55 | + | require.Equal(t, "ArrowUp", event.Key.Key) | |
| 56 | + | } | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestDefaultEventLimitsAcceptEscapedMaximumPaste(t *testing.T) { | |
| 62 | + | limits := DefaultEventLimits() | |
| 63 | + | text := strings.Repeat("\x00", limits.MaxPasteBytes) | |
| 64 | + | data, err := json.Marshal(textWire{ | |
| 65 | + | SchemaVersion: EventSchemaVersion, | |
| 66 | + | Kind: EventPaste, | |
| 67 | + | Text: &text, | |
| 68 | + | }) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | ||
| 71 | + | event, err := DecodeEvent(data, EventLimits{}) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | require.NotNil(t, event.Paste) | |
| 74 | + | require.Len(t, event.Paste.Text, limits.MaxPasteBytes) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestNormalizeEventLimitsRejectsImpossibleEnvelope(t *testing.T) { | |
| 78 | + | _, err := normalizeEventLimits(EventLimits{MaxEventBytes: 69, MaxTextBytes: 1, MaxPasteBytes: 1}) | |
| 79 | + | require.ErrorIs(t, err, ErrInvalidEventLimits) | |
| 80 | + | ||
| 81 | + | _, err = normalizeEventLimits(EventLimits{MaxTextBytes: -1}) | |
| 82 | + | require.ErrorIs(t, err, ErrInvalidEventLimits) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestDecodeEventRejectsMissingRequiredFields(t *testing.T) { | |
| 86 | + | tests := []string{ | |
| 87 | + | `{"schemaVersion":1,"type":"text"}`, | |
| 88 | + | `{"schemaVersion":1,"type":"key","code":"ArrowUp"}`, | |
| 89 | + | `{"schemaVersion":1,"type":"pointer","action":"down"}`, | |
| 90 | + | `{"schemaVersion":1,"type":"wheel"}`, | |
| 91 | + | `{"schemaVersion":1,"type":"resize","columns":80,"rows":24}`, | |
| 92 | + | `{"schemaVersion":1,"type":"focus"}`, | |
| 93 | + | } | |
| 94 | + | for _, data := range tests { | |
| 95 | + | _, err := DecodeEvent([]byte(data), EventLimits{}) | |
| 96 | + | require.Error(t, err, data) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestDecodeEventRejectsInvalidRuntimePayloads(t *testing.T) { | |
| 101 | + | tests := map[string]string{ | |
| 102 | + | "schema": `{"schemaVersion":2,"type":"focus","focused":true}`, | |
| 103 | + | "pointer button": `{"schemaVersion":1,"type":"pointer","action":"down","button":5,"buttons":1,"row":0,"column":0,"x":1,"y":2}`, | |
| 104 | + | "pointer buttons": `{"schemaVersion":1,"type":"pointer","action":"down","button":0,"buttons":32,"row":0,"column":0,"x":1,"y":2}`, | |
| 105 | + | "wheel mode": `{"schemaVersion":1,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":3,"row":0,"column":0}`, | |
| 106 | + | "resize dimension": `{"schemaVersion":1,"type":"resize","columns":0,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`, | |
| 107 | + | "non-finite coordinate": `{"schemaVersion":1,"type":"pointer","action":"move","button":-1,"buttons":0,"row":0,"column":0,"x":1e999,"y":2}`, | |
| 108 | + | "trailing value": `{"schemaVersion":1,"type":"focus","focused":true} {}`, | |
| 109 | + | } | |
| 110 | + | for name, data := range tests { | |
| 111 | + | t.Run(name, func(t *testing.T) { | |
| 112 | + | _, err := DecodeEvent([]byte(data), EventLimits{}) | |
| 113 | + | require.Error(t, err) | |
| 114 | + | }) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func FuzzDecodeEvent(f *testing.F) { | |
| 119 | + | f.Add([]byte(`{"schemaVersion":1,"type":"focus","focused":true}`)) | |
| 120 | + | f.Add([]byte(`{"schemaVersion":1,"type":"text","text":"hello"}`)) | |
| 121 | + | f.Add([]byte(`not json`)) | |
| 122 | + | f.Add([]byte(`{"schemaVersion":1,"type":"text","text":"` + strings.Repeat("x", 4<<10) + `"}`)) | |
| 123 | + | f.Fuzz(func(t *testing.T, data []byte) { | |
| 124 | + | event, err := DecodeEvent(data, EventLimits{MaxEventBytes: 4 << 10, MaxTextBytes: 512, MaxPasteBytes: 512}) | |
| 125 | + | if len(data) > 4<<10 { | |
| 126 | + | require.ErrorIs(t, err, ErrEventLimit) | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | if err != nil { | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | payloads := map[EventKind]bool{ | |
| 133 | + | EventText: event.Text != nil, | |
| 134 | + | EventKey: event.Key != nil, | |
| 135 | + | EventPaste: event.Paste != nil, | |
| 136 | + | EventPointer: event.Pointer != nil, | |
| 137 | + | EventWheel: event.Wheel != nil, | |
| 138 | + | EventResize: event.Resize != nil, | |
| 139 | + | EventFocus: event.Focus != nil, | |
| 140 | + | } | |
| 141 | + | set := 0 | |
| 142 | + | for _, present := range payloads { | |
| 143 | + | if present { | |
| 144 | + | set++ | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | if set != 1 || !payloads[event.Kind] { | |
| 148 | + | t.Fatalf("decoded event violates the closed union: %#v", event) | |
| 149 | + | } | |
| 150 | + | }) | |
| 151 | + | } | |
| @@ -0,0 +1,151 @@ | |||||
| 1 | + | package browser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestDecodeEventValidatesTheClosedSchema(t *testing.T) { | |||
| 12 | + | event, err := DecodeEvent([]byte(`{"schemaVersion":1,"type":"text","text":"é"}`), EventLimits{}) | |||
| 13 | + | require.NoError(t, err) | |||
| 14 | + | require.Equal(t, EventText, event.Kind) | |||
| 15 | + | require.Equal(t, "é", event.Text.Text) | |||
| 16 | + | ||||
| 17 | + | _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"text","text":"x","unknown":true}`), EventLimits{}) | |||
| 18 | + | require.Error(t, err) | |||
| 19 | + | ||||
| 20 | + | _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"paste","text":"toolong"}`), EventLimits{MaxPasteBytes: 3}) | |||
| 21 | + | require.ErrorIs(t, err, ErrEventLimit) | |||
| 22 | + | ||||
| 23 | + | _, err = DecodeEvent([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, EventLimits{}) | |||
| 24 | + | require.ErrorContains(t, err, "not valid UTF-8") | |||
| 25 | + | ||||
| 26 | + | _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"resize","columns":80,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`), EventLimits{}) | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | ||||
| 29 | + | for _, data := range []string{ | |||
| 30 | + | `{"schemaVersion":1,"type":"focus"}`, | |||
| 31 | + | `{"schemaVersion":1,"type":"wheel"}`, | |||
| 32 | + | } { | |||
| 33 | + | _, err = DecodeEvent([]byte(data), EventLimits{}) | |||
| 34 | + | require.Error(t, err) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestDecodeEventAcceptsRuntimeEventKinds(t *testing.T) { | |||
| 39 | + | tests := []struct { | |||
| 40 | + | kind EventKind | |||
| 41 | + | json string | |||
| 42 | + | }{ | |||
| 43 | + | {EventKey, `{"schemaVersion":1,"type":"key","key":"ArrowUp","code":"ArrowUp","alt":false,"ctrl":false,"meta":false,"shift":false,"repeat":false,"location":0}`}, | |||
| 44 | + | {EventPaste, `{"schemaVersion":1,"type":"paste","text":"hello"}`}, | |||
| 45 | + | {EventPointer, `{"schemaVersion":1,"type":"pointer","action":"down","button":0,"buttons":1,"row":0,"column":0,"x":1,"y":2,"alt":false,"ctrl":false,"meta":false,"shift":false}`}, | |||
| 46 | + | {EventWheel, `{"schemaVersion":1,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":0,"row":0,"column":0,"alt":false,"ctrl":false,"meta":false,"shift":false}`}, | |||
| 47 | + | {EventFocus, `{"schemaVersion":1,"type":"focus","focused":true}`}, | |||
| 48 | + | } | |||
| 49 | + | for _, test := range tests { | |||
| 50 | + | t.Run(string(test.kind), func(t *testing.T) { | |||
| 51 | + | event, err := DecodeEvent([]byte(test.json), EventLimits{}) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | require.Equal(t, test.kind, event.Kind) | |||
| 54 | + | if test.kind == EventKey { | |||
| 55 | + | require.Equal(t, "ArrowUp", event.Key.Key) | |||
| 56 | + | } | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestDefaultEventLimitsAcceptEscapedMaximumPaste(t *testing.T) { | |||
| 62 | + | limits := DefaultEventLimits() | |||
| 63 | + | text := strings.Repeat("\x00", limits.MaxPasteBytes) | |||
| 64 | + | data, err := json.Marshal(textWire{ | |||
| 65 | + | SchemaVersion: EventSchemaVersion, | |||
| 66 | + | Kind: EventPaste, | |||
| 67 | + | Text: &text, | |||
| 68 | + | }) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | ||||
| 71 | + | event, err := DecodeEvent(data, EventLimits{}) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | require.NotNil(t, event.Paste) | |||
| 74 | + | require.Len(t, event.Paste.Text, limits.MaxPasteBytes) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestNormalizeEventLimitsRejectsImpossibleEnvelope(t *testing.T) { | |||
| 78 | + | _, err := normalizeEventLimits(EventLimits{MaxEventBytes: 69, MaxTextBytes: 1, MaxPasteBytes: 1}) | |||
| 79 | + | require.ErrorIs(t, err, ErrInvalidEventLimits) | |||
| 80 | + | ||||
| 81 | + | _, err = normalizeEventLimits(EventLimits{MaxTextBytes: -1}) | |||
| 82 | + | require.ErrorIs(t, err, ErrInvalidEventLimits) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestDecodeEventRejectsMissingRequiredFields(t *testing.T) { | |||
| 86 | + | tests := []string{ | |||
| 87 | + | `{"schemaVersion":1,"type":"text"}`, | |||
| 88 | + | `{"schemaVersion":1,"type":"key","code":"ArrowUp"}`, | |||
| 89 | + | `{"schemaVersion":1,"type":"pointer","action":"down"}`, | |||
| 90 | + | `{"schemaVersion":1,"type":"wheel"}`, | |||
| 91 | + | `{"schemaVersion":1,"type":"resize","columns":80,"rows":24}`, | |||
| 92 | + | `{"schemaVersion":1,"type":"focus"}`, | |||
| 93 | + | } | |||
| 94 | + | for _, data := range tests { | |||
| 95 | + | _, err := DecodeEvent([]byte(data), EventLimits{}) | |||
| 96 | + | require.Error(t, err, data) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestDecodeEventRejectsInvalidRuntimePayloads(t *testing.T) { | |||
| 101 | + | tests := map[string]string{ | |||
| 102 | + | "schema": `{"schemaVersion":2,"type":"focus","focused":true}`, | |||
| 103 | + | "pointer button": `{"schemaVersion":1,"type":"pointer","action":"down","button":5,"buttons":1,"row":0,"column":0,"x":1,"y":2}`, | |||
| 104 | + | "pointer buttons": `{"schemaVersion":1,"type":"pointer","action":"down","button":0,"buttons":32,"row":0,"column":0,"x":1,"y":2}`, | |||
| 105 | + | "wheel mode": `{"schemaVersion":1,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":3,"row":0,"column":0}`, | |||
| 106 | + | "resize dimension": `{"schemaVersion":1,"type":"resize","columns":0,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`, | |||
| 107 | + | "non-finite coordinate": `{"schemaVersion":1,"type":"pointer","action":"move","button":-1,"buttons":0,"row":0,"column":0,"x":1e999,"y":2}`, | |||
| 108 | + | "trailing value": `{"schemaVersion":1,"type":"focus","focused":true} {}`, | |||
| 109 | + | } | |||
| 110 | + | for name, data := range tests { | |||
| 111 | + | t.Run(name, func(t *testing.T) { | |||
| 112 | + | _, err := DecodeEvent([]byte(data), EventLimits{}) | |||
| 113 | + | require.Error(t, err) | |||
| 114 | + | }) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func FuzzDecodeEvent(f *testing.F) { | |||
| 119 | + | f.Add([]byte(`{"schemaVersion":1,"type":"focus","focused":true}`)) | |||
| 120 | + | f.Add([]byte(`{"schemaVersion":1,"type":"text","text":"hello"}`)) | |||
| 121 | + | f.Add([]byte(`not json`)) | |||
| 122 | + | f.Add([]byte(`{"schemaVersion":1,"type":"text","text":"` + strings.Repeat("x", 4<<10) + `"}`)) | |||
| 123 | + | f.Fuzz(func(t *testing.T, data []byte) { | |||
| 124 | + | event, err := DecodeEvent(data, EventLimits{MaxEventBytes: 4 << 10, MaxTextBytes: 512, MaxPasteBytes: 512}) | |||
| 125 | + | if len(data) > 4<<10 { | |||
| 126 | + | require.ErrorIs(t, err, ErrEventLimit) | |||
| 127 | + | return | |||
| 128 | + | } | |||
| 129 | + | if err != nil { | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | payloads := map[EventKind]bool{ | |||
| 133 | + | EventText: event.Text != nil, | |||
| 134 | + | EventKey: event.Key != nil, | |||
| 135 | + | EventPaste: event.Paste != nil, | |||
| 136 | + | EventPointer: event.Pointer != nil, | |||
| 137 | + | EventWheel: event.Wheel != nil, | |||
| 138 | + | EventResize: event.Resize != nil, | |||
| 139 | + | EventFocus: event.Focus != nil, | |||
| 140 | + | } | |||
| 141 | + | set := 0 | |||
| 142 | + | for _, present := range payloads { | |||
| 143 | + | if present { | |||
| 144 | + | set++ | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | if set != 1 || !payloads[event.Kind] { | |||
| 148 | + | t.Fatalf("decoded event violates the closed union: %#v", event) | |||
| 149 | + | } | |||
| 150 | + | }) | |||
| 151 | + | } | |||
A html/browser/terminal.js +708 -0
| @@ -0,0 +1,708 @@ | |||
| 1 | + | (() => { | |
| 2 | + | 'use strict'; | |
| 3 | + | ||
| 4 | + | /** @typedef {{r: number, g: number, b: number}} RGB */ | |
| 5 | + | /** @typedef {{kind: 0}|{kind: 1, index: number}|{kind: 2, rgb: RGB}} Color */ | |
| 6 | + | /** @typedef {{bold?: boolean, italic?: boolean, inverse?: boolean, dim?: boolean, blink?: boolean, strikethrough?: boolean, underline?: boolean, underlineStyle?: number, foreground: Color, background: Color, underlineColor: Color}} TerminalStyle */ | |
| 7 | + | /** @typedef {{column: number, width: number, text: string, style: number}} CellUpdate */ | |
| 8 | + | /** @typedef {{row: number, cells: CellUpdate[]}} RowUpdate */ | |
| 9 | + | /** @typedef {{row: number, column: number, visible: boolean, style: number, styleSet: boolean}} Cursor */ | |
| 10 | + | /** @typedef {{schemaVersion: number, width: number, height: number, snapshot: boolean, rows: RowUpdate[], styles: TerminalStyle[], cursor: Cursor}} Update */ | |
| 11 | + | /** @typedef {{emit: boolean, preventDefault: boolean}} CaptureDecision */ | |
| 12 | + | /** @typedef {{schemaVersion?: number, type: string, [key: string]: unknown}} BrowserEvent */ | |
| 13 | + | /** @typedef {{maxCells?: number, maxRowsPerUpdate?: number, maxColumns?: number, maxStyles?: number, maxTextBytes?: number, maxUpdateBytes?: number, maxPasteBytes?: number}} BrowserLimits */ | |
| 14 | + | /** @typedef {{foreground: RGB, background: RGB, cursor: RGB, selection: RGB, selectionText: RGB, palette: RGB[]}} TerminalTheme */ | |
| 15 | + | /** @typedef {{label: string, decide?: (event: BrowserEvent) => CaptureDecision, send?: (event: BrowserEvent) => void, limits?: BrowserLimits}} MountOptions */ | |
| 16 | + | /** @typedef {{apply(update: Update): void, focus(): void, setMouseCapture(enabled: boolean): void, setTheme(theme: TerminalTheme): void, destroy(): void}} Terminal */ | |
| 17 | + | ||
| 18 | + | const SCHEMA_VERSION = 1; | |
| 19 | + | const DEFAULT_LIMITS = Object.freeze({ | |
| 20 | + | maxCells: 1_000_000, | |
| 21 | + | maxRowsPerUpdate: 10_000, | |
| 22 | + | maxColumns: 10_000, | |
| 23 | + | maxStyles: 65_536, | |
| 24 | + | maxTextBytes: 64 << 10, | |
| 25 | + | maxUpdateBytes: 64 << 20, | |
| 26 | + | maxPasteBytes: 1 << 20 | |
| 27 | + | }); | |
| 28 | + | const encoder = new TextEncoder(); | |
| 29 | + | ||
| 30 | + | function fail(message) { | |
| 31 | + | throw new TypeError(`VevTerminal: ${message}`); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | function object(value, name) { | |
| 35 | + | if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(`${name} must be an object`); | |
| 36 | + | return value; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | function keys(value, allowed, name) { | |
| 40 | + | for (const key of Object.keys(value)) { | |
| 41 | + | if (!allowed.includes(key)) fail(`${name} contains unknown field ${key}`); | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | function integer(value, minimum, maximum, name) { | |
| 46 | + | if (!Number.isSafeInteger(value) || value < minimum || value > maximum) fail(`${name} is out of range`); | |
| 47 | + | return value; | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | function boolean(value, name, fallback = false) { | |
| 51 | + | if (value === undefined) return fallback; | |
| 52 | + | if (typeof value !== 'boolean') fail(`${name} must be boolean`); | |
| 53 | + | return value; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /** @returns {{value: string, byteLength: number}} */ | |
| 57 | + | function boundedString(value, limit, name) { | |
| 58 | + | if (typeof value !== 'string') fail(`${name} must be a string`); | |
| 59 | + | const byteLength = encoder.encode(value).byteLength; | |
| 60 | + | if (byteLength > limit) fail(`${name} exceeds its byte limit`); | |
| 61 | + | return { value, byteLength }; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | function limits(requested = {}) { | |
| 65 | + | object(requested, 'limits'); | |
| 66 | + | keys(requested, Object.keys(DEFAULT_LIMITS), 'limits'); | |
| 67 | + | const result = {}; | |
| 68 | + | for (const [name, fallback] of Object.entries(DEFAULT_LIMITS)) { | |
| 69 | + | result[name] = requested[name] === undefined ? fallback : integer(requested[name], 1, Number.MAX_SAFE_INTEGER, `limits.${name}`); | |
| 70 | + | } | |
| 71 | + | return Object.freeze(result); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | function validateRGB(value, name) { | |
| 75 | + | object(value, name); | |
| 76 | + | keys(value, ['r', 'g', 'b'], name); | |
| 77 | + | return { | |
| 78 | + | r: integer(value.r, 0, 255, `${name}.r`), | |
| 79 | + | g: integer(value.g, 0, 255, `${name}.g`), | |
| 80 | + | b: integer(value.b, 0, 255, `${name}.b`) | |
| 81 | + | }; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | function validateColor(value, name) { | |
| 85 | + | object(value, name); | |
| 86 | + | keys(value, ['kind', 'index', 'rgb'], name); | |
| 87 | + | const kind = integer(value.kind, 0, 2, `${name}.kind`); | |
| 88 | + | if (kind === 0) { | |
| 89 | + | if (value.index !== undefined || value.rgb !== undefined) fail(`${name} default color has a payload`); | |
| 90 | + | return { kind }; | |
| 91 | + | } | |
| 92 | + | if (kind === 1) { | |
| 93 | + | if (value.rgb !== undefined) fail(`${name} indexed color has an rgb payload`); | |
| 94 | + | return { kind, index: integer(value.index, 0, 255, `${name}.index`) }; | |
| 95 | + | } | |
| 96 | + | if (value.index !== undefined) fail(`${name} rgb color has an index payload`); | |
| 97 | + | return { kind, rgb: validateRGB(value.rgb, `${name}.rgb`) }; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | function validateStyle(value, index) { | |
| 101 | + | const name = `styles[${index}]`; | |
| 102 | + | object(value, name); | |
| 103 | + | keys(value, [ | |
| 104 | + | 'bold', 'italic', 'inverse', 'dim', 'blink', 'strikethrough', 'underline', | |
| 105 | + | 'underlineStyle', 'foreground', 'background', 'underlineColor' | |
| 106 | + | ], name); | |
| 107 | + | return { | |
| 108 | + | bold: boolean(value.bold, `${name}.bold`), | |
| 109 | + | italic: boolean(value.italic, `${name}.italic`), | |
| 110 | + | inverse: boolean(value.inverse, `${name}.inverse`), | |
| 111 | + | dim: boolean(value.dim, `${name}.dim`), | |
| 112 | + | blink: boolean(value.blink, `${name}.blink`), | |
| 113 | + | strikethrough: boolean(value.strikethrough, `${name}.strikethrough`), | |
| 114 | + | underline: boolean(value.underline, `${name}.underline`), | |
| 115 | + | underlineStyle: value.underlineStyle === undefined ? 0 : integer(value.underlineStyle, 0, 5, `${name}.underlineStyle`), | |
| 116 | + | foreground: validateColor(value.foreground, `${name}.foreground`), | |
| 117 | + | background: validateColor(value.background, `${name}.background`), | |
| 118 | + | underlineColor: validateColor(value.underlineColor, `${name}.underlineColor`) | |
| 119 | + | }; | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | function validateCursor(value, width, height) { | |
| 123 | + | object(value, 'cursor'); | |
| 124 | + | keys(value, ['row', 'column', 'visible', 'style', 'styleSet'], 'cursor'); | |
| 125 | + | const styleSet = boolean(value.styleSet, 'cursor.styleSet'); | |
| 126 | + | const style = integer(value.style, 0, 6, 'cursor.style'); | |
| 127 | + | if (!styleSet && style !== 0) fail('cursor style must be zero when unset'); | |
| 128 | + | return { | |
| 129 | + | row: integer(value.row, 0, height - 1, 'cursor.row'), | |
| 130 | + | column: integer(value.column, 0, width - 1, 'cursor.column'), | |
| 131 | + | visible: boolean(value.visible, 'cursor.visible'), | |
| 132 | + | style, | |
| 133 | + | styleSet | |
| 134 | + | }; | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | function validateRow(value, width, height, styleCount, configured, textBudget, seen) { | |
| 138 | + | object(value, 'row'); | |
| 139 | + | keys(value, ['row', 'cells'], 'row'); | |
| 140 | + | const row = integer(value.row, 0, height - 1, 'row.row'); | |
| 141 | + | if (seen.has(row)) fail(`duplicate row ${row}`); | |
| 142 | + | seen.add(row); | |
| 143 | + | if (!Array.isArray(value.cells)) fail(`row ${row} cells must be an array`); | |
| 144 | + | let column = 0; | |
| 145 | + | let bytes = 0; | |
| 146 | + | const cells = value.cells.map((candidate, index) => { | |
| 147 | + | const name = `row ${row} cell ${index}`; | |
| 148 | + | object(candidate, name); | |
| 149 | + | keys(candidate, ['column', 'width', 'text', 'style'], name); | |
| 150 | + | const current = integer(candidate.column, 0, width - 1, `${name}.column`); | |
| 151 | + | const cellWidth = integer(candidate.width, 1, 2, `${name}.width`); | |
| 152 | + | if (current !== column) fail(`row coverage is not contiguous at row ${row} column ${column}`); | |
| 153 | + | if (current + cellWidth > width) fail(`row coverage exceeds width at row ${row}`); | |
| 154 | + | const text = boundedString(candidate.text, configured.maxTextBytes, `${name}.text`); | |
| 155 | + | const cell = { | |
| 156 | + | column: current, | |
| 157 | + | width: cellWidth, | |
| 158 | + | text: text.value, | |
| 159 | + | style: integer(candidate.style, 0, styleCount - 1, `${name}.style`) | |
| 160 | + | }; | |
| 161 | + | textBudget.value += text.byteLength; | |
| 162 | + | if (textBudget.value > configured.maxUpdateBytes) fail('update text exceeds its aggregate byte limit'); | |
| 163 | + | bytes += text.byteLength; | |
| 164 | + | column += cellWidth; | |
| 165 | + | return cell; | |
| 166 | + | }); | |
| 167 | + | if (column !== width) fail(`row coverage is ${column}, expected ${width}`); | |
| 168 | + | return { row, cells, bytes }; | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /** | |
| 172 | + | * Validate an entire update before any DOM mutation. | |
| 173 | + | * @param {unknown} value | |
| 174 | + | * @param {Readonly<Record<string, number>>} configured | |
| 175 | + | * @returns {Update} | |
| 176 | + | */ | |
| 177 | + | function validateUpdate(value, configured) { | |
| 178 | + | object(value, 'update'); | |
| 179 | + | keys(value, ['schemaVersion', 'width', 'height', 'snapshot', 'rows', 'styles', 'cursor'], 'update'); | |
| 180 | + | if (value.schemaVersion !== SCHEMA_VERSION) fail(`unsupported update schema ${value.schemaVersion}`); | |
| 181 | + | const width = integer(value.width, 1, configured.maxColumns, 'update.width'); | |
| 182 | + | const height = integer(value.height, 1, configured.maxRowsPerUpdate, 'update.height'); | |
| 183 | + | if (width > Math.floor(configured.maxCells / height)) fail('update exceeds the cell limit'); | |
| 184 | + | const snapshot = boolean(value.snapshot, 'update.snapshot'); | |
| 185 | + | if (!Array.isArray(value.styles) || value.styles.length > configured.maxStyles) fail('update styles exceed their limit'); | |
| 186 | + | const styles = value.styles.map(validateStyle); | |
| 187 | + | if (!Array.isArray(value.rows) || value.rows.length > configured.maxRowsPerUpdate) fail('update rows exceed their limit'); | |
| 188 | + | if (value.rows.length > Math.floor(configured.maxCells / width)) fail('update represented cells exceed their limit'); | |
| 189 | + | if (value.rows.length > 0 && styles.length === 0) fail('an update with rows requires styles'); | |
| 190 | + | if (snapshot && value.rows.length !== height) fail('snapshot must contain every row'); | |
| 191 | + | const seen = new Set(); | |
| 192 | + | const textBudget = { value: 0 }; | |
| 193 | + | const rows = value.rows.map(row => validateRow(row, width, height, styles.length, configured, textBudget, seen)); | |
| 194 | + | if (snapshot) { | |
| 195 | + | rows.sort((left, right) => left.row - right.row); | |
| 196 | + | rows.forEach((row, index) => { if (row.row !== index) fail('snapshot rows are incomplete'); }); | |
| 197 | + | } | |
| 198 | + | return { schemaVersion: SCHEMA_VERSION, width, height, snapshot, rows, styles, cursor: validateCursor(value.cursor, width, height) }; | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | function xtermColor(index) { | |
| 202 | + | if (index < 16) return `var(--vev-color-${index})`; | |
| 203 | + | if (index < 232) { | |
| 204 | + | const value = index - 16; | |
| 205 | + | const levels = [0, 95, 135, 175, 215, 255]; | |
| 206 | + | const r = levels[Math.floor(value / 36)]; | |
| 207 | + | const g = levels[Math.floor(value / 6) % 6]; | |
| 208 | + | const b = levels[value % 6]; | |
| 209 | + | return `rgb(${r} ${g} ${b})`; | |
| 210 | + | } | |
| 211 | + | const gray = 8 + (index - 232) * 10; | |
| 212 | + | return `rgb(${gray} ${gray} ${gray})`; | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | function colorValue(color, fallback) { | |
| 216 | + | if (color.kind === 0) return fallback; | |
| 217 | + | if (color.kind === 1) return xtermColor(color.index); | |
| 218 | + | return `rgb(${color.rgb.r} ${color.rgb.g} ${color.rgb.b})`; | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | function addStyleClasses(node, style) { | |
| 222 | + | if (style.bold) node.classList.add('vev-terminal__cell--bold'); | |
| 223 | + | if (style.italic) node.classList.add('vev-terminal__cell--italic'); | |
| 224 | + | if (style.dim) node.classList.add('vev-terminal__cell--dim'); | |
| 225 | + | if (style.blink) node.classList.add('vev-terminal__cell--blink'); | |
| 226 | + | if (style.strikethrough) node.classList.add('vev-terminal__cell--strike'); | |
| 227 | + | if (style.underline) { | |
| 228 | + | node.classList.add('vev-terminal__cell--underline'); | |
| 229 | + | const suffix = ['single', 'single', 'double', 'wavy', 'dotted', 'dashed'][style.underlineStyle]; | |
| 230 | + | if (suffix !== 'single') node.classList.add(`vev-terminal__cell--underline-${suffix}`); | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | function buildRow(document, row, styles) { | |
| 235 | + | const node = document.createElement('div'); | |
| 236 | + | node.className = 'vev-terminal__row'; | |
| 237 | + | node.dataset.row = String(row.row); | |
| 238 | + | let text = ''; | |
| 239 | + | for (let index = 0; index < row.cells.length; index += 1) { | |
| 240 | + | let cell = row.cells[index]; | |
| 241 | + | const style = styles[cell.style]; | |
| 242 | + | // A run of spaces needs one grid item, not one item per column. Only | |
| 243 | + | // merge plain spaces: arbitrary text still needs explicit cell widths. | |
| 244 | + | if (cell.text === ' ' && cell.width === 1 && !style.underline && !style.strikethrough) { | |
| 245 | + | let width = cell.width; | |
| 246 | + | let spaces = cell.text; | |
| 247 | + | while (index + 1 < row.cells.length) { | |
| 248 | + | const next = row.cells[index + 1]; | |
| 249 | + | if (next.text !== ' ' || next.width !== 1 || next.style !== cell.style || next.column !== cell.column + width) break; | |
| 250 | + | width += next.width; | |
| 251 | + | spaces += next.text; | |
| 252 | + | index += 1; | |
| 253 | + | } | |
| 254 | + | cell = { ...cell, width, text: spaces }; | |
| 255 | + | } | |
| 256 | + | const child = document.createElement('span'); | |
| 257 | + | child.className = 'vev-terminal__cell'; | |
| 258 | + | child.dataset.column = String(cell.column); | |
| 259 | + | child.dataset.width = String(cell.width); | |
| 260 | + | child.style.gridColumn = `${cell.column + 1} / span ${cell.width}`; | |
| 261 | + | let foreground = colorValue(style.foreground, 'var(--vev-fg)'); | |
| 262 | + | let background = colorValue(style.background, 'var(--vev-bg)'); | |
| 263 | + | if (style.inverse) [foreground, background] = [background, foreground]; | |
| 264 | + | // Default colors already resolve through the stylesheet. Avoid three | |
| 265 | + | // redundant custom properties per cell (and their style-resolution cost). | |
| 266 | + | if (style.inverse || style.foreground.kind !== 0) child.style.setProperty('--vev-cell-fg', foreground); | |
| 267 | + | if (style.inverse || style.background.kind !== 0) child.style.setProperty('--vev-cell-bg', background); | |
| 268 | + | if (style.underlineColor.kind !== 0 || style.dim) child.style.setProperty('--vev-cell-underline', colorValue(style.underlineColor, foreground)); | |
| 269 | + | addStyleClasses(child, style); | |
| 270 | + | child.textContent = cell.text; | |
| 271 | + | // A plain space has no ink to clip. Avoid a compositor clip for every | |
| 272 | + | // blank cell while retaining its grid box, selection and text content. | |
| 273 | + | if (/^ +$/.test(cell.text) && !style.underline && !style.strikethrough) { | |
| 274 | + | child.style.overflow = 'visible'; | |
| 275 | + | } | |
| 276 | + | node.append(child); | |
| 277 | + | text += cell.text; | |
| 278 | + | } | |
| 279 | + | return { node, text }; | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | function validateTheme(theme) { | |
| 283 | + | object(theme, 'theme'); | |
| 284 | + | keys(theme, ['foreground', 'background', 'cursor', 'selection', 'selectionText', 'palette'], 'theme'); | |
| 285 | + | const result = { | |
| 286 | + | foreground: validateRGB(theme.foreground, 'theme.foreground'), | |
| 287 | + | background: validateRGB(theme.background, 'theme.background'), | |
| 288 | + | cursor: validateRGB(theme.cursor, 'theme.cursor'), | |
| 289 | + | selection: validateRGB(theme.selection, 'theme.selection'), | |
| 290 | + | selectionText: validateRGB(theme.selectionText, 'theme.selectionText') | |
| 291 | + | }; | |
| 292 | + | if (!Array.isArray(theme.palette) || theme.palette.length !== 16) fail('theme.palette must contain 16 colors'); | |
| 293 | + | result.palette = theme.palette.map((color, index) => validateRGB(color, `theme.palette[${index}]`)); | |
| 294 | + | return result; | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | function rgbValue(color) { return `rgb(${color.r} ${color.g} ${color.b})`; } | |
| 298 | + | ||
| 299 | + | /** | |
| 300 | + | * Mount one interactive terminal adapter. | |
| 301 | + | * @param {Element} root | |
| 302 | + | * @param {MountOptions} options | |
| 303 | + | * @returns {Terminal} | |
| 304 | + | */ | |
| 305 | + | function mount(root, options = {}) { | |
| 306 | + | if (!(root instanceof Element)) fail('mount root must be an Element'); | |
| 307 | + | if (root.classList.contains('vev-terminal')) fail('mount root already owns a terminal'); | |
| 308 | + | object(options, 'options'); | |
| 309 | + | keys(options, ['label', 'decide', 'send', 'limits'], 'options'); | |
| 310 | + | const label = boundedString(options.label, 1024, 'options.label').value.trim(); | |
| 311 | + | if (label === '') fail('options.label must not be empty'); | |
| 312 | + | if (options.decide !== undefined && typeof options.decide !== 'function') fail('options.decide must be a function'); | |
| 313 | + | if (options.send !== undefined && typeof options.send !== 'function') fail('options.send must be a function'); | |
| 314 | + | const configured = limits(options.limits); | |
| 315 | + | let decide = options.decide || (() => ({ emit: true, preventDefault: false })); | |
| 316 | + | let send = options.send || (() => {}); | |
| 317 | + | const document = root.ownerDocument; | |
| 318 | + | const abort = new AbortController(); | |
| 319 | + | const signal = abort.signal; | |
| 320 | + | ||
| 321 | + | const previousRole = root.hasAttribute('role') ? root.getAttribute('role') : null; | |
| 322 | + | const previousLabel = root.hasAttribute('aria-label') ? root.getAttribute('aria-label') : null; | |
| 323 | + | root.classList.add('vev-terminal'); | |
| 324 | + | root.setAttribute('role', 'group'); | |
| 325 | + | root.setAttribute('aria-label', label); | |
| 326 | + | ||
| 327 | + | const input = document.createElement('textarea'); | |
| 328 | + | input.className = 'vev-terminal__input'; | |
| 329 | + | input.setAttribute('aria-label', `${label} input`); | |
| 330 | + | input.setAttribute('autocomplete', 'off'); | |
| 331 | + | input.setAttribute('autocapitalize', 'off'); | |
| 332 | + | input.setAttribute('spellcheck', 'false'); | |
| 333 | + | ||
| 334 | + | const viewport = document.createElement('div'); | |
| 335 | + | viewport.className = 'vev-terminal__viewport'; | |
| 336 | + | viewport.setAttribute('aria-hidden', 'true'); | |
| 337 | + | ||
| 338 | + | const cursor = document.createElement('div'); | |
| 339 | + | cursor.className = 'vev-terminal__cursor'; | |
| 340 | + | cursor.hidden = true; | |
| 341 | + | viewport.append(cursor); | |
| 342 | + | ||
| 343 | + | const accessible = document.createElement('pre'); | |
| 344 | + | accessible.className = 'vev-terminal__accessible-output'; | |
| 345 | + | accessible.setAttribute('aria-live', 'off'); | |
| 346 | + | accessible.setAttribute('aria-label', `${label} output`); | |
| 347 | + | ||
| 348 | + | root.append(input, viewport, accessible); | |
| 349 | + | ||
| 350 | + | let destroyed = false; | |
| 351 | + | let composing = false; | |
| 352 | + | let editingKeyDown = null; | |
| 353 | + | let mouseCapture = false; | |
| 354 | + | let width = 0; | |
| 355 | + | let height = 0; | |
| 356 | + | let rowNodes = []; | |
| 357 | + | let rowText = []; | |
| 358 | + | let rowBytes = []; | |
| 359 | + | let retainedBytes = 0; | |
| 360 | + | let previousRows = []; | |
| 361 | + | let previousStyles = ''; | |
| 362 | + | let resizeFrame = 0; | |
| 363 | + | let pointerFrame = 0; | |
| 364 | + | let pendingPointer = null; | |
| 365 | + | let lastResize = ''; | |
| 366 | + | const previousProperties = new Map(); | |
| 367 | + | ||
| 368 | + | function setOwnedProperty(name, value) { | |
| 369 | + | if (!previousProperties.has(name)) { | |
| 370 | + | previousProperties.set(name, { | |
| 371 | + | value: root.style.getPropertyValue(name), | |
| 372 | + | priority: root.style.getPropertyPriority(name) | |
| 373 | + | }); | |
| 374 | + | } | |
| 375 | + | root.style.setProperty(name, value); | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | function alive() { | |
| 379 | + | if (destroyed) fail('instance is destroyed'); | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | /** @returns {Readonly<BrowserEvent>|null} */ | |
| 383 | + | function capture(event, nativeEvent) { | |
| 384 | + | const decision = decide(Object.freeze({ ...event })); | |
| 385 | + | object(decision, 'capture decision'); | |
| 386 | + | keys(decision, ['emit', 'preventDefault'], 'capture decision'); | |
| 387 | + | const emit = boolean(decision.emit, 'capture decision.emit'); | |
| 388 | + | const preventDefault = boolean(decision.preventDefault, 'capture decision.preventDefault'); | |
| 389 | + | if (preventDefault && nativeEvent && nativeEvent.cancelable) nativeEvent.preventDefault(); | |
| 390 | + | return emit ? Object.freeze({ schemaVersion: SCHEMA_VERSION, ...event }) : null; | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | function dispatch(event, nativeEvent) { | |
| 394 | + | const captured = capture(event, nativeEvent); | |
| 395 | + | if (captured) send(captured); | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | function updateCursor(state) { | |
| 399 | + | cursor.hidden = !state.visible; | |
| 400 | + | cursor.className = 'vev-terminal__cursor'; | |
| 401 | + | if (!state.visible) return; | |
| 402 | + | const style = state.styleSet ? state.style : 0; | |
| 403 | + | const bar = style === 5 || style === 6; | |
| 404 | + | const underline = style === 3 || style === 4; | |
| 405 | + | if (bar) cursor.classList.add('vev-terminal__cursor--bar'); | |
| 406 | + | if (underline) cursor.classList.add('vev-terminal__cursor--underline'); | |
| 407 | + | if (style === 0 || style === 1 || style === 3 || style === 5) cursor.classList.add('vev-terminal__cell--blink'); | |
| 408 | + | cursor.style.left = `calc(${state.column} * var(--vev-cell-width))`; | |
| 409 | + | cursor.style.top = `calc(${state.row} * var(--vev-cell-height))`; | |
| 410 | + | cursor.style.width = bar ? '' : 'var(--vev-cell-width)'; | |
| 411 | + | cursor.style.height = underline ? '' : 'var(--vev-cell-height)'; | |
| 412 | + | if (underline) cursor.style.marginTop = 'calc(var(--vev-cell-height) * 0.88)'; | |
| 413 | + | else cursor.style.marginTop = ''; | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | /** @param {Update} value */ | |
| 417 | + | function apply(value) { | |
| 418 | + | alive(); | |
| 419 | + | const update = validateUpdate(value, configured); | |
| 420 | + | if (!update.snapshot && (update.width !== width || update.height !== height || rowNodes.length === 0)) fail('incremental update dimensions do not match the mounted snapshot'); | |
| 421 | + | if (!update.snapshot) { | |
| 422 | + | for (let row = 0; row < height; row += 1) { | |
| 423 | + | if (!rowNodes[row] || rowNodes[row].parentNode !== viewport) fail(`mounted row ${row} is unavailable`); | |
| 424 | + | } | |
| 425 | + | } | |
| 426 | + | // maxUpdateBytes bounds the text retained by the mounted terminal, not | |
| 427 | + | // only the current delta: reject the projected retained total before | |
| 428 | + | // building or mutating the DOM. | |
| 429 | + | let projected = retainedBytes; | |
| 430 | + | for (const row of update.rows) { | |
| 431 | + | projected -= rowBytes[row.row] || 0; | |
| 432 | + | projected += row.bytes; | |
| 433 | + | } | |
| 434 | + | if (update.snapshot) projected = update.rows.reduce((total, row) => total + row.bytes, 0); | |
| 435 | + | if (projected > configured.maxUpdateBytes) fail('retained text exceeds its aggregate byte limit'); | |
| 436 | + | const styleKey = JSON.stringify(update.styles); | |
| 437 | + | const reusable = update.width === width && update.height === height && styleKey === previousStyles; | |
| 438 | + | const built = update.rows.map(row => { | |
| 439 | + | const old = previousRows[row.row]; | |
| 440 | + | const node = rowNodes[row.row]; | |
| 441 | + | // Text-only updates preserve geometry and styling. Space runs use | |
| 442 | + | // merged nodes and deliberately take the full rebuilding path. | |
| 443 | + | const reuse = reusable && node?.parentNode === viewport && old && | |
| 444 | + | old.cells.length === row.cells.length && node.children.length === row.cells.length && | |
| 445 | + | row.cells.every((cell, index) => { | |
| 446 | + | const before = old.cells[index]; | |
| 447 | + | return cell.column === before.column && cell.width === before.width && cell.style === before.style && | |
| 448 | + | !/^ +$/.test(cell.text) && !/^ +$/.test(before.text); | |
| 449 | + | }); | |
| 450 | + | return reuse ? { row: row.row, node, bytes: row.bytes, text: row.cells.map(cell => cell.text).join(''), cells: row.cells } : | |
| 451 | + | { row: row.row, bytes: row.bytes, ...buildRow(document, row, update.styles) }; | |
| 452 | + | }); | |
| 453 | + | for (const item of built) { | |
| 454 | + | if (item.cells) { | |
| 455 | + | item.cells.forEach((cell, index) => { | |
| 456 | + | const child = item.node.children[index]; | |
| 457 | + | if (child.textContent !== cell.text) child.textContent = cell.text; | |
| 458 | + | }); | |
| 459 | + | } | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | if (update.snapshot) { | |
| 463 | + | const reuseAll = built.every(item => item.cells); | |
| 464 | + | const fragment = document.createDocumentFragment(); | |
| 465 | + | const nextNodes = new Array(update.height); | |
| 466 | + | const nextText = new Array(update.height); | |
| 467 | + | const nextBytes = new Array(update.height).fill(0); | |
| 468 | + | for (const item of built) { | |
| 469 | + | nextNodes[item.row] = item.node; | |
| 470 | + | nextText[item.row] = item.text; | |
| 471 | + | nextBytes[item.row] = item.bytes; | |
| 472 | + | if (!reuseAll) fragment.append(item.node); | |
| 473 | + | } | |
| 474 | + | if (!reuseAll) viewport.replaceChildren(fragment, cursor); | |
| 475 | + | rowNodes = nextNodes; | |
| 476 | + | rowText = nextText; | |
| 477 | + | rowBytes = nextBytes; | |
| 478 | + | retainedBytes = projected; | |
| 479 | + | } else { | |
| 480 | + | for (const item of built) { | |
| 481 | + | if (!item.cells) rowNodes[item.row].replaceWith(item.node); | |
| 482 | + | rowNodes[item.row] = item.node; | |
| 483 | + | retainedBytes += item.bytes - (rowBytes[item.row] || 0); | |
| 484 | + | rowBytes[item.row] = item.bytes; | |
| 485 | + | rowText[item.row] = item.text; | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | if (update.snapshot || styleKey !== previousStyles) previousRows = new Array(update.height); | |
| 489 | + | for (const row of update.rows) previousRows[row.row] = row; | |
| 490 | + | previousStyles = styleKey; | |
| 491 | + | width = update.width; | |
| 492 | + | height = update.height; | |
| 493 | + | setOwnedProperty('--vev-cols', String(width)); | |
| 494 | + | setOwnedProperty('--vev-rows', String(height)); | |
| 495 | + | const accessibleText = rowText.join('\n'); | |
| 496 | + | if (accessible.textContent !== accessibleText) accessible.textContent = accessibleText; | |
| 497 | + | updateCursor(update.cursor); | |
| 498 | + | scheduleResize(); | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | function position(event) { | |
| 502 | + | if (width === 0 || height === 0) return { row: 0, column: 0, x: 0, y: 0 }; | |
| 503 | + | const bounds = viewport.getBoundingClientRect(); | |
| 504 | + | if (bounds.width <= 0 || bounds.height <= 0) return { row: 0, column: 0, x: 0, y: 0 }; | |
| 505 | + | const clientX = Number.isFinite(event.clientX) ? event.clientX : bounds.left; | |
| 506 | + | const clientY = Number.isFinite(event.clientY) ? event.clientY : bounds.top; | |
| 507 | + | const x = Math.max(0, Math.min(bounds.width, clientX - bounds.left)); | |
| 508 | + | const y = Math.max(0, Math.min(bounds.height, clientY - bounds.top)); | |
| 509 | + | const column = Math.min(width - 1, Math.floor(x / (bounds.width / width))); | |
| 510 | + | const row = Math.min(height - 1, Math.floor(y / (bounds.height / height))); | |
| 511 | + | return { row, column, x, y }; | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | function modifierFields(event) { | |
| 515 | + | return { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey }; | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | function pointerPayload(event, action) { | |
| 519 | + | const invalidButton = !Number.isInteger(event.button) || event.button < -1 || event.button > 4; | |
| 520 | + | const invalidButtons = !Number.isInteger(event.buttons) || event.buttons < 0 || event.buttons > 31; | |
| 521 | + | if (invalidButton || invalidButtons) return null; | |
| 522 | + | return { type: 'pointer', action, button: event.button, buttons: event.buttons, ...position(event), ...modifierFields(event) }; | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | function flushPointer() { | |
| 526 | + | pointerFrame = 0; | |
| 527 | + | const captured = pendingPointer; | |
| 528 | + | pendingPointer = null; | |
| 529 | + | if (captured) send(captured); | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | // A discrete pointer event must never be overtaken by an older deferred | |
| 533 | + | // move: flush the pending move first so consumers observe event order. | |
| 534 | + | function dispatchDiscretePointer(event, action, nativeEvent) { | |
| 535 | + | const payload = pointerPayload(event, action); | |
| 536 | + | if (!payload) return; | |
| 537 | + | if (pendingPointer) flushPointer(); | |
| 538 | + | dispatch(payload, nativeEvent); | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | function scheduleResize() { | |
| 542 | + | if (resizeFrame || width === 0 || height === 0) return; | |
| 543 | + | resizeFrame = requestAnimationFrame(() => { | |
| 544 | + | resizeFrame = 0; | |
| 545 | + | const bounds = viewport.getBoundingClientRect(); | |
| 546 | + | const cellWidth = bounds.width / width; | |
| 547 | + | const cellHeight = bounds.height / height; | |
| 548 | + | if (bounds.width <= 0 || bounds.height <= 0 || cellWidth <= 0 || cellHeight <= 0) return; | |
| 549 | + | const payload = { | |
| 550 | + | type: 'resize', columns: width, rows: height, | |
| 551 | + | pixelWidth: bounds.width, pixelHeight: bounds.height, | |
| 552 | + | cellWidth, cellHeight, devicePixelRatio: window.devicePixelRatio | |
| 553 | + | }; | |
| 554 | + | const identity = JSON.stringify(payload); | |
| 555 | + | if (identity !== lastResize) { | |
| 556 | + | lastResize = identity; | |
| 557 | + | dispatch(payload); | |
| 558 | + | } | |
| 559 | + | }); | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | input.addEventListener('beforeinput', event => { | |
| 563 | + | if (composing || event.isComposing || event.inputType === 'insertCompositionText' || event.inputType === 'insertFromPaste') return; | |
| 564 | + | const editingKey = { deleteContentBackward: 'Backspace', deleteContentForward: 'Delete', insertLineBreak: 'Enter', insertParagraph: 'Enter' }[event.inputType]; | |
| 565 | + | if (editingKey) { | |
| 566 | + | if (editingKeyDown === editingKey) return; | |
| 567 | + | dispatch({ type: 'key', key: editingKey, code: editingKey, repeat: false, location: 0, alt: false, ctrl: false, meta: false, shift: false }, event); | |
| 568 | + | return; | |
| 569 | + | } | |
| 570 | + | if (!event.data) return; | |
| 571 | + | if (encoder.encode(event.data).byteLength > configured.maxTextBytes) { | |
| 572 | + | event.preventDefault(); | |
| 573 | + | return; | |
| 574 | + | } | |
| 575 | + | dispatch({ type: 'text', text: event.data }, event); | |
| 576 | + | }, { signal }); | |
| 577 | + | input.addEventListener('input', event => { | |
| 578 | + | if (!composing && !event.isComposing) input.value = ''; | |
| 579 | + | }, { signal }); | |
| 580 | + | input.addEventListener('compositionstart', () => { composing = true; }, { signal }); | |
| 581 | + | input.addEventListener('compositionend', event => { | |
| 582 | + | composing = false; | |
| 583 | + | input.value = ''; | |
| 584 | + | if (event.data && encoder.encode(event.data).byteLength <= configured.maxTextBytes) { | |
| 585 | + | dispatch({ type: 'text', text: event.data }, event); | |
| 586 | + | } | |
| 587 | + | }, { signal }); | |
| 588 | + | input.addEventListener('keydown', event => { | |
| 589 | + | if (composing || event.isComposing) return; | |
| 590 | + | const key = event.key || 'Unidentified'; | |
| 591 | + | editingKeyDown = ['Backspace', 'Delete', 'Enter'].includes(key) ? key : null; | |
| 592 | + | const code = event.code || 'Unidentified'; | |
| 593 | + | if (key === 'Unidentified' || key === 'Process') return; | |
| 594 | + | const nonText = key.length !== 1 || event.ctrlKey || event.altKey || event.metaKey; | |
| 595 | + | if (!nonText) return; | |
| 596 | + | if (key.length > 128 || code.length > 128) { | |
| 597 | + | event.preventDefault(); | |
| 598 | + | return; | |
| 599 | + | } | |
| 600 | + | dispatch({ type: 'key', key, code, repeat: event.repeat, location: event.location, ...modifierFields(event) }, event); | |
| 601 | + | }, { signal }); | |
| 602 | + | input.addEventListener('keyup', () => { editingKeyDown = null; }, { signal }); | |
| 603 | + | input.addEventListener('blur', () => { editingKeyDown = null; }, { signal }); | |
| 604 | + | input.addEventListener('paste', event => { | |
| 605 | + | const text = event.clipboardData?.getData('text/plain') || ''; | |
| 606 | + | if (encoder.encode(text).byteLength > configured.maxPasteBytes) { | |
| 607 | + | event.preventDefault(); | |
| 608 | + | return; | |
| 609 | + | } | |
| 610 | + | dispatch({ type: 'paste', text }, event); | |
| 611 | + | }, { signal }); | |
| 612 | + | input.addEventListener('focus', event => dispatch({ type: 'focus', focused: true }, event), { signal }); | |
| 613 | + | input.addEventListener('blur', event => dispatch({ type: 'focus', focused: false }, event), { signal }); | |
| 614 | + | ||
| 615 | + | viewport.addEventListener('pointerdown', event => { | |
| 616 | + | if (mouseCapture && viewport.setPointerCapture) { | |
| 617 | + | try { | |
| 618 | + | if (event.isPrimary !== false) viewport.setPointerCapture(event.pointerId); | |
| 619 | + | } catch { | |
| 620 | + | // Pointer capture is best-effort; ordering still holds. | |
| 621 | + | } | |
| 622 | + | } | |
| 623 | + | dispatchDiscretePointer(event, 'down', event); | |
| 624 | + | }, { signal }); | |
| 625 | + | viewport.addEventListener('pointerup', event => { | |
| 626 | + | dispatchDiscretePointer(event, 'up', event); | |
| 627 | + | }, { signal }); | |
| 628 | + | viewport.addEventListener('pointercancel', event => { | |
| 629 | + | dispatchDiscretePointer(event, 'cancel', event); | |
| 630 | + | }, { signal }); | |
| 631 | + | viewport.addEventListener('pointermove', event => { | |
| 632 | + | const payload = pointerPayload(event, 'move'); | |
| 633 | + | if (!payload) return; | |
| 634 | + | const captured = capture(payload, event); | |
| 635 | + | if (!captured || destroyed) return; | |
| 636 | + | pendingPointer = captured; | |
| 637 | + | if (!pointerFrame) pointerFrame = requestAnimationFrame(flushPointer); | |
| 638 | + | }, { signal }); | |
| 639 | + | viewport.addEventListener('wheel', event => { | |
| 640 | + | const invalidDelta = !Number.isFinite(event.deltaX) || !Number.isFinite(event.deltaY); | |
| 641 | + | const invalidMode = !Number.isInteger(event.deltaMode) || event.deltaMode < 0 || event.deltaMode > 2; | |
| 642 | + | if (invalidDelta || invalidMode) return; | |
| 643 | + | const { row, column } = position(event); | |
| 644 | + | dispatch({ type: 'wheel', deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, row, column, ...modifierFields(event) }, event); | |
| 645 | + | }, { signal, passive: false }); | |
| 646 | + | ||
| 647 | + | const observer = new ResizeObserver(scheduleResize); | |
| 648 | + | observer.observe(root); | |
| 649 | + | ||
| 650 | + | return Object.freeze({ | |
| 651 | + | apply, | |
| 652 | + | focus() { alive(); input.focus(); }, | |
| 653 | + | setMouseCapture(enabled) { | |
| 654 | + | alive(); | |
| 655 | + | mouseCapture = Boolean(enabled); | |
| 656 | + | root.classList.toggle('vev-terminal--mouse-capture', mouseCapture); | |
| 657 | + | }, | |
| 658 | + | setTheme(theme) { | |
| 659 | + | alive(); | |
| 660 | + | const value = validateTheme(theme); | |
| 661 | + | setOwnedProperty('--vev-fg', rgbValue(value.foreground)); | |
| 662 | + | setOwnedProperty('--vev-bg', rgbValue(value.background)); | |
| 663 | + | setOwnedProperty('--vev-cursor', rgbValue(value.cursor)); | |
| 664 | + | setOwnedProperty('--vev-selection-bg', rgbValue(value.selection)); | |
| 665 | + | setOwnedProperty('--vev-selection-fg', rgbValue(value.selectionText)); | |
| 666 | + | value.palette.forEach((color, index) => setOwnedProperty(`--vev-color-${index}`, rgbValue(color))); | |
| 667 | + | }, | |
| 668 | + | destroy() { | |
| 669 | + | if (destroyed) return; | |
| 670 | + | destroyed = true; | |
| 671 | + | abort.abort(); | |
| 672 | + | observer.disconnect(); | |
| 673 | + | if (resizeFrame) cancelAnimationFrame(resizeFrame); | |
| 674 | + | if (pointerFrame) cancelAnimationFrame(pointerFrame); | |
| 675 | + | input.value = ''; | |
| 676 | + | accessible.textContent = ''; | |
| 677 | + | viewport.replaceChildren(); | |
| 678 | + | input.remove(); | |
| 679 | + | viewport.remove(); | |
| 680 | + | accessible.remove(); | |
| 681 | + | rowNodes = []; | |
| 682 | + | rowText = []; | |
| 683 | + | rowBytes = []; | |
| 684 | + | retainedBytes = 0; | |
| 685 | + | previousRows = []; | |
| 686 | + | previousStyles = ''; | |
| 687 | + | pendingPointer = null; | |
| 688 | + | width = 0; | |
| 689 | + | height = 0; | |
| 690 | + | lastResize = ''; | |
| 691 | + | decide = () => ({ emit: false, preventDefault: false }); | |
| 692 | + | send = () => {}; | |
| 693 | + | for (const [name, previous] of previousProperties) { | |
| 694 | + | if (previous.value === '') root.style.removeProperty(name); | |
| 695 | + | else root.style.setProperty(name, previous.value, previous.priority); | |
| 696 | + | } | |
| 697 | + | previousProperties.clear(); | |
| 698 | + | root.classList.remove('vev-terminal', 'vev-terminal--mouse-capture'); | |
| 699 | + | if (previousRole === null) root.removeAttribute('role'); | |
| 700 | + | else root.setAttribute('role', previousRole); | |
| 701 | + | if (previousLabel === null) root.removeAttribute('aria-label'); | |
| 702 | + | else root.setAttribute('aria-label', previousLabel); | |
| 703 | + | } | |
| 704 | + | }); | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | globalThis.VevTerminal = Object.freeze({ schemaVersion: SCHEMA_VERSION, mount }); | |
| 708 | + | })(); | |
| @@ -0,0 +1,708 @@ | |||||
| 1 | + | (() => { | |||
| 2 | + | 'use strict'; | |||
| 3 | + | ||||
| 4 | + | /** @typedef {{r: number, g: number, b: number}} RGB */ | |||
| 5 | + | /** @typedef {{kind: 0}|{kind: 1, index: number}|{kind: 2, rgb: RGB}} Color */ | |||
| 6 | + | /** @typedef {{bold?: boolean, italic?: boolean, inverse?: boolean, dim?: boolean, blink?: boolean, strikethrough?: boolean, underline?: boolean, underlineStyle?: number, foreground: Color, background: Color, underlineColor: Color}} TerminalStyle */ | |||
| 7 | + | /** @typedef {{column: number, width: number, text: string, style: number}} CellUpdate */ | |||
| 8 | + | /** @typedef {{row: number, cells: CellUpdate[]}} RowUpdate */ | |||
| 9 | + | /** @typedef {{row: number, column: number, visible: boolean, style: number, styleSet: boolean}} Cursor */ | |||
| 10 | + | /** @typedef {{schemaVersion: number, width: number, height: number, snapshot: boolean, rows: RowUpdate[], styles: TerminalStyle[], cursor: Cursor}} Update */ | |||
| 11 | + | /** @typedef {{emit: boolean, preventDefault: boolean}} CaptureDecision */ | |||
| 12 | + | /** @typedef {{schemaVersion?: number, type: string, [key: string]: unknown}} BrowserEvent */ | |||
| 13 | + | /** @typedef {{maxCells?: number, maxRowsPerUpdate?: number, maxColumns?: number, maxStyles?: number, maxTextBytes?: number, maxUpdateBytes?: number, maxPasteBytes?: number}} BrowserLimits */ | |||
| 14 | + | /** @typedef {{foreground: RGB, background: RGB, cursor: RGB, selection: RGB, selectionText: RGB, palette: RGB[]}} TerminalTheme */ | |||
| 15 | + | /** @typedef {{label: string, decide?: (event: BrowserEvent) => CaptureDecision, send?: (event: BrowserEvent) => void, limits?: BrowserLimits}} MountOptions */ | |||
| 16 | + | /** @typedef {{apply(update: Update): void, focus(): void, setMouseCapture(enabled: boolean): void, setTheme(theme: TerminalTheme): void, destroy(): void}} Terminal */ | |||
| 17 | + | ||||
| 18 | + | const SCHEMA_VERSION = 1; | |||
| 19 | + | const DEFAULT_LIMITS = Object.freeze({ | |||
| 20 | + | maxCells: 1_000_000, | |||
| 21 | + | maxRowsPerUpdate: 10_000, | |||
| 22 | + | maxColumns: 10_000, | |||
| 23 | + | maxStyles: 65_536, | |||
| 24 | + | maxTextBytes: 64 << 10, | |||
| 25 | + | maxUpdateBytes: 64 << 20, | |||
| 26 | + | maxPasteBytes: 1 << 20 | |||
| 27 | + | }); | |||
| 28 | + | const encoder = new TextEncoder(); | |||
| 29 | + | ||||
| 30 | + | function fail(message) { | |||
| 31 | + | throw new TypeError(`VevTerminal: ${message}`); | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | function object(value, name) { | |||
| 35 | + | if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(`${name} must be an object`); | |||
| 36 | + | return value; | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | function keys(value, allowed, name) { | |||
| 40 | + | for (const key of Object.keys(value)) { | |||
| 41 | + | if (!allowed.includes(key)) fail(`${name} contains unknown field ${key}`); | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | function integer(value, minimum, maximum, name) { | |||
| 46 | + | if (!Number.isSafeInteger(value) || value < minimum || value > maximum) fail(`${name} is out of range`); | |||
| 47 | + | return value; | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | function boolean(value, name, fallback = false) { | |||
| 51 | + | if (value === undefined) return fallback; | |||
| 52 | + | if (typeof value !== 'boolean') fail(`${name} must be boolean`); | |||
| 53 | + | return value; | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | /** @returns {{value: string, byteLength: number}} */ | |||
| 57 | + | function boundedString(value, limit, name) { | |||
| 58 | + | if (typeof value !== 'string') fail(`${name} must be a string`); | |||
| 59 | + | const byteLength = encoder.encode(value).byteLength; | |||
| 60 | + | if (byteLength > limit) fail(`${name} exceeds its byte limit`); | |||
| 61 | + | return { value, byteLength }; | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | function limits(requested = {}) { | |||
| 65 | + | object(requested, 'limits'); | |||
| 66 | + | keys(requested, Object.keys(DEFAULT_LIMITS), 'limits'); | |||
| 67 | + | const result = {}; | |||
| 68 | + | for (const [name, fallback] of Object.entries(DEFAULT_LIMITS)) { | |||
| 69 | + | result[name] = requested[name] === undefined ? fallback : integer(requested[name], 1, Number.MAX_SAFE_INTEGER, `limits.${name}`); | |||
| 70 | + | } | |||
| 71 | + | return Object.freeze(result); | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | function validateRGB(value, name) { | |||
| 75 | + | object(value, name); | |||
| 76 | + | keys(value, ['r', 'g', 'b'], name); | |||
| 77 | + | return { | |||
| 78 | + | r: integer(value.r, 0, 255, `${name}.r`), | |||
| 79 | + | g: integer(value.g, 0, 255, `${name}.g`), | |||
| 80 | + | b: integer(value.b, 0, 255, `${name}.b`) | |||
| 81 | + | }; | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | function validateColor(value, name) { | |||
| 85 | + | object(value, name); | |||
| 86 | + | keys(value, ['kind', 'index', 'rgb'], name); | |||
| 87 | + | const kind = integer(value.kind, 0, 2, `${name}.kind`); | |||
| 88 | + | if (kind === 0) { | |||
| 89 | + | if (value.index !== undefined || value.rgb !== undefined) fail(`${name} default color has a payload`); | |||
| 90 | + | return { kind }; | |||
| 91 | + | } | |||
| 92 | + | if (kind === 1) { | |||
| 93 | + | if (value.rgb !== undefined) fail(`${name} indexed color has an rgb payload`); | |||
| 94 | + | return { kind, index: integer(value.index, 0, 255, `${name}.index`) }; | |||
| 95 | + | } | |||
| 96 | + | if (value.index !== undefined) fail(`${name} rgb color has an index payload`); | |||
| 97 | + | return { kind, rgb: validateRGB(value.rgb, `${name}.rgb`) }; | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | function validateStyle(value, index) { | |||
| 101 | + | const name = `styles[${index}]`; | |||
| 102 | + | object(value, name); | |||
| 103 | + | keys(value, [ | |||
| 104 | + | 'bold', 'italic', 'inverse', 'dim', 'blink', 'strikethrough', 'underline', | |||
| 105 | + | 'underlineStyle', 'foreground', 'background', 'underlineColor' | |||
| 106 | + | ], name); | |||
| 107 | + | return { | |||
| 108 | + | bold: boolean(value.bold, `${name}.bold`), | |||
| 109 | + | italic: boolean(value.italic, `${name}.italic`), | |||
| 110 | + | inverse: boolean(value.inverse, `${name}.inverse`), | |||
| 111 | + | dim: boolean(value.dim, `${name}.dim`), | |||
| 112 | + | blink: boolean(value.blink, `${name}.blink`), | |||
| 113 | + | strikethrough: boolean(value.strikethrough, `${name}.strikethrough`), | |||
| 114 | + | underline: boolean(value.underline, `${name}.underline`), | |||
| 115 | + | underlineStyle: value.underlineStyle === undefined ? 0 : integer(value.underlineStyle, 0, 5, `${name}.underlineStyle`), | |||
| 116 | + | foreground: validateColor(value.foreground, `${name}.foreground`), | |||
| 117 | + | background: validateColor(value.background, `${name}.background`), | |||
| 118 | + | underlineColor: validateColor(value.underlineColor, `${name}.underlineColor`) | |||
| 119 | + | }; | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | function validateCursor(value, width, height) { | |||
| 123 | + | object(value, 'cursor'); | |||
| 124 | + | keys(value, ['row', 'column', 'visible', 'style', 'styleSet'], 'cursor'); | |||
| 125 | + | const styleSet = boolean(value.styleSet, 'cursor.styleSet'); | |||
| 126 | + | const style = integer(value.style, 0, 6, 'cursor.style'); | |||
| 127 | + | if (!styleSet && style !== 0) fail('cursor style must be zero when unset'); | |||
| 128 | + | return { | |||
| 129 | + | row: integer(value.row, 0, height - 1, 'cursor.row'), | |||
| 130 | + | column: integer(value.column, 0, width - 1, 'cursor.column'), | |||
| 131 | + | visible: boolean(value.visible, 'cursor.visible'), | |||
| 132 | + | style, | |||
| 133 | + | styleSet | |||
| 134 | + | }; | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | function validateRow(value, width, height, styleCount, configured, textBudget, seen) { | |||
| 138 | + | object(value, 'row'); | |||
| 139 | + | keys(value, ['row', 'cells'], 'row'); | |||
| 140 | + | const row = integer(value.row, 0, height - 1, 'row.row'); | |||
| 141 | + | if (seen.has(row)) fail(`duplicate row ${row}`); | |||
| 142 | + | seen.add(row); | |||
| 143 | + | if (!Array.isArray(value.cells)) fail(`row ${row} cells must be an array`); | |||
| 144 | + | let column = 0; | |||
| 145 | + | let bytes = 0; | |||
| 146 | + | const cells = value.cells.map((candidate, index) => { | |||
| 147 | + | const name = `row ${row} cell ${index}`; | |||
| 148 | + | object(candidate, name); | |||
| 149 | + | keys(candidate, ['column', 'width', 'text', 'style'], name); | |||
| 150 | + | const current = integer(candidate.column, 0, width - 1, `${name}.column`); | |||
| 151 | + | const cellWidth = integer(candidate.width, 1, 2, `${name}.width`); | |||
| 152 | + | if (current !== column) fail(`row coverage is not contiguous at row ${row} column ${column}`); | |||
| 153 | + | if (current + cellWidth > width) fail(`row coverage exceeds width at row ${row}`); | |||
| 154 | + | const text = boundedString(candidate.text, configured.maxTextBytes, `${name}.text`); | |||
| 155 | + | const cell = { | |||
| 156 | + | column: current, | |||
| 157 | + | width: cellWidth, | |||
| 158 | + | text: text.value, | |||
| 159 | + | style: integer(candidate.style, 0, styleCount - 1, `${name}.style`) | |||
| 160 | + | }; | |||
| 161 | + | textBudget.value += text.byteLength; | |||
| 162 | + | if (textBudget.value > configured.maxUpdateBytes) fail('update text exceeds its aggregate byte limit'); | |||
| 163 | + | bytes += text.byteLength; | |||
| 164 | + | column += cellWidth; | |||
| 165 | + | return cell; | |||
| 166 | + | }); | |||
| 167 | + | if (column !== width) fail(`row coverage is ${column}, expected ${width}`); | |||
| 168 | + | return { row, cells, bytes }; | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | /** | |||
| 172 | + | * Validate an entire update before any DOM mutation. | |||
| 173 | + | * @param {unknown} value | |||
| 174 | + | * @param {Readonly<Record<string, number>>} configured | |||
| 175 | + | * @returns {Update} | |||
| 176 | + | */ | |||
| 177 | + | function validateUpdate(value, configured) { | |||
| 178 | + | object(value, 'update'); | |||
| 179 | + | keys(value, ['schemaVersion', 'width', 'height', 'snapshot', 'rows', 'styles', 'cursor'], 'update'); | |||
| 180 | + | if (value.schemaVersion !== SCHEMA_VERSION) fail(`unsupported update schema ${value.schemaVersion}`); | |||
| 181 | + | const width = integer(value.width, 1, configured.maxColumns, 'update.width'); | |||
| 182 | + | const height = integer(value.height, 1, configured.maxRowsPerUpdate, 'update.height'); | |||
| 183 | + | if (width > Math.floor(configured.maxCells / height)) fail('update exceeds the cell limit'); | |||
| 184 | + | const snapshot = boolean(value.snapshot, 'update.snapshot'); | |||
| 185 | + | if (!Array.isArray(value.styles) || value.styles.length > configured.maxStyles) fail('update styles exceed their limit'); | |||
| 186 | + | const styles = value.styles.map(validateStyle); | |||
| 187 | + | if (!Array.isArray(value.rows) || value.rows.length > configured.maxRowsPerUpdate) fail('update rows exceed their limit'); | |||
| 188 | + | if (value.rows.length > Math.floor(configured.maxCells / width)) fail('update represented cells exceed their limit'); | |||
| 189 | + | if (value.rows.length > 0 && styles.length === 0) fail('an update with rows requires styles'); | |||
| 190 | + | if (snapshot && value.rows.length !== height) fail('snapshot must contain every row'); | |||
| 191 | + | const seen = new Set(); | |||
| 192 | + | const textBudget = { value: 0 }; | |||
| 193 | + | const rows = value.rows.map(row => validateRow(row, width, height, styles.length, configured, textBudget, seen)); | |||
| 194 | + | if (snapshot) { | |||
| 195 | + | rows.sort((left, right) => left.row - right.row); | |||
| 196 | + | rows.forEach((row, index) => { if (row.row !== index) fail('snapshot rows are incomplete'); }); | |||
| 197 | + | } | |||
| 198 | + | return { schemaVersion: SCHEMA_VERSION, width, height, snapshot, rows, styles, cursor: validateCursor(value.cursor, width, height) }; | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | function xtermColor(index) { | |||
| 202 | + | if (index < 16) return `var(--vev-color-${index})`; | |||
| 203 | + | if (index < 232) { | |||
| 204 | + | const value = index - 16; | |||
| 205 | + | const levels = [0, 95, 135, 175, 215, 255]; | |||
| 206 | + | const r = levels[Math.floor(value / 36)]; | |||
| 207 | + | const g = levels[Math.floor(value / 6) % 6]; | |||
| 208 | + | const b = levels[value % 6]; | |||
| 209 | + | return `rgb(${r} ${g} ${b})`; | |||
| 210 | + | } | |||
| 211 | + | const gray = 8 + (index - 232) * 10; | |||
| 212 | + | return `rgb(${gray} ${gray} ${gray})`; | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | function colorValue(color, fallback) { | |||
| 216 | + | if (color.kind === 0) return fallback; | |||
| 217 | + | if (color.kind === 1) return xtermColor(color.index); | |||
| 218 | + | return `rgb(${color.rgb.r} ${color.rgb.g} ${color.rgb.b})`; | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | function addStyleClasses(node, style) { | |||
| 222 | + | if (style.bold) node.classList.add('vev-terminal__cell--bold'); | |||
| 223 | + | if (style.italic) node.classList.add('vev-terminal__cell--italic'); | |||
| 224 | + | if (style.dim) node.classList.add('vev-terminal__cell--dim'); | |||
| 225 | + | if (style.blink) node.classList.add('vev-terminal__cell--blink'); | |||
| 226 | + | if (style.strikethrough) node.classList.add('vev-terminal__cell--strike'); | |||
| 227 | + | if (style.underline) { | |||
| 228 | + | node.classList.add('vev-terminal__cell--underline'); | |||
| 229 | + | const suffix = ['single', 'single', 'double', 'wavy', 'dotted', 'dashed'][style.underlineStyle]; | |||
| 230 | + | if (suffix !== 'single') node.classList.add(`vev-terminal__cell--underline-${suffix}`); | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | function buildRow(document, row, styles) { | |||
| 235 | + | const node = document.createElement('div'); | |||
| 236 | + | node.className = 'vev-terminal__row'; | |||
| 237 | + | node.dataset.row = String(row.row); | |||
| 238 | + | let text = ''; | |||
| 239 | + | for (let index = 0; index < row.cells.length; index += 1) { | |||
| 240 | + | let cell = row.cells[index]; | |||
| 241 | + | const style = styles[cell.style]; | |||
| 242 | + | // A run of spaces needs one grid item, not one item per column. Only | |||
| 243 | + | // merge plain spaces: arbitrary text still needs explicit cell widths. | |||
| 244 | + | if (cell.text === ' ' && cell.width === 1 && !style.underline && !style.strikethrough) { | |||
| 245 | + | let width = cell.width; | |||
| 246 | + | let spaces = cell.text; | |||
| 247 | + | while (index + 1 < row.cells.length) { | |||
| 248 | + | const next = row.cells[index + 1]; | |||
| 249 | + | if (next.text !== ' ' || next.width !== 1 || next.style !== cell.style || next.column !== cell.column + width) break; | |||
| 250 | + | width += next.width; | |||
| 251 | + | spaces += next.text; | |||
| 252 | + | index += 1; | |||
| 253 | + | } | |||
| 254 | + | cell = { ...cell, width, text: spaces }; | |||
| 255 | + | } | |||
| 256 | + | const child = document.createElement('span'); | |||
| 257 | + | child.className = 'vev-terminal__cell'; | |||
| 258 | + | child.dataset.column = String(cell.column); | |||
| 259 | + | child.dataset.width = String(cell.width); | |||
| 260 | + | child.style.gridColumn = `${cell.column + 1} / span ${cell.width}`; | |||
| 261 | + | let foreground = colorValue(style.foreground, 'var(--vev-fg)'); | |||
| 262 | + | let background = colorValue(style.background, 'var(--vev-bg)'); | |||
| 263 | + | if (style.inverse) [foreground, background] = [background, foreground]; | |||
| 264 | + | // Default colors already resolve through the stylesheet. Avoid three | |||
| 265 | + | // redundant custom properties per cell (and their style-resolution cost). | |||
| 266 | + | if (style.inverse || style.foreground.kind !== 0) child.style.setProperty('--vev-cell-fg', foreground); | |||
| 267 | + | if (style.inverse || style.background.kind !== 0) child.style.setProperty('--vev-cell-bg', background); | |||
| 268 | + | if (style.underlineColor.kind !== 0 || style.dim) child.style.setProperty('--vev-cell-underline', colorValue(style.underlineColor, foreground)); | |||
| 269 | + | addStyleClasses(child, style); | |||
| 270 | + | child.textContent = cell.text; | |||
| 271 | + | // A plain space has no ink to clip. Avoid a compositor clip for every | |||
| 272 | + | // blank cell while retaining its grid box, selection and text content. | |||
| 273 | + | if (/^ +$/.test(cell.text) && !style.underline && !style.strikethrough) { | |||
| 274 | + | child.style.overflow = 'visible'; | |||
| 275 | + | } | |||
| 276 | + | node.append(child); | |||
| 277 | + | text += cell.text; | |||
| 278 | + | } | |||
| 279 | + | return { node, text }; | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | function validateTheme(theme) { | |||
| 283 | + | object(theme, 'theme'); | |||
| 284 | + | keys(theme, ['foreground', 'background', 'cursor', 'selection', 'selectionText', 'palette'], 'theme'); | |||
| 285 | + | const result = { | |||
| 286 | + | foreground: validateRGB(theme.foreground, 'theme.foreground'), | |||
| 287 | + | background: validateRGB(theme.background, 'theme.background'), | |||
| 288 | + | cursor: validateRGB(theme.cursor, 'theme.cursor'), | |||
| 289 | + | selection: validateRGB(theme.selection, 'theme.selection'), | |||
| 290 | + | selectionText: validateRGB(theme.selectionText, 'theme.selectionText') | |||
| 291 | + | }; | |||
| 292 | + | if (!Array.isArray(theme.palette) || theme.palette.length !== 16) fail('theme.palette must contain 16 colors'); | |||
| 293 | + | result.palette = theme.palette.map((color, index) => validateRGB(color, `theme.palette[${index}]`)); | |||
| 294 | + | return result; | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | function rgbValue(color) { return `rgb(${color.r} ${color.g} ${color.b})`; } | |||
| 298 | + | ||||
| 299 | + | /** | |||
| 300 | + | * Mount one interactive terminal adapter. | |||
| 301 | + | * @param {Element} root | |||
| 302 | + | * @param {MountOptions} options | |||
| 303 | + | * @returns {Terminal} | |||
| 304 | + | */ | |||
| 305 | + | function mount(root, options = {}) { | |||
| 306 | + | if (!(root instanceof Element)) fail('mount root must be an Element'); | |||
| 307 | + | if (root.classList.contains('vev-terminal')) fail('mount root already owns a terminal'); | |||
| 308 | + | object(options, 'options'); | |||
| 309 | + | keys(options, ['label', 'decide', 'send', 'limits'], 'options'); | |||
| 310 | + | const label = boundedString(options.label, 1024, 'options.label').value.trim(); | |||
| 311 | + | if (label === '') fail('options.label must not be empty'); | |||
| 312 | + | if (options.decide !== undefined && typeof options.decide !== 'function') fail('options.decide must be a function'); | |||
| 313 | + | if (options.send !== undefined && typeof options.send !== 'function') fail('options.send must be a function'); | |||
| 314 | + | const configured = limits(options.limits); | |||
| 315 | + | let decide = options.decide || (() => ({ emit: true, preventDefault: false })); | |||
| 316 | + | let send = options.send || (() => {}); | |||
| 317 | + | const document = root.ownerDocument; | |||
| 318 | + | const abort = new AbortController(); | |||
| 319 | + | const signal = abort.signal; | |||
| 320 | + | ||||
| 321 | + | const previousRole = root.hasAttribute('role') ? root.getAttribute('role') : null; | |||
| 322 | + | const previousLabel = root.hasAttribute('aria-label') ? root.getAttribute('aria-label') : null; | |||
| 323 | + | root.classList.add('vev-terminal'); | |||
| 324 | + | root.setAttribute('role', 'group'); | |||
| 325 | + | root.setAttribute('aria-label', label); | |||
| 326 | + | ||||
| 327 | + | const input = document.createElement('textarea'); | |||
| 328 | + | input.className = 'vev-terminal__input'; | |||
| 329 | + | input.setAttribute('aria-label', `${label} input`); | |||
| 330 | + | input.setAttribute('autocomplete', 'off'); | |||
| 331 | + | input.setAttribute('autocapitalize', 'off'); | |||
| 332 | + | input.setAttribute('spellcheck', 'false'); | |||
| 333 | + | ||||
| 334 | + | const viewport = document.createElement('div'); | |||
| 335 | + | viewport.className = 'vev-terminal__viewport'; | |||
| 336 | + | viewport.setAttribute('aria-hidden', 'true'); | |||
| 337 | + | ||||
| 338 | + | const cursor = document.createElement('div'); | |||
| 339 | + | cursor.className = 'vev-terminal__cursor'; | |||
| 340 | + | cursor.hidden = true; | |||
| 341 | + | viewport.append(cursor); | |||
| 342 | + | ||||
| 343 | + | const accessible = document.createElement('pre'); | |||
| 344 | + | accessible.className = 'vev-terminal__accessible-output'; | |||
| 345 | + | accessible.setAttribute('aria-live', 'off'); | |||
| 346 | + | accessible.setAttribute('aria-label', `${label} output`); | |||
| 347 | + | ||||
| 348 | + | root.append(input, viewport, accessible); | |||
| 349 | + | ||||
| 350 | + | let destroyed = false; | |||
| 351 | + | let composing = false; | |||
| 352 | + | let editingKeyDown = null; | |||
| 353 | + | let mouseCapture = false; | |||
| 354 | + | let width = 0; | |||
| 355 | + | let height = 0; | |||
| 356 | + | let rowNodes = []; | |||
| 357 | + | let rowText = []; | |||
| 358 | + | let rowBytes = []; | |||
| 359 | + | let retainedBytes = 0; | |||
| 360 | + | let previousRows = []; | |||
| 361 | + | let previousStyles = ''; | |||
| 362 | + | let resizeFrame = 0; | |||
| 363 | + | let pointerFrame = 0; | |||
| 364 | + | let pendingPointer = null; | |||
| 365 | + | let lastResize = ''; | |||
| 366 | + | const previousProperties = new Map(); | |||
| 367 | + | ||||
| 368 | + | function setOwnedProperty(name, value) { | |||
| 369 | + | if (!previousProperties.has(name)) { | |||
| 370 | + | previousProperties.set(name, { | |||
| 371 | + | value: root.style.getPropertyValue(name), | |||
| 372 | + | priority: root.style.getPropertyPriority(name) | |||
| 373 | + | }); | |||
| 374 | + | } | |||
| 375 | + | root.style.setProperty(name, value); | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | function alive() { | |||
| 379 | + | if (destroyed) fail('instance is destroyed'); | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | /** @returns {Readonly<BrowserEvent>|null} */ | |||
| 383 | + | function capture(event, nativeEvent) { | |||
| 384 | + | const decision = decide(Object.freeze({ ...event })); | |||
| 385 | + | object(decision, 'capture decision'); | |||
| 386 | + | keys(decision, ['emit', 'preventDefault'], 'capture decision'); | |||
| 387 | + | const emit = boolean(decision.emit, 'capture decision.emit'); | |||
| 388 | + | const preventDefault = boolean(decision.preventDefault, 'capture decision.preventDefault'); | |||
| 389 | + | if (preventDefault && nativeEvent && nativeEvent.cancelable) nativeEvent.preventDefault(); | |||
| 390 | + | return emit ? Object.freeze({ schemaVersion: SCHEMA_VERSION, ...event }) : null; | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | function dispatch(event, nativeEvent) { | |||
| 394 | + | const captured = capture(event, nativeEvent); | |||
| 395 | + | if (captured) send(captured); | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | function updateCursor(state) { | |||
| 399 | + | cursor.hidden = !state.visible; | |||
| 400 | + | cursor.className = 'vev-terminal__cursor'; | |||
| 401 | + | if (!state.visible) return; | |||
| 402 | + | const style = state.styleSet ? state.style : 0; | |||
| 403 | + | const bar = style === 5 || style === 6; | |||
| 404 | + | const underline = style === 3 || style === 4; | |||
| 405 | + | if (bar) cursor.classList.add('vev-terminal__cursor--bar'); | |||
| 406 | + | if (underline) cursor.classList.add('vev-terminal__cursor--underline'); | |||
| 407 | + | if (style === 0 || style === 1 || style === 3 || style === 5) cursor.classList.add('vev-terminal__cell--blink'); | |||
| 408 | + | cursor.style.left = `calc(${state.column} * var(--vev-cell-width))`; | |||
| 409 | + | cursor.style.top = `calc(${state.row} * var(--vev-cell-height))`; | |||
| 410 | + | cursor.style.width = bar ? '' : 'var(--vev-cell-width)'; | |||
| 411 | + | cursor.style.height = underline ? '' : 'var(--vev-cell-height)'; | |||
| 412 | + | if (underline) cursor.style.marginTop = 'calc(var(--vev-cell-height) * 0.88)'; | |||
| 413 | + | else cursor.style.marginTop = ''; | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | /** @param {Update} value */ | |||
| 417 | + | function apply(value) { | |||
| 418 | + | alive(); | |||
| 419 | + | const update = validateUpdate(value, configured); | |||
| 420 | + | if (!update.snapshot && (update.width !== width || update.height !== height || rowNodes.length === 0)) fail('incremental update dimensions do not match the mounted snapshot'); | |||
| 421 | + | if (!update.snapshot) { | |||
| 422 | + | for (let row = 0; row < height; row += 1) { | |||
| 423 | + | if (!rowNodes[row] || rowNodes[row].parentNode !== viewport) fail(`mounted row ${row} is unavailable`); | |||
| 424 | + | } | |||
| 425 | + | } | |||
| 426 | + | // maxUpdateBytes bounds the text retained by the mounted terminal, not | |||
| 427 | + | // only the current delta: reject the projected retained total before | |||
| 428 | + | // building or mutating the DOM. | |||
| 429 | + | let projected = retainedBytes; | |||
| 430 | + | for (const row of update.rows) { | |||
| 431 | + | projected -= rowBytes[row.row] || 0; | |||
| 432 | + | projected += row.bytes; | |||
| 433 | + | } | |||
| 434 | + | if (update.snapshot) projected = update.rows.reduce((total, row) => total + row.bytes, 0); | |||
| 435 | + | if (projected > configured.maxUpdateBytes) fail('retained text exceeds its aggregate byte limit'); | |||
| 436 | + | const styleKey = JSON.stringify(update.styles); | |||
| 437 | + | const reusable = update.width === width && update.height === height && styleKey === previousStyles; | |||
| 438 | + | const built = update.rows.map(row => { | |||
| 439 | + | const old = previousRows[row.row]; | |||
| 440 | + | const node = rowNodes[row.row]; | |||
| 441 | + | // Text-only updates preserve geometry and styling. Space runs use | |||
| 442 | + | // merged nodes and deliberately take the full rebuilding path. | |||
| 443 | + | const reuse = reusable && node?.parentNode === viewport && old && | |||
| 444 | + | old.cells.length === row.cells.length && node.children.length === row.cells.length && | |||
| 445 | + | row.cells.every((cell, index) => { | |||
| 446 | + | const before = old.cells[index]; | |||
| 447 | + | return cell.column === before.column && cell.width === before.width && cell.style === before.style && | |||
| 448 | + | !/^ +$/.test(cell.text) && !/^ +$/.test(before.text); | |||
| 449 | + | }); | |||
| 450 | + | return reuse ? { row: row.row, node, bytes: row.bytes, text: row.cells.map(cell => cell.text).join(''), cells: row.cells } : | |||
| 451 | + | { row: row.row, bytes: row.bytes, ...buildRow(document, row, update.styles) }; | |||
| 452 | + | }); | |||
| 453 | + | for (const item of built) { | |||
| 454 | + | if (item.cells) { | |||
| 455 | + | item.cells.forEach((cell, index) => { | |||
| 456 | + | const child = item.node.children[index]; | |||
| 457 | + | if (child.textContent !== cell.text) child.textContent = cell.text; | |||
| 458 | + | }); | |||
| 459 | + | } | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | if (update.snapshot) { | |||
| 463 | + | const reuseAll = built.every(item => item.cells); | |||
| 464 | + | const fragment = document.createDocumentFragment(); | |||
| 465 | + | const nextNodes = new Array(update.height); | |||
| 466 | + | const nextText = new Array(update.height); | |||
| 467 | + | const nextBytes = new Array(update.height).fill(0); | |||
| 468 | + | for (const item of built) { | |||
| 469 | + | nextNodes[item.row] = item.node; | |||
| 470 | + | nextText[item.row] = item.text; | |||
| 471 | + | nextBytes[item.row] = item.bytes; | |||
| 472 | + | if (!reuseAll) fragment.append(item.node); | |||
| 473 | + | } | |||
| 474 | + | if (!reuseAll) viewport.replaceChildren(fragment, cursor); | |||
| 475 | + | rowNodes = nextNodes; | |||
| 476 | + | rowText = nextText; | |||
| 477 | + | rowBytes = nextBytes; | |||
| 478 | + | retainedBytes = projected; | |||
| 479 | + | } else { | |||
| 480 | + | for (const item of built) { | |||
| 481 | + | if (!item.cells) rowNodes[item.row].replaceWith(item.node); | |||
| 482 | + | rowNodes[item.row] = item.node; | |||
| 483 | + | retainedBytes += item.bytes - (rowBytes[item.row] || 0); | |||
| 484 | + | rowBytes[item.row] = item.bytes; | |||
| 485 | + | rowText[item.row] = item.text; | |||
| 486 | + | } | |||
| 487 | + | } | |||
| 488 | + | if (update.snapshot || styleKey !== previousStyles) previousRows = new Array(update.height); | |||
| 489 | + | for (const row of update.rows) previousRows[row.row] = row; | |||
| 490 | + | previousStyles = styleKey; | |||
| 491 | + | width = update.width; | |||
| 492 | + | height = update.height; | |||
| 493 | + | setOwnedProperty('--vev-cols', String(width)); | |||
| 494 | + | setOwnedProperty('--vev-rows', String(height)); | |||
| 495 | + | const accessibleText = rowText.join('\n'); | |||
| 496 | + | if (accessible.textContent !== accessibleText) accessible.textContent = accessibleText; | |||
| 497 | + | updateCursor(update.cursor); | |||
| 498 | + | scheduleResize(); | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | function position(event) { | |||
| 502 | + | if (width === 0 || height === 0) return { row: 0, column: 0, x: 0, y: 0 }; | |||
| 503 | + | const bounds = viewport.getBoundingClientRect(); | |||
| 504 | + | if (bounds.width <= 0 || bounds.height <= 0) return { row: 0, column: 0, x: 0, y: 0 }; | |||
| 505 | + | const clientX = Number.isFinite(event.clientX) ? event.clientX : bounds.left; | |||
| 506 | + | const clientY = Number.isFinite(event.clientY) ? event.clientY : bounds.top; | |||
| 507 | + | const x = Math.max(0, Math.min(bounds.width, clientX - bounds.left)); | |||
| 508 | + | const y = Math.max(0, Math.min(bounds.height, clientY - bounds.top)); | |||
| 509 | + | const column = Math.min(width - 1, Math.floor(x / (bounds.width / width))); | |||
| 510 | + | const row = Math.min(height - 1, Math.floor(y / (bounds.height / height))); | |||
| 511 | + | return { row, column, x, y }; | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | function modifierFields(event) { | |||
| 515 | + | return { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey }; | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | function pointerPayload(event, action) { | |||
| 519 | + | const invalidButton = !Number.isInteger(event.button) || event.button < -1 || event.button > 4; | |||
| 520 | + | const invalidButtons = !Number.isInteger(event.buttons) || event.buttons < 0 || event.buttons > 31; | |||
| 521 | + | if (invalidButton || invalidButtons) return null; | |||
| 522 | + | return { type: 'pointer', action, button: event.button, buttons: event.buttons, ...position(event), ...modifierFields(event) }; | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | function flushPointer() { | |||
| 526 | + | pointerFrame = 0; | |||
| 527 | + | const captured = pendingPointer; | |||
| 528 | + | pendingPointer = null; | |||
| 529 | + | if (captured) send(captured); | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | // A discrete pointer event must never be overtaken by an older deferred | |||
| 533 | + | // move: flush the pending move first so consumers observe event order. | |||
| 534 | + | function dispatchDiscretePointer(event, action, nativeEvent) { | |||
| 535 | + | const payload = pointerPayload(event, action); | |||
| 536 | + | if (!payload) return; | |||
| 537 | + | if (pendingPointer) flushPointer(); | |||
| 538 | + | dispatch(payload, nativeEvent); | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | function scheduleResize() { | |||
| 542 | + | if (resizeFrame || width === 0 || height === 0) return; | |||
| 543 | + | resizeFrame = requestAnimationFrame(() => { | |||
| 544 | + | resizeFrame = 0; | |||
| 545 | + | const bounds = viewport.getBoundingClientRect(); | |||
| 546 | + | const cellWidth = bounds.width / width; | |||
| 547 | + | const cellHeight = bounds.height / height; | |||
| 548 | + | if (bounds.width <= 0 || bounds.height <= 0 || cellWidth <= 0 || cellHeight <= 0) return; | |||
| 549 | + | const payload = { | |||
| 550 | + | type: 'resize', columns: width, rows: height, | |||
| 551 | + | pixelWidth: bounds.width, pixelHeight: bounds.height, | |||
| 552 | + | cellWidth, cellHeight, devicePixelRatio: window.devicePixelRatio | |||
| 553 | + | }; | |||
| 554 | + | const identity = JSON.stringify(payload); | |||
| 555 | + | if (identity !== lastResize) { | |||
| 556 | + | lastResize = identity; | |||
| 557 | + | dispatch(payload); | |||
| 558 | + | } | |||
| 559 | + | }); | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | input.addEventListener('beforeinput', event => { | |||
| 563 | + | if (composing || event.isComposing || event.inputType === 'insertCompositionText' || event.inputType === 'insertFromPaste') return; | |||
| 564 | + | const editingKey = { deleteContentBackward: 'Backspace', deleteContentForward: 'Delete', insertLineBreak: 'Enter', insertParagraph: 'Enter' }[event.inputType]; | |||
| 565 | + | if (editingKey) { | |||
| 566 | + | if (editingKeyDown === editingKey) return; | |||
| 567 | + | dispatch({ type: 'key', key: editingKey, code: editingKey, repeat: false, location: 0, alt: false, ctrl: false, meta: false, shift: false }, event); | |||
| 568 | + | return; | |||
| 569 | + | } | |||
| 570 | + | if (!event.data) return; | |||
| 571 | + | if (encoder.encode(event.data).byteLength > configured.maxTextBytes) { | |||
| 572 | + | event.preventDefault(); | |||
| 573 | + | return; | |||
| 574 | + | } | |||
| 575 | + | dispatch({ type: 'text', text: event.data }, event); | |||
| 576 | + | }, { signal }); | |||
| 577 | + | input.addEventListener('input', event => { | |||
| 578 | + | if (!composing && !event.isComposing) input.value = ''; | |||
| 579 | + | }, { signal }); | |||
| 580 | + | input.addEventListener('compositionstart', () => { composing = true; }, { signal }); | |||
| 581 | + | input.addEventListener('compositionend', event => { | |||
| 582 | + | composing = false; | |||
| 583 | + | input.value = ''; | |||
| 584 | + | if (event.data && encoder.encode(event.data).byteLength <= configured.maxTextBytes) { | |||
| 585 | + | dispatch({ type: 'text', text: event.data }, event); | |||
| 586 | + | } | |||
| 587 | + | }, { signal }); | |||
| 588 | + | input.addEventListener('keydown', event => { | |||
| 589 | + | if (composing || event.isComposing) return; | |||
| 590 | + | const key = event.key || 'Unidentified'; | |||
| 591 | + | editingKeyDown = ['Backspace', 'Delete', 'Enter'].includes(key) ? key : null; | |||
| 592 | + | const code = event.code || 'Unidentified'; | |||
| 593 | + | if (key === 'Unidentified' || key === 'Process') return; | |||
| 594 | + | const nonText = key.length !== 1 || event.ctrlKey || event.altKey || event.metaKey; | |||
| 595 | + | if (!nonText) return; | |||
| 596 | + | if (key.length > 128 || code.length > 128) { | |||
| 597 | + | event.preventDefault(); | |||
| 598 | + | return; | |||
| 599 | + | } | |||
| 600 | + | dispatch({ type: 'key', key, code, repeat: event.repeat, location: event.location, ...modifierFields(event) }, event); | |||
| 601 | + | }, { signal }); | |||
| 602 | + | input.addEventListener('keyup', () => { editingKeyDown = null; }, { signal }); | |||
| 603 | + | input.addEventListener('blur', () => { editingKeyDown = null; }, { signal }); | |||
| 604 | + | input.addEventListener('paste', event => { | |||
| 605 | + | const text = event.clipboardData?.getData('text/plain') || ''; | |||
| 606 | + | if (encoder.encode(text).byteLength > configured.maxPasteBytes) { | |||
| 607 | + | event.preventDefault(); | |||
| 608 | + | return; | |||
| 609 | + | } | |||
| 610 | + | dispatch({ type: 'paste', text }, event); | |||
| 611 | + | }, { signal }); | |||
| 612 | + | input.addEventListener('focus', event => dispatch({ type: 'focus', focused: true }, event), { signal }); | |||
| 613 | + | input.addEventListener('blur', event => dispatch({ type: 'focus', focused: false }, event), { signal }); | |||
| 614 | + | ||||
| 615 | + | viewport.addEventListener('pointerdown', event => { | |||
| 616 | + | if (mouseCapture && viewport.setPointerCapture) { | |||
| 617 | + | try { | |||
| 618 | + | if (event.isPrimary !== false) viewport.setPointerCapture(event.pointerId); | |||
| 619 | + | } catch { | |||
| 620 | + | // Pointer capture is best-effort; ordering still holds. | |||
| 621 | + | } | |||
| 622 | + | } | |||
| 623 | + | dispatchDiscretePointer(event, 'down', event); | |||
| 624 | + | }, { signal }); | |||
| 625 | + | viewport.addEventListener('pointerup', event => { | |||
| 626 | + | dispatchDiscretePointer(event, 'up', event); | |||
| 627 | + | }, { signal }); | |||
| 628 | + | viewport.addEventListener('pointercancel', event => { | |||
| 629 | + | dispatchDiscretePointer(event, 'cancel', event); | |||
| 630 | + | }, { signal }); | |||
| 631 | + | viewport.addEventListener('pointermove', event => { | |||
| 632 | + | const payload = pointerPayload(event, 'move'); | |||
| 633 | + | if (!payload) return; | |||
| 634 | + | const captured = capture(payload, event); | |||
| 635 | + | if (!captured || destroyed) return; | |||
| 636 | + | pendingPointer = captured; | |||
| 637 | + | if (!pointerFrame) pointerFrame = requestAnimationFrame(flushPointer); | |||
| 638 | + | }, { signal }); | |||
| 639 | + | viewport.addEventListener('wheel', event => { | |||
| 640 | + | const invalidDelta = !Number.isFinite(event.deltaX) || !Number.isFinite(event.deltaY); | |||
| 641 | + | const invalidMode = !Number.isInteger(event.deltaMode) || event.deltaMode < 0 || event.deltaMode > 2; | |||
| 642 | + | if (invalidDelta || invalidMode) return; | |||
| 643 | + | const { row, column } = position(event); | |||
| 644 | + | dispatch({ type: 'wheel', deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, row, column, ...modifierFields(event) }, event); | |||
| 645 | + | }, { signal, passive: false }); | |||
| 646 | + | ||||
| 647 | + | const observer = new ResizeObserver(scheduleResize); | |||
| 648 | + | observer.observe(root); | |||
| 649 | + | ||||
| 650 | + | return Object.freeze({ | |||
| 651 | + | apply, | |||
| 652 | + | focus() { alive(); input.focus(); }, | |||
| 653 | + | setMouseCapture(enabled) { | |||
| 654 | + | alive(); | |||
| 655 | + | mouseCapture = Boolean(enabled); | |||
| 656 | + | root.classList.toggle('vev-terminal--mouse-capture', mouseCapture); | |||
| 657 | + | }, | |||
| 658 | + | setTheme(theme) { | |||
| 659 | + | alive(); | |||
| 660 | + | const value = validateTheme(theme); | |||
| 661 | + | setOwnedProperty('--vev-fg', rgbValue(value.foreground)); | |||
| 662 | + | setOwnedProperty('--vev-bg', rgbValue(value.background)); | |||
| 663 | + | setOwnedProperty('--vev-cursor', rgbValue(value.cursor)); | |||
| 664 | + | setOwnedProperty('--vev-selection-bg', rgbValue(value.selection)); | |||
| 665 | + | setOwnedProperty('--vev-selection-fg', rgbValue(value.selectionText)); | |||
| 666 | + | value.palette.forEach((color, index) => setOwnedProperty(`--vev-color-${index}`, rgbValue(color))); | |||
| 667 | + | }, | |||
| 668 | + | destroy() { | |||
| 669 | + | if (destroyed) return; | |||
| 670 | + | destroyed = true; | |||
| 671 | + | abort.abort(); | |||
| 672 | + | observer.disconnect(); | |||
| 673 | + | if (resizeFrame) cancelAnimationFrame(resizeFrame); | |||
| 674 | + | if (pointerFrame) cancelAnimationFrame(pointerFrame); | |||
| 675 | + | input.value = ''; | |||
| 676 | + | accessible.textContent = ''; | |||
| 677 | + | viewport.replaceChildren(); | |||
| 678 | + | input.remove(); | |||
| 679 | + | viewport.remove(); | |||
| 680 | + | accessible.remove(); | |||
| 681 | + | rowNodes = []; | |||
| 682 | + | rowText = []; | |||
| 683 | + | rowBytes = []; | |||
| 684 | + | retainedBytes = 0; | |||
| 685 | + | previousRows = []; | |||
| 686 | + | previousStyles = ''; | |||
| 687 | + | pendingPointer = null; | |||
| 688 | + | width = 0; | |||
| 689 | + | height = 0; | |||
| 690 | + | lastResize = ''; | |||
| 691 | + | decide = () => ({ emit: false, preventDefault: false }); | |||
| 692 | + | send = () => {}; | |||
| 693 | + | for (const [name, previous] of previousProperties) { | |||
| 694 | + | if (previous.value === '') root.style.removeProperty(name); | |||
| 695 | + | else root.style.setProperty(name, previous.value, previous.priority); | |||
| 696 | + | } | |||
| 697 | + | previousProperties.clear(); | |||
| 698 | + | root.classList.remove('vev-terminal', 'vev-terminal--mouse-capture'); | |||
| 699 | + | if (previousRole === null) root.removeAttribute('role'); | |||
| 700 | + | else root.setAttribute('role', previousRole); | |||
| 701 | + | if (previousLabel === null) root.removeAttribute('aria-label'); | |||
| 702 | + | else root.setAttribute('aria-label', previousLabel); | |||
| 703 | + | } | |||
| 704 | + | }); | |||
| 705 | + | } | |||
| 706 | + | ||||
| 707 | + | globalThis.VevTerminal = Object.freeze({ schemaVersion: SCHEMA_VERSION, mount }); | |||
| 708 | + | })(); | |||
A html/consumer_external_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package html_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev-vt/core" | |
| 8 | + | htmlrenderer "github.com/bnema/vev-vt/html" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestExternalHTMLConsumerUsesOnlyCore(t *testing.T) { | |
| 12 | + | frame := core.NewFrame(2, 1) | |
| 13 | + | frame.Set(0, 0, core.Cell{Rune: 'O', Style: core.DefaultStyle()}) | |
| 14 | + | frame.Set(1, 0, core.Cell{Rune: 'K', Style: core.DefaultStyle()}) | |
| 15 | + | ||
| 16 | + | renderer, err := htmlrenderer.New(htmlrenderer.Options{}) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | prepared, err := renderer.Prepare(frame, nil, false, htmlrenderer.Cursor{}) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | if !json.Valid(prepared.JSON()) { | |
| 25 | + | t.Fatalf("prepared JSON is invalid: %q", prepared.JSON()) | |
| 26 | + | } | |
| 27 | + | if err := prepared.Commit(); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package html_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev-vt/core" | |||
| 8 | + | htmlrenderer "github.com/bnema/vev-vt/html" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestExternalHTMLConsumerUsesOnlyCore(t *testing.T) { | |||
| 12 | + | frame := core.NewFrame(2, 1) | |||
| 13 | + | frame.Set(0, 0, core.Cell{Rune: 'O', Style: core.DefaultStyle()}) | |||
| 14 | + | frame.Set(1, 0, core.Cell{Rune: 'K', Style: core.DefaultStyle()}) | |||
| 15 | + | ||||
| 16 | + | renderer, err := htmlrenderer.New(htmlrenderer.Options{}) | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | prepared, err := renderer.Prepare(frame, nil, false, htmlrenderer.Cursor{}) | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | if !json.Valid(prepared.JSON()) { | |||
| 25 | + | t.Fatalf("prepared JSON is invalid: %q", prepared.JSON()) | |||
| 26 | + | } | |||
| 27 | + | if err := prepared.Commit(); err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | } | |||
A html/doc.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | // Package html prepares safe, transactional browser updates from the | |
| 2 | + | // frontend-neutral github.com/bnema/vev-vt/core model. | |
| 3 | + | // | |
| 4 | + | // Renderer state is single-owner and is not internally synchronized. Prepare | |
| 5 | + | // allows exactly one outstanding draw. The consumer must call Commit only | |
| 6 | + | // after accepting the immutable update, or Abort to preserve the committed | |
| 7 | + | // shadow. Reset invalidates any outstanding draw. | |
| 8 | + | // | |
| 9 | + | // Damage values are non-authoritative hints: every row is compared with the | |
| 10 | + | // committed shadow, so mutations outside reported damage remain observable. | |
| 11 | + | // Updates replace complete rows to preserve wide-cell atomicity. Scroll damage | |
| 12 | + | // uses a full snapshot until browser measurements justify a more complex plan. | |
| 13 | + | // | |
| 14 | + | // The package emits typed data and structural CSS; it never converts terminal | |
| 15 | + | // text to markup. Package browser owns safe DOM application and neutral input | |
| 16 | + | // events. HTTP, WebSocket, sessions, PTY encoding, and application policy are | |
| 17 | + | // consumer responsibilities. | |
| 18 | + | package html | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | // Package html prepares safe, transactional browser updates from the | |||
| 2 | + | // frontend-neutral github.com/bnema/vev-vt/core model. | |||
| 3 | + | // | |||
| 4 | + | // Renderer state is single-owner and is not internally synchronized. Prepare | |||
| 5 | + | // allows exactly one outstanding draw. The consumer must call Commit only | |||
| 6 | + | // after accepting the immutable update, or Abort to preserve the committed | |||
| 7 | + | // shadow. Reset invalidates any outstanding draw. | |||
| 8 | + | // | |||
| 9 | + | // Damage values are non-authoritative hints: every row is compared with the | |||
| 10 | + | // committed shadow, so mutations outside reported damage remain observable. | |||
| 11 | + | // Updates replace complete rows to preserve wide-cell atomicity. Scroll damage | |||
| 12 | + | // uses a full snapshot until browser measurements justify a more complex plan. | |||
| 13 | + | // | |||
| 14 | + | // The package emits typed data and structural CSS; it never converts terminal | |||
| 15 | + | // text to markup. Package browser owns safe DOM application and neutral input | |||
| 16 | + | // events. HTTP, WebSocket, sessions, PTY encoding, and application policy are | |||
| 17 | + | // consumer responsibilities. | |||
| 18 | + | package html | |||
A html/import_boundary_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package html_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os/exec" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestHTMLProductionImportBoundary(t *testing.T) { | |
| 10 | + | command := exec.Command("go", "list", "-deps", "./...") | |
| 11 | + | output, err := command.CombinedOutput() | |
| 12 | + | if err != nil { | |
| 13 | + | t.Fatalf("go list HTML dependencies: %v\n%s", err, output) | |
| 14 | + | } | |
| 15 | + | for _, dependency := range strings.Fields(string(output)) { | |
| 16 | + | if dependency == "net/http" || strings.Contains(dependency, "/websocket") || | |
| 17 | + | dependency == "github.com/bnema/vev" || strings.HasPrefix(dependency, "github.com/bnema/vev/") { | |
| 18 | + | t.Errorf("forbidden HTML production dependency %q", dependency) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | command = exec.Command("go", "list", "-deps", "github.com/bnema/vev-vt/core") | |
| 23 | + | output, err = command.CombinedOutput() | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatalf("go list core dependencies: %v\n%s", err, output) | |
| 26 | + | } | |
| 27 | + | for _, dependency := range strings.Fields(string(output)) { | |
| 28 | + | if dependency == "github.com/bnema/vev-vt/html" || strings.HasPrefix(dependency, "github.com/bnema/vev-vt/html/") { | |
| 29 | + | t.Errorf("core imports concrete frontend %q", dependency) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package html_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os/exec" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestHTMLProductionImportBoundary(t *testing.T) { | |||
| 10 | + | command := exec.Command("go", "list", "-deps", "./...") | |||
| 11 | + | output, err := command.CombinedOutput() | |||
| 12 | + | if err != nil { | |||
| 13 | + | t.Fatalf("go list HTML dependencies: %v\n%s", err, output) | |||
| 14 | + | } | |||
| 15 | + | for _, dependency := range strings.Fields(string(output)) { | |||
| 16 | + | if dependency == "net/http" || strings.Contains(dependency, "/websocket") || | |||
| 17 | + | dependency == "github.com/bnema/vev" || strings.HasPrefix(dependency, "github.com/bnema/vev/") { | |||
| 18 | + | t.Errorf("forbidden HTML production dependency %q", dependency) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | command = exec.Command("go", "list", "-deps", "github.com/bnema/vev-vt/core") | |||
| 23 | + | output, err = command.CombinedOutput() | |||
| 24 | + | if err != nil { | |||
| 25 | + | t.Fatalf("go list core dependencies: %v\n%s", err, output) | |||
| 26 | + | } | |||
| 27 | + | for _, dependency := range strings.Fields(string(output)) { | |||
| 28 | + | if dependency == "github.com/bnema/vev-vt/html" || strings.HasPrefix(dependency, "github.com/bnema/vev-vt/html/") { | |||
| 29 | + | t.Errorf("core imports concrete frontend %q", dependency) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | } | |||
A html/limits.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | defaultMaxCells = 1_000_000 | |
| 9 | + | defaultMaxRowsPerUpdate = 10_000 | |
| 10 | + | defaultMaxGeneratedBytes = 64 << 20 | |
| 11 | + | defaultMaxStyles = 65_536 | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | var ( | |
| 15 | + | // ErrPendingDraw reports an attempt to prepare while a draw is pending. | |
| 16 | + | ErrPendingDraw = errors.New("html: prepared draw already pending") | |
| 17 | + | // ErrFinalizedDraw reports a repeated commit or abort. | |
| 18 | + | ErrFinalizedDraw = errors.New("html: prepared draw already finalized") | |
| 19 | + | // ErrStaleDraw reports a nil draw or one invalidated by Reset. | |
| 20 | + | ErrStaleDraw = errors.New("html: prepared draw is stale") | |
| 21 | + | // ErrLimitExceeded reports a configured renderer bound violation. | |
| 22 | + | ErrLimitExceeded = errors.New("html: resource limit exceeded") | |
| 23 | + | // ErrInvalidLimits reports a negative limit field. | |
| 24 | + | ErrInvalidLimits = errors.New("html: limits must not be negative") | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // Limits bounds renderer work and retained update data. Zero values select | |
| 28 | + | // documented safe defaults. | |
| 29 | + | type Limits struct { | |
| 30 | + | MaxCells int | |
| 31 | + | MaxRowsPerUpdate int | |
| 32 | + | MaxGeneratedBytes int | |
| 33 | + | MaxStyles int | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // DefaultLimits returns the bounds used for zero-valued limit fields. | |
| 37 | + | func DefaultLimits() Limits { | |
| 38 | + | return Limits{ | |
| 39 | + | MaxCells: defaultMaxCells, | |
| 40 | + | MaxRowsPerUpdate: defaultMaxRowsPerUpdate, | |
| 41 | + | MaxGeneratedBytes: defaultMaxGeneratedBytes, | |
| 42 | + | MaxStyles: defaultMaxStyles, | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Options configures a Renderer for its lifetime. | |
| 47 | + | type Options struct { | |
| 48 | + | Limits Limits | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func normalizeLimits(limits Limits) (Limits, error) { | |
| 52 | + | if limits.MaxCells < 0 || limits.MaxRowsPerUpdate < 0 || limits.MaxGeneratedBytes < 0 || limits.MaxStyles < 0 { | |
| 53 | + | return Limits{}, ErrInvalidLimits | |
| 54 | + | } | |
| 55 | + | defaults := DefaultLimits() | |
| 56 | + | if limits.MaxCells == 0 { | |
| 57 | + | limits.MaxCells = defaults.MaxCells | |
| 58 | + | } | |
| 59 | + | if limits.MaxRowsPerUpdate == 0 { | |
| 60 | + | limits.MaxRowsPerUpdate = defaults.MaxRowsPerUpdate | |
| 61 | + | } | |
| 62 | + | if limits.MaxGeneratedBytes == 0 { | |
| 63 | + | limits.MaxGeneratedBytes = defaults.MaxGeneratedBytes | |
| 64 | + | } | |
| 65 | + | if limits.MaxStyles == 0 { | |
| 66 | + | limits.MaxStyles = defaults.MaxStyles | |
| 67 | + | } | |
| 68 | + | return limits, nil | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | const ( | |||
| 8 | + | defaultMaxCells = 1_000_000 | |||
| 9 | + | defaultMaxRowsPerUpdate = 10_000 | |||
| 10 | + | defaultMaxGeneratedBytes = 64 << 20 | |||
| 11 | + | defaultMaxStyles = 65_536 | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | var ( | |||
| 15 | + | // ErrPendingDraw reports an attempt to prepare while a draw is pending. | |||
| 16 | + | ErrPendingDraw = errors.New("html: prepared draw already pending") | |||
| 17 | + | // ErrFinalizedDraw reports a repeated commit or abort. | |||
| 18 | + | ErrFinalizedDraw = errors.New("html: prepared draw already finalized") | |||
| 19 | + | // ErrStaleDraw reports a nil draw or one invalidated by Reset. | |||
| 20 | + | ErrStaleDraw = errors.New("html: prepared draw is stale") | |||
| 21 | + | // ErrLimitExceeded reports a configured renderer bound violation. | |||
| 22 | + | ErrLimitExceeded = errors.New("html: resource limit exceeded") | |||
| 23 | + | // ErrInvalidLimits reports a negative limit field. | |||
| 24 | + | ErrInvalidLimits = errors.New("html: limits must not be negative") | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // Limits bounds renderer work and retained update data. Zero values select | |||
| 28 | + | // documented safe defaults. | |||
| 29 | + | type Limits struct { | |||
| 30 | + | MaxCells int | |||
| 31 | + | MaxRowsPerUpdate int | |||
| 32 | + | MaxGeneratedBytes int | |||
| 33 | + | MaxStyles int | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // DefaultLimits returns the bounds used for zero-valued limit fields. | |||
| 37 | + | func DefaultLimits() Limits { | |||
| 38 | + | return Limits{ | |||
| 39 | + | MaxCells: defaultMaxCells, | |||
| 40 | + | MaxRowsPerUpdate: defaultMaxRowsPerUpdate, | |||
| 41 | + | MaxGeneratedBytes: defaultMaxGeneratedBytes, | |||
| 42 | + | MaxStyles: defaultMaxStyles, | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Options configures a Renderer for its lifetime. | |||
| 47 | + | type Options struct { | |||
| 48 | + | Limits Limits | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func normalizeLimits(limits Limits) (Limits, error) { | |||
| 52 | + | if limits.MaxCells < 0 || limits.MaxRowsPerUpdate < 0 || limits.MaxGeneratedBytes < 0 || limits.MaxStyles < 0 { | |||
| 53 | + | return Limits{}, ErrInvalidLimits | |||
| 54 | + | } | |||
| 55 | + | defaults := DefaultLimits() | |||
| 56 | + | if limits.MaxCells == 0 { | |||
| 57 | + | limits.MaxCells = defaults.MaxCells | |||
| 58 | + | } | |||
| 59 | + | if limits.MaxRowsPerUpdate == 0 { | |||
| 60 | + | limits.MaxRowsPerUpdate = defaults.MaxRowsPerUpdate | |||
| 61 | + | } | |||
| 62 | + | if limits.MaxGeneratedBytes == 0 { | |||
| 63 | + | limits.MaxGeneratedBytes = defaults.MaxGeneratedBytes | |||
| 64 | + | } | |||
| 65 | + | if limits.MaxStyles == 0 { | |||
| 66 | + | limits.MaxStyles = defaults.MaxStyles | |||
| 67 | + | } | |||
| 68 | + | return limits, nil | |||
| 69 | + | } | |||
A html/renderer.go +435 -0
| @@ -0,0 +1,435 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "math" | |
| 7 | + | "unicode/utf8" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev-vt/core" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type Renderer struct { | |
| 13 | + | limits Limits | |
| 14 | + | generation uint64 | |
| 15 | + | pending *transaction | |
| 16 | + | committed core.Frame | |
| 17 | + | cursor Cursor | |
| 18 | + | initialized bool | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // CellSource is the read-only semantic grid consumed by HTML rendering. | |
| 22 | + | type CellSource = core.CellSource | |
| 23 | + | ||
| 24 | + | type transactionState uint8 | |
| 25 | + | ||
| 26 | + | const ( | |
| 27 | + | transactionPending transactionState = iota | |
| 28 | + | transactionCommitted | |
| 29 | + | transactionAborted | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | type transaction struct { | |
| 33 | + | renderer *Renderer | |
| 34 | + | generation uint64 | |
| 35 | + | state transactionState | |
| 36 | + | update Update | |
| 37 | + | json []byte | |
| 38 | + | frame core.Frame | |
| 39 | + | cursor Cursor | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // PreparedDraw owns one immutable update and its speculative renderer state. | |
| 43 | + | type PreparedDraw struct { | |
| 44 | + | tx *transaction | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func New(options Options) (*Renderer, error) { | |
| 48 | + | limits, err := normalizeLimits(options.Limits) | |
| 49 | + | if err != nil { | |
| 50 | + | return nil, err | |
| 51 | + | } | |
| 52 | + | return &Renderer{limits: limits, generation: 1}, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Prepare validates and encodes a speculative update. The renderer advances | |
| 56 | + | // only after Commit. Abort preserves the previous committed shadow. | |
| 57 | + | func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, cursor Cursor) (*PreparedDraw, error) { | |
| 58 | + | if r == nil { | |
| 59 | + | return nil, fmt.Errorf("html: nil renderer") | |
| 60 | + | } | |
| 61 | + | if r.pending != nil { | |
| 62 | + | return nil, ErrPendingDraw | |
| 63 | + | } | |
| 64 | + | frame, err := materializeCellSource(source) | |
| 65 | + | if err != nil { | |
| 66 | + | return nil, err | |
| 67 | + | } | |
| 68 | + | if err := r.validateFrame(frame); err != nil { | |
| 69 | + | return nil, err | |
| 70 | + | } | |
| 71 | + | cursor = normalizeWrapPendingCursor(cursor, frame.Width) | |
| 72 | + | if err := validateCursor(cursor, frame.Width, frame.Height); err != nil { | |
| 73 | + | return nil, err | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height) | |
| 77 | + | candidate := frame.Clone() | |
| 78 | + | update, err := r.buildUpdate(candidate, snapshot, cursor) | |
| 79 | + | if err != nil { | |
| 80 | + | return nil, err | |
| 81 | + | } | |
| 82 | + | encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes) | |
| 83 | + | if err != nil { | |
| 84 | + | return nil, err | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | tx := &transaction{ | |
| 88 | + | renderer: r, | |
| 89 | + | generation: r.generation, | |
| 90 | + | state: transactionPending, | |
| 91 | + | update: update, | |
| 92 | + | json: encoded, | |
| 93 | + | frame: candidate, | |
| 94 | + | cursor: cursor, | |
| 95 | + | } | |
| 96 | + | r.pending = tx | |
| 97 | + | return &PreparedDraw{tx: tx}, nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (r *Renderer) validateFrame(frame core.Frame) error { | |
| 101 | + | if frame.Width <= 0 || frame.Height <= 0 { | |
| 102 | + | return fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height) | |
| 103 | + | } | |
| 104 | + | if frame.Width > math.MaxInt/frame.Height { | |
| 105 | + | return fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded) | |
| 106 | + | } | |
| 107 | + | cells := frame.Width * frame.Height | |
| 108 | + | if cells > r.limits.MaxCells { | |
| 109 | + | return fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells) | |
| 110 | + | } | |
| 111 | + | if frame.Height > r.limits.MaxRowsPerUpdate { | |
| 112 | + | return fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate) | |
| 113 | + | } | |
| 114 | + | if err := frame.Validate(); err != nil { | |
| 115 | + | return fmt.Errorf("html: validate frame: %w", err) | |
| 116 | + | } | |
| 117 | + | for y := range frame.Height { | |
| 118 | + | row := frame.Row(y) | |
| 119 | + | for x := 0; x < frame.Width; x++ { | |
| 120 | + | cell := row[x] | |
| 121 | + | if err := validateCoreStyle(cell.Style); err != nil { | |
| 122 | + | return fmt.Errorf("html: cell (%d,%d): %w", x, y, err) | |
| 123 | + | } | |
| 124 | + | if cell.Continuation { | |
| 125 | + | if cell.Rune != 0 { | |
| 126 | + | return fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y) | |
| 127 | + | } | |
| 128 | + | if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 { | |
| 129 | + | return fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y) | |
| 130 | + | } | |
| 131 | + | if !cell.Style.Equal(row[x-1].Style) { | |
| 132 | + | return fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y) | |
| 133 | + | } | |
| 134 | + | continue | |
| 135 | + | } | |
| 136 | + | if cell.Rune == 0 { | |
| 137 | + | continue | |
| 138 | + | } | |
| 139 | + | if !utf8.ValidRune(cell.Rune) { | |
| 140 | + | return fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y) | |
| 141 | + | } | |
| 142 | + | width := core.RuneWidth(cell.Rune) | |
| 143 | + | switch width { | |
| 144 | + | case 1: | |
| 145 | + | case 2: | |
| 146 | + | if x+1 >= frame.Width || !row[x+1].Continuation { | |
| 147 | + | return fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y) | |
| 148 | + | } | |
| 149 | + | case 0: | |
| 150 | + | return fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y) | |
| 151 | + | default: | |
| 152 | + | return fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | return nil | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // materializeCellSource reads a source into an owned frame. A core.Frame | |
| 160 | + | // takes the fast clone path; any other source is copied cell by cell. | |
| 161 | + | func materializeCellSource(source CellSource) (core.Frame, error) { | |
| 162 | + | if source == nil { | |
| 163 | + | return core.Frame{}, fmt.Errorf("html: nil cell source") | |
| 164 | + | } | |
| 165 | + | switch frame := source.(type) { | |
| 166 | + | case core.Frame: | |
| 167 | + | if err := frame.Validate(); err != nil { | |
| 168 | + | return core.Frame{}, fmt.Errorf("html: validate frame: %w", err) | |
| 169 | + | } | |
| 170 | + | return frame.Clone(), nil | |
| 171 | + | case *core.Frame: | |
| 172 | + | if frame == nil { | |
| 173 | + | return core.Frame{}, fmt.Errorf("html: nil cell source") | |
| 174 | + | } | |
| 175 | + | if err := frame.Validate(); err != nil { | |
| 176 | + | return core.Frame{}, fmt.Errorf("html: validate frame: %w", err) | |
| 177 | + | } | |
| 178 | + | return frame.Clone(), nil | |
| 179 | + | } | |
| 180 | + | width, height := source.Columns(), source.Rows() | |
| 181 | + | if width <= 0 || height <= 0 { | |
| 182 | + | return core.Frame{}, fmt.Errorf("html: invalid cell source size %dx%d", width, height) | |
| 183 | + | } | |
| 184 | + | clone := core.NewFrame(width, height) | |
| 185 | + | for y := range height { | |
| 186 | + | for x := range width { | |
| 187 | + | clone.Set(x, y, source.Cell(x, y)) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | return clone, nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column | |
| 194 | + | // produced after writing the last column with autowrap enabled onto the | |
| 195 | + | // last visible column. It leaves every in-bounds cursor untouched. | |
| 196 | + | func normalizeWrapPendingCursor(cursor Cursor, width int) Cursor { | |
| 197 | + | if cursor.Column == width && cursor.Row >= 0 { | |
| 198 | + | cursor.Column = width - 1 | |
| 199 | + | } | |
| 200 | + | return cursor | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func validateCursor(cursor Cursor, width, height int) error { | |
| 204 | + | if cursor.Row < 0 || cursor.Row >= height || cursor.Column < 0 || cursor.Column >= width { | |
| 205 | + | return fmt.Errorf("html: cursor (%d,%d) outside %dx%d frame", cursor.Column, cursor.Row, width, height) | |
| 206 | + | } | |
| 207 | + | if cursor.Style > 6 { | |
| 208 | + | return fmt.Errorf("html: invalid cursor style %d", cursor.Style) | |
| 209 | + | } | |
| 210 | + | if !cursor.StyleSet && cursor.Style != 0 { | |
| 211 | + | return fmt.Errorf("html: cursor style must be zero when unset") | |
| 212 | + | } | |
| 213 | + | return nil | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func damageRequiresSnapshot(damage []core.Damage, width, height int) bool { | |
| 217 | + | for _, item := range damage { | |
| 218 | + | switch item.Kind { | |
| 219 | + | case core.DamageText, core.DamageClear: | |
| 220 | + | if item.X < 0 || item.Y < 0 || item.Width <= 0 || item.Height <= 0 || item.X > width-item.Width || item.Y > height-item.Height { | |
| 221 | + | return true | |
| 222 | + | } | |
| 223 | + | case core.DamageScrollUp, core.DamageFullRedraw: | |
| 224 | + | return true | |
| 225 | + | default: | |
| 226 | + | return true | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | return false | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (Update, error) { | |
| 233 | + | update := Update{ | |
| 234 | + | SchemaVersion: UpdateSchemaVersion, | |
| 235 | + | Width: frame.Width, | |
| 236 | + | Height: frame.Height, | |
| 237 | + | Snapshot: snapshot, | |
| 238 | + | Rows: make([]RowUpdate, 0), | |
| 239 | + | Styles: make([]Style, 0), | |
| 240 | + | Cursor: cursor, | |
| 241 | + | } | |
| 242 | + | styleIDs := make(map[Style]int) | |
| 243 | + | for y := range frame.Height { | |
| 244 | + | if !snapshot && rowsEqual(frame.Row(y), r.committed.Row(y)) { | |
| 245 | + | continue | |
| 246 | + | } | |
| 247 | + | if len(update.Rows) >= r.limits.MaxRowsPerUpdate { | |
| 248 | + | return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate) | |
| 249 | + | } | |
| 250 | + | row, err := encodeRow(y, frame.Row(y), &update.Styles, styleIDs, r.limits.MaxStyles) | |
| 251 | + | if err != nil { | |
| 252 | + | return Update{}, err | |
| 253 | + | } | |
| 254 | + | update.Rows = append(update.Rows, row) | |
| 255 | + | } | |
| 256 | + | return update, nil | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on | |
| 260 | + | // the exact encoded size. The previous conservative estimate could reject | |
| 261 | + | // compact updates well below the configured limit. | |
| 262 | + | func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | |
| 263 | + | encoded, err := json.Marshal(update) | |
| 264 | + | if err != nil { | |
| 265 | + | return nil, fmt.Errorf("html: encode update: %w", err) | |
| 266 | + | } | |
| 267 | + | if len(encoded) > limit { | |
| 268 | + | return nil, fmt.Errorf("%w: generated update is %d bytes, limit is %d", ErrLimitExceeded, len(encoded), limit) | |
| 269 | + | } | |
| 270 | + | return encoded, nil | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func rowsEqual(left, right []core.Cell) bool { | |
| 274 | + | if len(left) != len(right) { | |
| 275 | + | return false | |
| 276 | + | } | |
| 277 | + | for i := range left { | |
| 278 | + | if !left[i].Equal(right[i]) { | |
| 279 | + | return false | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | return true | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) { | |
| 286 | + | row := RowUpdate{Row: y, Cells: make([]CellUpdate, 0, len(cells))} | |
| 287 | + | for x := 0; x < len(cells); x++ { | |
| 288 | + | cell := cells[x] | |
| 289 | + | if cell.Continuation { | |
| 290 | + | continue | |
| 291 | + | } | |
| 292 | + | style := styleFromCore(cell.Style) | |
| 293 | + | styleID, ok := styleIDs[style] | |
| 294 | + | if !ok { | |
| 295 | + | if len(*styles) >= maxStyles { | |
| 296 | + | return RowUpdate{}, fmt.Errorf("%w: update styles exceed limit %d", ErrLimitExceeded, maxStyles) | |
| 297 | + | } | |
| 298 | + | styleID = len(*styles) | |
| 299 | + | styleIDs[style] = styleID | |
| 300 | + | *styles = append(*styles, style) | |
| 301 | + | } | |
| 302 | + | width := core.RuneWidth(cell.Rune) | |
| 303 | + | text := string(cell.Rune) | |
| 304 | + | if cell.Rune == 0 { | |
| 305 | + | width = 1 | |
| 306 | + | text = " " | |
| 307 | + | } | |
| 308 | + | row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID}) | |
| 309 | + | } | |
| 310 | + | return row, nil | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func validateCoreStyle(style core.Style) error { | |
| 314 | + | const knownAttrs = core.AttrDim | core.AttrUnderline | core.AttrBlink | core.AttrStrikethrough | |
| 315 | + | if style.Attrs & ^knownAttrs != 0 { | |
| 316 | + | return fmt.Errorf("unknown style attribute bits %#x", style.Attrs&^knownAttrs) | |
| 317 | + | } | |
| 318 | + | if style.UnderlineStyle > core.UnderlineDashed { | |
| 319 | + | return fmt.Errorf("invalid underline style %d", style.UnderlineStyle) | |
| 320 | + | } | |
| 321 | + | if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > 255) { | |
| 322 | + | return fmt.Errorf("invalid foreground index %d", style.Foreground) | |
| 323 | + | } | |
| 324 | + | if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > 255) { | |
| 325 | + | return fmt.Errorf("invalid background index %d", style.Background) | |
| 326 | + | } | |
| 327 | + | if !style.HasUnderlineColorRGB && style.HasUnderlineColor && (style.UnderlineColor < 0 || style.UnderlineColor > 255) { | |
| 328 | + | return fmt.Errorf("invalid underline color index %d", style.UnderlineColor) | |
| 329 | + | } | |
| 330 | + | return nil | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func styleFromCore(style core.Style) Style { | |
| 334 | + | return Style{ | |
| 335 | + | Bold: style.Bold, | |
| 336 | + | Italic: style.Italic, | |
| 337 | + | Inverse: style.Inverse, | |
| 338 | + | Dim: style.Attrs&core.AttrDim != 0, | |
| 339 | + | Blink: style.Attrs&core.AttrBlink != 0, | |
| 340 | + | Strikethrough: style.Attrs&core.AttrStrikethrough != 0, | |
| 341 | + | Underline: style.Attrs&core.AttrUnderline != 0, | |
| 342 | + | UnderlineStyle: style.UnderlineStyle, | |
| 343 | + | Foreground: colorFromCore(style.Foreground, style.HasForegroundRGB, style.ForegroundRGB), | |
| 344 | + | Background: colorFromCore(style.Background, style.HasBackgroundRGB, style.BackgroundRGB), | |
| 345 | + | UnderlineColor: underlineColorFromCore(style), | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | // Prepare validates every indexed color before these uint8 conversions. | |
| 350 | + | func colorFromCore(index int, hasRGB bool, rgb core.RGB) Color { | |
| 351 | + | if hasRGB { | |
| 352 | + | return Color{Kind: ColorRGB, RGB: RGB{R: rgb.R, G: rgb.G, B: rgb.B}} | |
| 353 | + | } | |
| 354 | + | if index >= 0 { | |
| 355 | + | return Color{Kind: ColorIndexed, Index: uint8(index)} | |
| 356 | + | } | |
| 357 | + | return Color{Kind: ColorDefault} | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func underlineColorFromCore(style core.Style) Color { | |
| 361 | + | if style.HasUnderlineColorRGB { | |
| 362 | + | return Color{Kind: ColorRGB, RGB: RGB{R: style.UnderlineColorRGB.R, G: style.UnderlineColorRGB.G, B: style.UnderlineColorRGB.B}} | |
| 363 | + | } | |
| 364 | + | if style.HasUnderlineColor { | |
| 365 | + | return Color{Kind: ColorIndexed, Index: uint8(style.UnderlineColor)} | |
| 366 | + | } | |
| 367 | + | return Color{Kind: ColorDefault} | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | // Update returns an owned copy that remains valid after later renderer work. | |
| 371 | + | func (p *PreparedDraw) Update() Update { | |
| 372 | + | if p == nil || p.tx == nil { | |
| 373 | + | return Update{} | |
| 374 | + | } | |
| 375 | + | return cloneUpdate(p.tx.update) | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // JSON returns an owned canonical JSON representation of Update. | |
| 379 | + | func (p *PreparedDraw) JSON() []byte { | |
| 380 | + | if p == nil || p.tx == nil { | |
| 381 | + | return nil | |
| 382 | + | } | |
| 383 | + | return append([]byte(nil), p.tx.json...) | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // Commit atomically advances the renderer shadow exactly once. | |
| 387 | + | func (p *PreparedDraw) Commit() error { | |
| 388 | + | if p == nil || p.tx == nil || p.tx.renderer == nil { | |
| 389 | + | return ErrStaleDraw | |
| 390 | + | } | |
| 391 | + | tx := p.tx | |
| 392 | + | if tx.state != transactionPending { | |
| 393 | + | return ErrFinalizedDraw | |
| 394 | + | } | |
| 395 | + | r := tx.renderer | |
| 396 | + | if tx.generation != r.generation || r.pending != tx { | |
| 397 | + | return ErrStaleDraw | |
| 398 | + | } | |
| 399 | + | r.committed = tx.frame | |
| 400 | + | r.cursor = tx.cursor | |
| 401 | + | r.initialized = true | |
| 402 | + | r.pending = nil | |
| 403 | + | tx.state = transactionCommitted | |
| 404 | + | return nil | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | // Abort finalizes the transaction without advancing the renderer shadow. | |
| 408 | + | func (p *PreparedDraw) Abort() error { | |
| 409 | + | if p == nil || p.tx == nil || p.tx.renderer == nil { | |
| 410 | + | return ErrStaleDraw | |
| 411 | + | } | |
| 412 | + | tx := p.tx | |
| 413 | + | if tx.state != transactionPending { | |
| 414 | + | return ErrFinalizedDraw | |
| 415 | + | } | |
| 416 | + | r := tx.renderer | |
| 417 | + | if tx.generation != r.generation || r.pending != tx { | |
| 418 | + | return ErrStaleDraw | |
| 419 | + | } | |
| 420 | + | r.pending = nil | |
| 421 | + | tx.state = transactionAborted | |
| 422 | + | return nil | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | // Reset invalidates any prepared draw and clears committed state. | |
| 426 | + | func (r *Renderer) Reset() { | |
| 427 | + | if r == nil { | |
| 428 | + | return | |
| 429 | + | } | |
| 430 | + | r.generation++ | |
| 431 | + | r.pending = nil | |
| 432 | + | r.committed = core.Frame{} | |
| 433 | + | r.cursor = Cursor{} | |
| 434 | + | r.initialized = false | |
| 435 | + | } | |
| @@ -0,0 +1,435 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "math" | |||
| 7 | + | "unicode/utf8" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev-vt/core" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type Renderer struct { | |||
| 13 | + | limits Limits | |||
| 14 | + | generation uint64 | |||
| 15 | + | pending *transaction | |||
| 16 | + | committed core.Frame | |||
| 17 | + | cursor Cursor | |||
| 18 | + | initialized bool | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // CellSource is the read-only semantic grid consumed by HTML rendering. | |||
| 22 | + | type CellSource = core.CellSource | |||
| 23 | + | ||||
| 24 | + | type transactionState uint8 | |||
| 25 | + | ||||
| 26 | + | const ( | |||
| 27 | + | transactionPending transactionState = iota | |||
| 28 | + | transactionCommitted | |||
| 29 | + | transactionAborted | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | type transaction struct { | |||
| 33 | + | renderer *Renderer | |||
| 34 | + | generation uint64 | |||
| 35 | + | state transactionState | |||
| 36 | + | update Update | |||
| 37 | + | json []byte | |||
| 38 | + | frame core.Frame | |||
| 39 | + | cursor Cursor | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // PreparedDraw owns one immutable update and its speculative renderer state. | |||
| 43 | + | type PreparedDraw struct { | |||
| 44 | + | tx *transaction | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func New(options Options) (*Renderer, error) { | |||
| 48 | + | limits, err := normalizeLimits(options.Limits) | |||
| 49 | + | if err != nil { | |||
| 50 | + | return nil, err | |||
| 51 | + | } | |||
| 52 | + | return &Renderer{limits: limits, generation: 1}, nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Prepare validates and encodes a speculative update. The renderer advances | |||
| 56 | + | // only after Commit. Abort preserves the previous committed shadow. | |||
| 57 | + | func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, cursor Cursor) (*PreparedDraw, error) { | |||
| 58 | + | if r == nil { | |||
| 59 | + | return nil, fmt.Errorf("html: nil renderer") | |||
| 60 | + | } | |||
| 61 | + | if r.pending != nil { | |||
| 62 | + | return nil, ErrPendingDraw | |||
| 63 | + | } | |||
| 64 | + | frame, err := materializeCellSource(source) | |||
| 65 | + | if err != nil { | |||
| 66 | + | return nil, err | |||
| 67 | + | } | |||
| 68 | + | if err := r.validateFrame(frame); err != nil { | |||
| 69 | + | return nil, err | |||
| 70 | + | } | |||
| 71 | + | cursor = normalizeWrapPendingCursor(cursor, frame.Width) | |||
| 72 | + | if err := validateCursor(cursor, frame.Width, frame.Height); err != nil { | |||
| 73 | + | return nil, err | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height) | |||
| 77 | + | candidate := frame.Clone() | |||
| 78 | + | update, err := r.buildUpdate(candidate, snapshot, cursor) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return nil, err | |||
| 81 | + | } | |||
| 82 | + | encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes) | |||
| 83 | + | if err != nil { | |||
| 84 | + | return nil, err | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | tx := &transaction{ | |||
| 88 | + | renderer: r, | |||
| 89 | + | generation: r.generation, | |||
| 90 | + | state: transactionPending, | |||
| 91 | + | update: update, | |||
| 92 | + | json: encoded, | |||
| 93 | + | frame: candidate, | |||
| 94 | + | cursor: cursor, | |||
| 95 | + | } | |||
| 96 | + | r.pending = tx | |||
| 97 | + | return &PreparedDraw{tx: tx}, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (r *Renderer) validateFrame(frame core.Frame) error { | |||
| 101 | + | if frame.Width <= 0 || frame.Height <= 0 { | |||
| 102 | + | return fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height) | |||
| 103 | + | } | |||
| 104 | + | if frame.Width > math.MaxInt/frame.Height { | |||
| 105 | + | return fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded) | |||
| 106 | + | } | |||
| 107 | + | cells := frame.Width * frame.Height | |||
| 108 | + | if cells > r.limits.MaxCells { | |||
| 109 | + | return fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells) | |||
| 110 | + | } | |||
| 111 | + | if frame.Height > r.limits.MaxRowsPerUpdate { | |||
| 112 | + | return fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate) | |||
| 113 | + | } | |||
| 114 | + | if err := frame.Validate(); err != nil { | |||
| 115 | + | return fmt.Errorf("html: validate frame: %w", err) | |||
| 116 | + | } | |||
| 117 | + | for y := range frame.Height { | |||
| 118 | + | row := frame.Row(y) | |||
| 119 | + | for x := 0; x < frame.Width; x++ { | |||
| 120 | + | cell := row[x] | |||
| 121 | + | if err := validateCoreStyle(cell.Style); err != nil { | |||
| 122 | + | return fmt.Errorf("html: cell (%d,%d): %w", x, y, err) | |||
| 123 | + | } | |||
| 124 | + | if cell.Continuation { | |||
| 125 | + | if cell.Rune != 0 { | |||
| 126 | + | return fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y) | |||
| 127 | + | } | |||
| 128 | + | if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 { | |||
| 129 | + | return fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y) | |||
| 130 | + | } | |||
| 131 | + | if !cell.Style.Equal(row[x-1].Style) { | |||
| 132 | + | return fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y) | |||
| 133 | + | } | |||
| 134 | + | continue | |||
| 135 | + | } | |||
| 136 | + | if cell.Rune == 0 { | |||
| 137 | + | continue | |||
| 138 | + | } | |||
| 139 | + | if !utf8.ValidRune(cell.Rune) { | |||
| 140 | + | return fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y) | |||
| 141 | + | } | |||
| 142 | + | width := core.RuneWidth(cell.Rune) | |||
| 143 | + | switch width { | |||
| 144 | + | case 1: | |||
| 145 | + | case 2: | |||
| 146 | + | if x+1 >= frame.Width || !row[x+1].Continuation { | |||
| 147 | + | return fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y) | |||
| 148 | + | } | |||
| 149 | + | case 0: | |||
| 150 | + | return fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y) | |||
| 151 | + | default: | |||
| 152 | + | return fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | return nil | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // materializeCellSource reads a source into an owned frame. A core.Frame | |||
| 160 | + | // takes the fast clone path; any other source is copied cell by cell. | |||
| 161 | + | func materializeCellSource(source CellSource) (core.Frame, error) { | |||
| 162 | + | if source == nil { | |||
| 163 | + | return core.Frame{}, fmt.Errorf("html: nil cell source") | |||
| 164 | + | } | |||
| 165 | + | switch frame := source.(type) { | |||
| 166 | + | case core.Frame: | |||
| 167 | + | if err := frame.Validate(); err != nil { | |||
| 168 | + | return core.Frame{}, fmt.Errorf("html: validate frame: %w", err) | |||
| 169 | + | } | |||
| 170 | + | return frame.Clone(), nil | |||
| 171 | + | case *core.Frame: | |||
| 172 | + | if frame == nil { | |||
| 173 | + | return core.Frame{}, fmt.Errorf("html: nil cell source") | |||
| 174 | + | } | |||
| 175 | + | if err := frame.Validate(); err != nil { | |||
| 176 | + | return core.Frame{}, fmt.Errorf("html: validate frame: %w", err) | |||
| 177 | + | } | |||
| 178 | + | return frame.Clone(), nil | |||
| 179 | + | } | |||
| 180 | + | width, height := source.Columns(), source.Rows() | |||
| 181 | + | if width <= 0 || height <= 0 { | |||
| 182 | + | return core.Frame{}, fmt.Errorf("html: invalid cell source size %dx%d", width, height) | |||
| 183 | + | } | |||
| 184 | + | clone := core.NewFrame(width, height) | |||
| 185 | + | for y := range height { | |||
| 186 | + | for x := range width { | |||
| 187 | + | clone.Set(x, y, source.Cell(x, y)) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | return clone, nil | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column | |||
| 194 | + | // produced after writing the last column with autowrap enabled onto the | |||
| 195 | + | // last visible column. It leaves every in-bounds cursor untouched. | |||
| 196 | + | func normalizeWrapPendingCursor(cursor Cursor, width int) Cursor { | |||
| 197 | + | if cursor.Column == width && cursor.Row >= 0 { | |||
| 198 | + | cursor.Column = width - 1 | |||
| 199 | + | } | |||
| 200 | + | return cursor | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func validateCursor(cursor Cursor, width, height int) error { | |||
| 204 | + | if cursor.Row < 0 || cursor.Row >= height || cursor.Column < 0 || cursor.Column >= width { | |||
| 205 | + | return fmt.Errorf("html: cursor (%d,%d) outside %dx%d frame", cursor.Column, cursor.Row, width, height) | |||
| 206 | + | } | |||
| 207 | + | if cursor.Style > 6 { | |||
| 208 | + | return fmt.Errorf("html: invalid cursor style %d", cursor.Style) | |||
| 209 | + | } | |||
| 210 | + | if !cursor.StyleSet && cursor.Style != 0 { | |||
| 211 | + | return fmt.Errorf("html: cursor style must be zero when unset") | |||
| 212 | + | } | |||
| 213 | + | return nil | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func damageRequiresSnapshot(damage []core.Damage, width, height int) bool { | |||
| 217 | + | for _, item := range damage { | |||
| 218 | + | switch item.Kind { | |||
| 219 | + | case core.DamageText, core.DamageClear: | |||
| 220 | + | if item.X < 0 || item.Y < 0 || item.Width <= 0 || item.Height <= 0 || item.X > width-item.Width || item.Y > height-item.Height { | |||
| 221 | + | return true | |||
| 222 | + | } | |||
| 223 | + | case core.DamageScrollUp, core.DamageFullRedraw: | |||
| 224 | + | return true | |||
| 225 | + | default: | |||
| 226 | + | return true | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | return false | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (Update, error) { | |||
| 233 | + | update := Update{ | |||
| 234 | + | SchemaVersion: UpdateSchemaVersion, | |||
| 235 | + | Width: frame.Width, | |||
| 236 | + | Height: frame.Height, | |||
| 237 | + | Snapshot: snapshot, | |||
| 238 | + | Rows: make([]RowUpdate, 0), | |||
| 239 | + | Styles: make([]Style, 0), | |||
| 240 | + | Cursor: cursor, | |||
| 241 | + | } | |||
| 242 | + | styleIDs := make(map[Style]int) | |||
| 243 | + | for y := range frame.Height { | |||
| 244 | + | if !snapshot && rowsEqual(frame.Row(y), r.committed.Row(y)) { | |||
| 245 | + | continue | |||
| 246 | + | } | |||
| 247 | + | if len(update.Rows) >= r.limits.MaxRowsPerUpdate { | |||
| 248 | + | return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate) | |||
| 249 | + | } | |||
| 250 | + | row, err := encodeRow(y, frame.Row(y), &update.Styles, styleIDs, r.limits.MaxStyles) | |||
| 251 | + | if err != nil { | |||
| 252 | + | return Update{}, err | |||
| 253 | + | } | |||
| 254 | + | update.Rows = append(update.Rows, row) | |||
| 255 | + | } | |||
| 256 | + | return update, nil | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on | |||
| 260 | + | // the exact encoded size. The previous conservative estimate could reject | |||
| 261 | + | // compact updates well below the configured limit. | |||
| 262 | + | func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | |||
| 263 | + | encoded, err := json.Marshal(update) | |||
| 264 | + | if err != nil { | |||
| 265 | + | return nil, fmt.Errorf("html: encode update: %w", err) | |||
| 266 | + | } | |||
| 267 | + | if len(encoded) > limit { | |||
| 268 | + | return nil, fmt.Errorf("%w: generated update is %d bytes, limit is %d", ErrLimitExceeded, len(encoded), limit) | |||
| 269 | + | } | |||
| 270 | + | return encoded, nil | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func rowsEqual(left, right []core.Cell) bool { | |||
| 274 | + | if len(left) != len(right) { | |||
| 275 | + | return false | |||
| 276 | + | } | |||
| 277 | + | for i := range left { | |||
| 278 | + | if !left[i].Equal(right[i]) { | |||
| 279 | + | return false | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | return true | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) { | |||
| 286 | + | row := RowUpdate{Row: y, Cells: make([]CellUpdate, 0, len(cells))} | |||
| 287 | + | for x := 0; x < len(cells); x++ { | |||
| 288 | + | cell := cells[x] | |||
| 289 | + | if cell.Continuation { | |||
| 290 | + | continue | |||
| 291 | + | } | |||
| 292 | + | style := styleFromCore(cell.Style) | |||
| 293 | + | styleID, ok := styleIDs[style] | |||
| 294 | + | if !ok { | |||
| 295 | + | if len(*styles) >= maxStyles { | |||
| 296 | + | return RowUpdate{}, fmt.Errorf("%w: update styles exceed limit %d", ErrLimitExceeded, maxStyles) | |||
| 297 | + | } | |||
| 298 | + | styleID = len(*styles) | |||
| 299 | + | styleIDs[style] = styleID | |||
| 300 | + | *styles = append(*styles, style) | |||
| 301 | + | } | |||
| 302 | + | width := core.RuneWidth(cell.Rune) | |||
| 303 | + | text := string(cell.Rune) | |||
| 304 | + | if cell.Rune == 0 { | |||
| 305 | + | width = 1 | |||
| 306 | + | text = " " | |||
| 307 | + | } | |||
| 308 | + | row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID}) | |||
| 309 | + | } | |||
| 310 | + | return row, nil | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func validateCoreStyle(style core.Style) error { | |||
| 314 | + | const knownAttrs = core.AttrDim | core.AttrUnderline | core.AttrBlink | core.AttrStrikethrough | |||
| 315 | + | if style.Attrs & ^knownAttrs != 0 { | |||
| 316 | + | return fmt.Errorf("unknown style attribute bits %#x", style.Attrs&^knownAttrs) | |||
| 317 | + | } | |||
| 318 | + | if style.UnderlineStyle > core.UnderlineDashed { | |||
| 319 | + | return fmt.Errorf("invalid underline style %d", style.UnderlineStyle) | |||
| 320 | + | } | |||
| 321 | + | if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > 255) { | |||
| 322 | + | return fmt.Errorf("invalid foreground index %d", style.Foreground) | |||
| 323 | + | } | |||
| 324 | + | if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > 255) { | |||
| 325 | + | return fmt.Errorf("invalid background index %d", style.Background) | |||
| 326 | + | } | |||
| 327 | + | if !style.HasUnderlineColorRGB && style.HasUnderlineColor && (style.UnderlineColor < 0 || style.UnderlineColor > 255) { | |||
| 328 | + | return fmt.Errorf("invalid underline color index %d", style.UnderlineColor) | |||
| 329 | + | } | |||
| 330 | + | return nil | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func styleFromCore(style core.Style) Style { | |||
| 334 | + | return Style{ | |||
| 335 | + | Bold: style.Bold, | |||
| 336 | + | Italic: style.Italic, | |||
| 337 | + | Inverse: style.Inverse, | |||
| 338 | + | Dim: style.Attrs&core.AttrDim != 0, | |||
| 339 | + | Blink: style.Attrs&core.AttrBlink != 0, | |||
| 340 | + | Strikethrough: style.Attrs&core.AttrStrikethrough != 0, | |||
| 341 | + | Underline: style.Attrs&core.AttrUnderline != 0, | |||
| 342 | + | UnderlineStyle: style.UnderlineStyle, | |||
| 343 | + | Foreground: colorFromCore(style.Foreground, style.HasForegroundRGB, style.ForegroundRGB), | |||
| 344 | + | Background: colorFromCore(style.Background, style.HasBackgroundRGB, style.BackgroundRGB), | |||
| 345 | + | UnderlineColor: underlineColorFromCore(style), | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | // Prepare validates every indexed color before these uint8 conversions. | |||
| 350 | + | func colorFromCore(index int, hasRGB bool, rgb core.RGB) Color { | |||
| 351 | + | if hasRGB { | |||
| 352 | + | return Color{Kind: ColorRGB, RGB: RGB{R: rgb.R, G: rgb.G, B: rgb.B}} | |||
| 353 | + | } | |||
| 354 | + | if index >= 0 { | |||
| 355 | + | return Color{Kind: ColorIndexed, Index: uint8(index)} | |||
| 356 | + | } | |||
| 357 | + | return Color{Kind: ColorDefault} | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func underlineColorFromCore(style core.Style) Color { | |||
| 361 | + | if style.HasUnderlineColorRGB { | |||
| 362 | + | return Color{Kind: ColorRGB, RGB: RGB{R: style.UnderlineColorRGB.R, G: style.UnderlineColorRGB.G, B: style.UnderlineColorRGB.B}} | |||
| 363 | + | } | |||
| 364 | + | if style.HasUnderlineColor { | |||
| 365 | + | return Color{Kind: ColorIndexed, Index: uint8(style.UnderlineColor)} | |||
| 366 | + | } | |||
| 367 | + | return Color{Kind: ColorDefault} | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | // Update returns an owned copy that remains valid after later renderer work. | |||
| 371 | + | func (p *PreparedDraw) Update() Update { | |||
| 372 | + | if p == nil || p.tx == nil { | |||
| 373 | + | return Update{} | |||
| 374 | + | } | |||
| 375 | + | return cloneUpdate(p.tx.update) | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | // JSON returns an owned canonical JSON representation of Update. | |||
| 379 | + | func (p *PreparedDraw) JSON() []byte { | |||
| 380 | + | if p == nil || p.tx == nil { | |||
| 381 | + | return nil | |||
| 382 | + | } | |||
| 383 | + | return append([]byte(nil), p.tx.json...) | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | // Commit atomically advances the renderer shadow exactly once. | |||
| 387 | + | func (p *PreparedDraw) Commit() error { | |||
| 388 | + | if p == nil || p.tx == nil || p.tx.renderer == nil { | |||
| 389 | + | return ErrStaleDraw | |||
| 390 | + | } | |||
| 391 | + | tx := p.tx | |||
| 392 | + | if tx.state != transactionPending { | |||
| 393 | + | return ErrFinalizedDraw | |||
| 394 | + | } | |||
| 395 | + | r := tx.renderer | |||
| 396 | + | if tx.generation != r.generation || r.pending != tx { | |||
| 397 | + | return ErrStaleDraw | |||
| 398 | + | } | |||
| 399 | + | r.committed = tx.frame | |||
| 400 | + | r.cursor = tx.cursor | |||
| 401 | + | r.initialized = true | |||
| 402 | + | r.pending = nil | |||
| 403 | + | tx.state = transactionCommitted | |||
| 404 | + | return nil | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | // Abort finalizes the transaction without advancing the renderer shadow. | |||
| 408 | + | func (p *PreparedDraw) Abort() error { | |||
| 409 | + | if p == nil || p.tx == nil || p.tx.renderer == nil { | |||
| 410 | + | return ErrStaleDraw | |||
| 411 | + | } | |||
| 412 | + | tx := p.tx | |||
| 413 | + | if tx.state != transactionPending { | |||
| 414 | + | return ErrFinalizedDraw | |||
| 415 | + | } | |||
| 416 | + | r := tx.renderer | |||
| 417 | + | if tx.generation != r.generation || r.pending != tx { | |||
| 418 | + | return ErrStaleDraw | |||
| 419 | + | } | |||
| 420 | + | r.pending = nil | |||
| 421 | + | tx.state = transactionAborted | |||
| 422 | + | return nil | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | // Reset invalidates any prepared draw and clears committed state. | |||
| 426 | + | func (r *Renderer) Reset() { | |||
| 427 | + | if r == nil { | |||
| 428 | + | return | |||
| 429 | + | } | |||
| 430 | + | r.generation++ | |||
| 431 | + | r.pending = nil | |||
| 432 | + | r.committed = core.Frame{} | |||
| 433 | + | r.cursor = Cursor{} | |||
| 434 | + | r.initialized = false | |||
| 435 | + | } | |||
A html/renderer_test.go +230 -0
| @@ -0,0 +1,230 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev-vt/core" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRendererSnapshotCommitsTransactionally(t *testing.T) { | |
| 13 | + | frame := core.NewFrame(3, 1) | |
| 14 | + | frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) | |
| 15 | + | frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) | |
| 16 | + | frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |
| 17 | + | ||
| 18 | + | renderer, err := New(Options{}) | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | ||
| 21 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true}) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | require.True(t, prepared.Update().Snapshot) | |
| 24 | + | require.Len(t, prepared.Update().Rows, 1) | |
| 25 | + | require.Equal(t, []CellUpdate{ | |
| 26 | + | {Column: 0, Width: 1, Text: "A", Style: 0}, | |
| 27 | + | {Column: 1, Width: 2, Text: "界", Style: 0}, | |
| 28 | + | }, prepared.Update().Rows[0].Cells) | |
| 29 | + | require.NotContains(t, string(prepared.JSON()), `"kind":0,"rgb"`) | |
| 30 | + | ||
| 31 | + | _, err = renderer.Prepare(frame, nil, false, Cursor{}) | |
| 32 | + | require.ErrorIs(t, err, ErrPendingDraw) | |
| 33 | + | require.NoError(t, prepared.Commit()) | |
| 34 | + | require.ErrorIs(t, prepared.Commit(), ErrFinalizedDraw) | |
| 35 | + | ||
| 36 | + | unchanged, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true}) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | require.False(t, unchanged.Update().Snapshot) | |
| 39 | + | require.Empty(t, unchanged.Update().Rows) | |
| 40 | + | require.NoError(t, unchanged.Abort()) | |
| 41 | + | require.ErrorIs(t, unchanged.Abort(), ErrFinalizedDraw) | |
| 42 | + | ||
| 43 | + | var nilDraw *PreparedDraw | |
| 44 | + | require.True(t, errors.Is(nilDraw.Commit(), ErrStaleDraw)) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestPreparedJSONMatchesBrowserFixture(t *testing.T) { | |
| 48 | + | frame := core.NewFrame(3, 1) | |
| 49 | + | frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) | |
| 50 | + | frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) | |
| 51 | + | frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |
| 52 | + | renderer, err := New(Options{}) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{Column: 1, Visible: true, Style: 3, StyleSet: true}) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | fixture, err := os.ReadFile("../internal/htmlharness/testdata/snapshot.json") | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | require.JSONEq(t, string(fixture), string(prepared.JSON())) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestRendererAcceptsCellSourceWithoutFrameCopy(t *testing.T) { | |
| 62 | + | frame := core.NewFrame(2, 1) | |
| 63 | + | frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) | |
| 64 | + | snapshot := frame.Clone() | |
| 65 | + | ||
| 66 | + | renderer, err := New(Options{}) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | prepared, err := renderer.Prepare(cellSourceFunc{ | |
| 69 | + | columns: snapshot.Columns(), | |
| 70 | + | rows: snapshot.Rows(), | |
| 71 | + | cell: snapshot.Cell, | |
| 72 | + | }, nil, false, Cursor{}) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | require.True(t, prepared.Update().Snapshot) | |
| 75 | + | require.Equal(t, "A", prepared.Update().Rows[0].Cells[0].Text) | |
| 76 | + | require.NoError(t, prepared.Commit()) | |
| 77 | + | ||
| 78 | + | _, err = renderer.Prepare(nil, nil, false, Cursor{}) | |
| 79 | + | require.ErrorContains(t, err, "nil cell source") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | type cellSourceFunc struct { | |
| 83 | + | columns int | |
| 84 | + | rows int | |
| 85 | + | cell func(x, y int) core.Cell | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (s cellSourceFunc) Columns() int { return s.columns } | |
| 89 | + | func (s cellSourceFunc) Rows() int { return s.rows } | |
| 90 | + | func (s cellSourceFunc) Cell(x, y int) core.Cell { return s.cell(x, y) } | |
| 91 | + | func (s cellSourceFunc) At(x, y int) core.Cell { return s.cell(x, y) } | |
| 92 | + | func (s cellSourceFunc) Row(y int) []core.Cell { return nil } | |
| 93 | + | func (s cellSourceFunc) WriteRow(y, x int, c []core.Cell) int { return 0 } | |
| 94 | + | ||
| 95 | + | func TestRendererNormalizesWrapPendingCursor(t *testing.T) { | |
| 96 | + | frame := core.NewFrame(3, 2) | |
| 97 | + | renderer, err := New(Options{}) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 3, Visible: true}) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | require.Equal(t, 2, prepared.Update().Cursor.Column) | |
| 102 | + | require.NoError(t, prepared.Commit()) | |
| 103 | + | ||
| 104 | + | _, err = renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 4}) | |
| 105 | + | require.ErrorContains(t, err, "outside") | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestRendererEnforcesExactGeneratedByteLimit(t *testing.T) { | |
| 109 | + | frame := core.NewFrame(1, 1) | |
| 110 | + | renderer, err := New(Options{}) | |
| 111 | + | require.NoError(t, err) | |
| 112 | + | prepared, err := renderer.Prepare(frame, nil, true, Cursor{}) | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | size := len(prepared.JSON()) | |
| 115 | + | require.NoError(t, prepared.Commit()) | |
| 116 | + | ||
| 117 | + | tight, err := New(Options{Limits: Limits{MaxGeneratedBytes: size}}) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | accepted, err := tight.Prepare(frame, nil, true, Cursor{}) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | require.NoError(t, accepted.Commit()) | |
| 122 | + | ||
| 123 | + | strict, err := New(Options{Limits: Limits{MaxGeneratedBytes: size - 1}}) | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | _, err = strict.Prepare(frame, nil, true, Cursor{}) | |
| 126 | + | require.ErrorIs(t, err, ErrLimitExceeded) | |
| 127 | + | require.ErrorContains(t, err, "generated update is") | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestRendererUsesIncrementalDamageWithNormalCounts(t *testing.T) { | |
| 131 | + | frame := core.NewFrame(2, 2) | |
| 132 | + | renderer, err := New(Options{}) | |
| 133 | + | require.NoError(t, err) | |
| 134 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | require.NoError(t, first.Commit()) | |
| 137 | + | ||
| 138 | + | frame.Set(0, 0, core.Cell{Rune: 'X', Style: core.DefaultStyle()}) | |
| 139 | + | prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, false, Cursor{}) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | require.False(t, prepared.Update().Snapshot) | |
| 142 | + | require.NoError(t, prepared.Commit()) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestRendererFindsChangesOutsideDamage(t *testing.T) { | |
| 146 | + | frame := core.NewFrame(2, 2) | |
| 147 | + | renderer, err := New(Options{}) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 150 | + | require.NoError(t, err) | |
| 151 | + | require.NoError(t, first.Commit()) | |
| 152 | + | ||
| 153 | + | frame.Set(1, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()}) | |
| 154 | + | prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false, Cursor{}) | |
| 155 | + | require.NoError(t, err) | |
| 156 | + | require.False(t, prepared.Update().Snapshot) | |
| 157 | + | require.Equal(t, 1, prepared.Update().Rows[0].Row) | |
| 158 | + | require.NoError(t, prepared.Commit()) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestRendererSnapshotsScrollAndEmitsCursorOnlyChanges(t *testing.T) { | |
| 162 | + | frame := core.NewFrame(2, 2) | |
| 163 | + | renderer, err := New(Options{}) | |
| 164 | + | require.NoError(t, err) | |
| 165 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 166 | + | require.NoError(t, err) | |
| 167 | + | require.NoError(t, first.Commit()) | |
| 168 | + | ||
| 169 | + | cursorOnly, err := renderer.Prepare(frame, nil, false, Cursor{Row: 1, Column: 1, Visible: true, Style: 4, StyleSet: true}) | |
| 170 | + | require.NoError(t, err) | |
| 171 | + | require.Empty(t, cursorOnly.Update().Rows) | |
| 172 | + | require.NotNil(t, cursorOnly.Update().Rows) | |
| 173 | + | require.NotNil(t, cursorOnly.Update().Styles) | |
| 174 | + | require.Contains(t, string(cursorOnly.JSON()), `"rows":[]`) | |
| 175 | + | require.Contains(t, string(cursorOnly.JSON()), `"styles":[]`) | |
| 176 | + | require.Equal(t, 1, cursorOnly.Update().Cursor.Row) | |
| 177 | + | require.NoError(t, cursorOnly.Commit()) | |
| 178 | + | ||
| 179 | + | scroll, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageScrollUp, X: 0, Y: 0, Width: 2, Height: 2, Count: 1}}, false, Cursor{}) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | require.True(t, scroll.Update().Snapshot) | |
| 182 | + | require.Len(t, scroll.Update().Rows, 2) | |
| 183 | + | require.NoError(t, scroll.Abort()) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func TestPreparedDrawReturnsOwnedUpdateAndJSON(t *testing.T) { | |
| 187 | + | frame := core.NewFrame(1, 1) | |
| 188 | + | renderer, err := New(Options{}) | |
| 189 | + | require.NoError(t, err) | |
| 190 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 191 | + | require.NoError(t, err) | |
| 192 | + | ||
| 193 | + | update := prepared.Update() | |
| 194 | + | encoded := prepared.JSON() | |
| 195 | + | wantJSON := string(encoded) | |
| 196 | + | update.Rows[0].Cells[0].Text = "mutated" | |
| 197 | + | encoded[0] = 'X' | |
| 198 | + | require.Equal(t, " ", prepared.Update().Rows[0].Cells[0].Text) | |
| 199 | + | require.JSONEq(t, wantJSON, string(prepared.JSON())) | |
| 200 | + | require.Equal(t, byte('{'), prepared.JSON()[0]) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestCopiedPreparedDrawSharesFinalization(t *testing.T) { | |
| 204 | + | frame := core.NewFrame(1, 1) | |
| 205 | + | renderer, err := New(Options{}) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 208 | + | require.NoError(t, err) | |
| 209 | + | copied := *prepared | |
| 210 | + | require.NoError(t, copied.Commit()) | |
| 211 | + | require.ErrorIs(t, prepared.Abort(), ErrFinalizedDraw) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) { | |
| 215 | + | frame := core.NewFrame(1, 1) | |
| 216 | + | renderer, err := New(Options{}) | |
| 217 | + | require.NoError(t, err) | |
| 218 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | copied := *prepared | |
| 221 | + | ||
| 222 | + | renderer.Reset() | |
| 223 | + | require.ErrorIs(t, prepared.Commit(), ErrStaleDraw) | |
| 224 | + | require.ErrorIs(t, copied.Abort(), ErrStaleDraw) | |
| 225 | + | ||
| 226 | + | next, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 227 | + | require.NoError(t, err) | |
| 228 | + | require.True(t, next.Update().Snapshot) | |
| 229 | + | require.NoError(t, next.Commit()) | |
| 230 | + | } | |
| @@ -0,0 +1,230 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "os" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev-vt/core" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestRendererSnapshotCommitsTransactionally(t *testing.T) { | |||
| 13 | + | frame := core.NewFrame(3, 1) | |||
| 14 | + | frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) | |||
| 15 | + | frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) | |||
| 16 | + | frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |||
| 17 | + | ||||
| 18 | + | renderer, err := New(Options{}) | |||
| 19 | + | require.NoError(t, err) | |||
| 20 | + | ||||
| 21 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true}) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | require.True(t, prepared.Update().Snapshot) | |||
| 24 | + | require.Len(t, prepared.Update().Rows, 1) | |||
| 25 | + | require.Equal(t, []CellUpdate{ | |||
| 26 | + | {Column: 0, Width: 1, Text: "A", Style: 0}, | |||
| 27 | + | {Column: 1, Width: 2, Text: "界", Style: 0}, | |||
| 28 | + | }, prepared.Update().Rows[0].Cells) | |||
| 29 | + | require.NotContains(t, string(prepared.JSON()), `"kind":0,"rgb"`) | |||
| 30 | + | ||||
| 31 | + | _, err = renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 32 | + | require.ErrorIs(t, err, ErrPendingDraw) | |||
| 33 | + | require.NoError(t, prepared.Commit()) | |||
| 34 | + | require.ErrorIs(t, prepared.Commit(), ErrFinalizedDraw) | |||
| 35 | + | ||||
| 36 | + | unchanged, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true}) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | require.False(t, unchanged.Update().Snapshot) | |||
| 39 | + | require.Empty(t, unchanged.Update().Rows) | |||
| 40 | + | require.NoError(t, unchanged.Abort()) | |||
| 41 | + | require.ErrorIs(t, unchanged.Abort(), ErrFinalizedDraw) | |||
| 42 | + | ||||
| 43 | + | var nilDraw *PreparedDraw | |||
| 44 | + | require.True(t, errors.Is(nilDraw.Commit(), ErrStaleDraw)) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestPreparedJSONMatchesBrowserFixture(t *testing.T) { | |||
| 48 | + | frame := core.NewFrame(3, 1) | |||
| 49 | + | frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) | |||
| 50 | + | frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) | |||
| 51 | + | frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |||
| 52 | + | renderer, err := New(Options{}) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{Column: 1, Visible: true, Style: 3, StyleSet: true}) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | fixture, err := os.ReadFile("../internal/htmlharness/testdata/snapshot.json") | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | require.JSONEq(t, string(fixture), string(prepared.JSON())) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestRendererAcceptsCellSourceWithoutFrameCopy(t *testing.T) { | |||
| 62 | + | frame := core.NewFrame(2, 1) | |||
| 63 | + | frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) | |||
| 64 | + | snapshot := frame.Clone() | |||
| 65 | + | ||||
| 66 | + | renderer, err := New(Options{}) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | prepared, err := renderer.Prepare(cellSourceFunc{ | |||
| 69 | + | columns: snapshot.Columns(), | |||
| 70 | + | rows: snapshot.Rows(), | |||
| 71 | + | cell: snapshot.Cell, | |||
| 72 | + | }, nil, false, Cursor{}) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | require.True(t, prepared.Update().Snapshot) | |||
| 75 | + | require.Equal(t, "A", prepared.Update().Rows[0].Cells[0].Text) | |||
| 76 | + | require.NoError(t, prepared.Commit()) | |||
| 77 | + | ||||
| 78 | + | _, err = renderer.Prepare(nil, nil, false, Cursor{}) | |||
| 79 | + | require.ErrorContains(t, err, "nil cell source") | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | type cellSourceFunc struct { | |||
| 83 | + | columns int | |||
| 84 | + | rows int | |||
| 85 | + | cell func(x, y int) core.Cell | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (s cellSourceFunc) Columns() int { return s.columns } | |||
| 89 | + | func (s cellSourceFunc) Rows() int { return s.rows } | |||
| 90 | + | func (s cellSourceFunc) Cell(x, y int) core.Cell { return s.cell(x, y) } | |||
| 91 | + | func (s cellSourceFunc) At(x, y int) core.Cell { return s.cell(x, y) } | |||
| 92 | + | func (s cellSourceFunc) Row(y int) []core.Cell { return nil } | |||
| 93 | + | func (s cellSourceFunc) WriteRow(y, x int, c []core.Cell) int { return 0 } | |||
| 94 | + | ||||
| 95 | + | func TestRendererNormalizesWrapPendingCursor(t *testing.T) { | |||
| 96 | + | frame := core.NewFrame(3, 2) | |||
| 97 | + | renderer, err := New(Options{}) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 3, Visible: true}) | |||
| 100 | + | require.NoError(t, err) | |||
| 101 | + | require.Equal(t, 2, prepared.Update().Cursor.Column) | |||
| 102 | + | require.NoError(t, prepared.Commit()) | |||
| 103 | + | ||||
| 104 | + | _, err = renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 4}) | |||
| 105 | + | require.ErrorContains(t, err, "outside") | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestRendererEnforcesExactGeneratedByteLimit(t *testing.T) { | |||
| 109 | + | frame := core.NewFrame(1, 1) | |||
| 110 | + | renderer, err := New(Options{}) | |||
| 111 | + | require.NoError(t, err) | |||
| 112 | + | prepared, err := renderer.Prepare(frame, nil, true, Cursor{}) | |||
| 113 | + | require.NoError(t, err) | |||
| 114 | + | size := len(prepared.JSON()) | |||
| 115 | + | require.NoError(t, prepared.Commit()) | |||
| 116 | + | ||||
| 117 | + | tight, err := New(Options{Limits: Limits{MaxGeneratedBytes: size}}) | |||
| 118 | + | require.NoError(t, err) | |||
| 119 | + | accepted, err := tight.Prepare(frame, nil, true, Cursor{}) | |||
| 120 | + | require.NoError(t, err) | |||
| 121 | + | require.NoError(t, accepted.Commit()) | |||
| 122 | + | ||||
| 123 | + | strict, err := New(Options{Limits: Limits{MaxGeneratedBytes: size - 1}}) | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | _, err = strict.Prepare(frame, nil, true, Cursor{}) | |||
| 126 | + | require.ErrorIs(t, err, ErrLimitExceeded) | |||
| 127 | + | require.ErrorContains(t, err, "generated update is") | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestRendererUsesIncrementalDamageWithNormalCounts(t *testing.T) { | |||
| 131 | + | frame := core.NewFrame(2, 2) | |||
| 132 | + | renderer, err := New(Options{}) | |||
| 133 | + | require.NoError(t, err) | |||
| 134 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 135 | + | require.NoError(t, err) | |||
| 136 | + | require.NoError(t, first.Commit()) | |||
| 137 | + | ||||
| 138 | + | frame.Set(0, 0, core.Cell{Rune: 'X', Style: core.DefaultStyle()}) | |||
| 139 | + | prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, false, Cursor{}) | |||
| 140 | + | require.NoError(t, err) | |||
| 141 | + | require.False(t, prepared.Update().Snapshot) | |||
| 142 | + | require.NoError(t, prepared.Commit()) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestRendererFindsChangesOutsideDamage(t *testing.T) { | |||
| 146 | + | frame := core.NewFrame(2, 2) | |||
| 147 | + | renderer, err := New(Options{}) | |||
| 148 | + | require.NoError(t, err) | |||
| 149 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 150 | + | require.NoError(t, err) | |||
| 151 | + | require.NoError(t, first.Commit()) | |||
| 152 | + | ||||
| 153 | + | frame.Set(1, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()}) | |||
| 154 | + | prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false, Cursor{}) | |||
| 155 | + | require.NoError(t, err) | |||
| 156 | + | require.False(t, prepared.Update().Snapshot) | |||
| 157 | + | require.Equal(t, 1, prepared.Update().Rows[0].Row) | |||
| 158 | + | require.NoError(t, prepared.Commit()) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestRendererSnapshotsScrollAndEmitsCursorOnlyChanges(t *testing.T) { | |||
| 162 | + | frame := core.NewFrame(2, 2) | |||
| 163 | + | renderer, err := New(Options{}) | |||
| 164 | + | require.NoError(t, err) | |||
| 165 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 166 | + | require.NoError(t, err) | |||
| 167 | + | require.NoError(t, first.Commit()) | |||
| 168 | + | ||||
| 169 | + | cursorOnly, err := renderer.Prepare(frame, nil, false, Cursor{Row: 1, Column: 1, Visible: true, Style: 4, StyleSet: true}) | |||
| 170 | + | require.NoError(t, err) | |||
| 171 | + | require.Empty(t, cursorOnly.Update().Rows) | |||
| 172 | + | require.NotNil(t, cursorOnly.Update().Rows) | |||
| 173 | + | require.NotNil(t, cursorOnly.Update().Styles) | |||
| 174 | + | require.Contains(t, string(cursorOnly.JSON()), `"rows":[]`) | |||
| 175 | + | require.Contains(t, string(cursorOnly.JSON()), `"styles":[]`) | |||
| 176 | + | require.Equal(t, 1, cursorOnly.Update().Cursor.Row) | |||
| 177 | + | require.NoError(t, cursorOnly.Commit()) | |||
| 178 | + | ||||
| 179 | + | scroll, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageScrollUp, X: 0, Y: 0, Width: 2, Height: 2, Count: 1}}, false, Cursor{}) | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | require.True(t, scroll.Update().Snapshot) | |||
| 182 | + | require.Len(t, scroll.Update().Rows, 2) | |||
| 183 | + | require.NoError(t, scroll.Abort()) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func TestPreparedDrawReturnsOwnedUpdateAndJSON(t *testing.T) { | |||
| 187 | + | frame := core.NewFrame(1, 1) | |||
| 188 | + | renderer, err := New(Options{}) | |||
| 189 | + | require.NoError(t, err) | |||
| 190 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 191 | + | require.NoError(t, err) | |||
| 192 | + | ||||
| 193 | + | update := prepared.Update() | |||
| 194 | + | encoded := prepared.JSON() | |||
| 195 | + | wantJSON := string(encoded) | |||
| 196 | + | update.Rows[0].Cells[0].Text = "mutated" | |||
| 197 | + | encoded[0] = 'X' | |||
| 198 | + | require.Equal(t, " ", prepared.Update().Rows[0].Cells[0].Text) | |||
| 199 | + | require.JSONEq(t, wantJSON, string(prepared.JSON())) | |||
| 200 | + | require.Equal(t, byte('{'), prepared.JSON()[0]) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func TestCopiedPreparedDrawSharesFinalization(t *testing.T) { | |||
| 204 | + | frame := core.NewFrame(1, 1) | |||
| 205 | + | renderer, err := New(Options{}) | |||
| 206 | + | require.NoError(t, err) | |||
| 207 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 208 | + | require.NoError(t, err) | |||
| 209 | + | copied := *prepared | |||
| 210 | + | require.NoError(t, copied.Commit()) | |||
| 211 | + | require.ErrorIs(t, prepared.Abort(), ErrFinalizedDraw) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) { | |||
| 215 | + | frame := core.NewFrame(1, 1) | |||
| 216 | + | renderer, err := New(Options{}) | |||
| 217 | + | require.NoError(t, err) | |||
| 218 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 219 | + | require.NoError(t, err) | |||
| 220 | + | copied := *prepared | |||
| 221 | + | ||||
| 222 | + | renderer.Reset() | |||
| 223 | + | require.ErrorIs(t, prepared.Commit(), ErrStaleDraw) | |||
| 224 | + | require.ErrorIs(t, copied.Abort(), ErrStaleDraw) | |||
| 225 | + | ||||
| 226 | + | next, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 227 | + | require.NoError(t, err) | |||
| 228 | + | require.True(t, next.Update().Snapshot) | |||
| 229 | + | require.NoError(t, next.Commit()) | |||
| 230 | + | } | |||
A html/style_test.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev-vt/core" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestRendererCanonicalizesEveryStyleField(t *testing.T) { | |
| 11 | + | style := core.DefaultStyle() | |
| 12 | + | style.Bold = true | |
| 13 | + | style.Italic = true | |
| 14 | + | style.Inverse = true | |
| 15 | + | style.Attrs = core.AttrDim | core.AttrUnderline | core.AttrBlink | core.AttrStrikethrough | |
| 16 | + | style.Foreground = 12 | |
| 17 | + | style.HasBackgroundRGB = true | |
| 18 | + | style.BackgroundRGB = core.RGB{R: 1, G: 2, B: 3} | |
| 19 | + | style.UnderlineStyle = core.UnderlineCurly | |
| 20 | + | style.HasUnderlineColor = true | |
| 21 | + | style.UnderlineColor = 200 | |
| 22 | + | ||
| 23 | + | frame := core.NewFrame(1, 1) | |
| 24 | + | frame.Set(0, 0, core.Cell{Rune: 'X', Style: style}) | |
| 25 | + | renderer, err := New(Options{}) | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | require.Equal(t, []Style{{ | |
| 30 | + | Bold: true, Italic: true, Inverse: true, Dim: true, Blink: true, | |
| 31 | + | Strikethrough: true, Underline: true, UnderlineStyle: core.UnderlineCurly, | |
| 32 | + | Foreground: Color{Kind: ColorIndexed, Index: 12}, | |
| 33 | + | Background: Color{Kind: ColorRGB, RGB: RGB{R: 1, G: 2, B: 3}}, | |
| 34 | + | UnderlineColor: Color{Kind: ColorIndexed, Index: 200}, | |
| 35 | + | }}, prepared.Update().Styles) | |
| 36 | + | } | |
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev-vt/core" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestRendererCanonicalizesEveryStyleField(t *testing.T) { | |||
| 11 | + | style := core.DefaultStyle() | |||
| 12 | + | style.Bold = true | |||
| 13 | + | style.Italic = true | |||
| 14 | + | style.Inverse = true | |||
| 15 | + | style.Attrs = core.AttrDim | core.AttrUnderline | core.AttrBlink | core.AttrStrikethrough | |||
| 16 | + | style.Foreground = 12 | |||
| 17 | + | style.HasBackgroundRGB = true | |||
| 18 | + | style.BackgroundRGB = core.RGB{R: 1, G: 2, B: 3} | |||
| 19 | + | style.UnderlineStyle = core.UnderlineCurly | |||
| 20 | + | style.HasUnderlineColor = true | |||
| 21 | + | style.UnderlineColor = 200 | |||
| 22 | + | ||||
| 23 | + | frame := core.NewFrame(1, 1) | |||
| 24 | + | frame.Set(0, 0, core.Cell{Rune: 'X', Style: style}) | |||
| 25 | + | renderer, err := New(Options{}) | |||
| 26 | + | require.NoError(t, err) | |||
| 27 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | require.Equal(t, []Style{{ | |||
| 30 | + | Bold: true, Italic: true, Inverse: true, Dim: true, Blink: true, | |||
| 31 | + | Strikethrough: true, Underline: true, UnderlineStyle: core.UnderlineCurly, | |||
| 32 | + | Foreground: Color{Kind: ColorIndexed, Index: 12}, | |||
| 33 | + | Background: Color{Kind: ColorRGB, RGB: RGB{R: 1, G: 2, B: 3}}, | |||
| 34 | + | UnderlineColor: Color{Kind: ColorIndexed, Index: 200}, | |||
| 35 | + | }}, prepared.Update().Styles) | |||
| 36 | + | } | |||
A html/terminal.css +120 -0
| @@ -0,0 +1,120 @@ | |||
| 1 | + | .vev-terminal { | |
| 2 | + | --vev-fg: #d8dee9; | |
| 3 | + | --vev-bg: #101218; | |
| 4 | + | --vev-cursor: #ff8700; | |
| 5 | + | --vev-selection-bg: #394150; | |
| 6 | + | --vev-selection-fg: #ffffff; | |
| 7 | + | --vev-color-0: #000000; | |
| 8 | + | --vev-color-1: #800000; | |
| 9 | + | --vev-color-2: #008000; | |
| 10 | + | --vev-color-3: #808000; | |
| 11 | + | --vev-color-4: #000080; | |
| 12 | + | --vev-color-5: #800080; | |
| 13 | + | --vev-color-6: #008080; | |
| 14 | + | --vev-color-7: #c0c0c0; | |
| 15 | + | --vev-color-8: #808080; | |
| 16 | + | --vev-color-9: #ff0000; | |
| 17 | + | --vev-color-10: #00ff00; | |
| 18 | + | --vev-color-11: #ffff00; | |
| 19 | + | --vev-color-12: #0000ff; | |
| 20 | + | --vev-color-13: #ff00ff; | |
| 21 | + | --vev-color-14: #00ffff; | |
| 22 | + | --vev-color-15: #ffffff; | |
| 23 | + | --vev-font-family: ui-monospace, "Cascadia Mono", "Noto Sans Mono", monospace; | |
| 24 | + | --vev-font-size: 14px; | |
| 25 | + | --vev-line-height: 1.2; | |
| 26 | + | --vev-cell-width: 1ch; | |
| 27 | + | --vev-cell-height: calc(var(--vev-font-size) * var(--vev-line-height)); | |
| 28 | + | ||
| 29 | + | position: relative; | |
| 30 | + | display: inline-block; | |
| 31 | + | overflow: hidden; | |
| 32 | + | color: var(--vev-fg); | |
| 33 | + | background: var(--vev-bg); | |
| 34 | + | font-family: var(--vev-font-family); | |
| 35 | + | font-size: var(--vev-font-size); | |
| 36 | + | line-height: var(--vev-line-height); | |
| 37 | + | font-variant-ligatures: none; | |
| 38 | + | white-space: pre; | |
| 39 | + | user-select: text; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | .vev-terminal:focus-within { | |
| 43 | + | outline: 2px solid var(--vev-cursor); | |
| 44 | + | outline-offset: 2px; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | .vev-terminal__viewport { | |
| 48 | + | position: relative; | |
| 49 | + | display: grid; | |
| 50 | + | grid-template-rows: repeat(var(--vev-rows), var(--vev-cell-height)); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | .vev-terminal__row { | |
| 54 | + | contain: strict; | |
| 55 | + | display: grid; | |
| 56 | + | grid-template-columns: repeat(var(--vev-cols), var(--vev-cell-width)); | |
| 57 | + | min-width: calc(var(--vev-cols) * var(--vev-cell-width)); | |
| 58 | + | height: var(--vev-cell-height); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | .vev-terminal__cell { | |
| 62 | + | overflow: hidden; | |
| 63 | + | min-width: 0; | |
| 64 | + | color: var(--vev-cell-fg, var(--vev-fg)); | |
| 65 | + | background: var(--vev-cell-bg, var(--vev-bg)); | |
| 66 | + | text-decoration-color: var(--vev-cell-underline, currentColor); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | .vev-terminal__cell--bold { font-weight: 700; } | |
| 70 | + | .vev-terminal__cell--italic { font-style: italic; } | |
| 71 | + | .vev-terminal__cell--dim { color: color-mix(in srgb, var(--vev-cell-fg, var(--vev-fg)) 50%, var(--vev-cell-bg, var(--vev-bg))); } | |
| 72 | + | .vev-terminal__cell--strike { text-decoration-line: line-through; } | |
| 73 | + | .vev-terminal__cell--underline { text-decoration-line: underline; } | |
| 74 | + | .vev-terminal__cell--strike.vev-terminal__cell--underline { text-decoration-line: line-through underline; } | |
| 75 | + | .vev-terminal__cell--underline-double { text-decoration-style: double; } | |
| 76 | + | .vev-terminal__cell--underline-wavy { text-decoration-style: wavy; } | |
| 77 | + | .vev-terminal__cell--underline-dotted { text-decoration-style: dotted; } | |
| 78 | + | .vev-terminal__cell--underline-dashed { text-decoration-style: dashed; } | |
| 79 | + | .vev-terminal__cell--blink { animation: vev-terminal-blink 1s step-end infinite; } | |
| 80 | + | ||
| 81 | + | .vev-terminal__cursor { | |
| 82 | + | position: absolute; | |
| 83 | + | z-index: 2; | |
| 84 | + | pointer-events: none; | |
| 85 | + | background: var(--vev-cursor); | |
| 86 | + | opacity: 0.75; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | .vev-terminal__cursor--bar { width: max(1px, calc(var(--vev-cell-width) * 0.12)); } | |
| 90 | + | .vev-terminal__cursor--underline { height: max(1px, calc(var(--vev-cell-height) * 0.12)); } | |
| 91 | + | ||
| 92 | + | .vev-terminal__input { font-size: 16px; } | |
| 93 | + | ||
| 94 | + | .vev-terminal__input, | |
| 95 | + | .vev-terminal__accessible-output { | |
| 96 | + | position: absolute; | |
| 97 | + | width: 1px; | |
| 98 | + | height: 1px; | |
| 99 | + | padding: 0; | |
| 100 | + | margin: -1px; | |
| 101 | + | overflow: hidden; | |
| 102 | + | clip-path: inset(50%); | |
| 103 | + | white-space: pre; | |
| 104 | + | border: 0; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | .vev-terminal ::selection { | |
| 108 | + | color: var(--vev-selection-fg); | |
| 109 | + | background: var(--vev-selection-bg); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | .vev-terminal--mouse-capture { user-select: none; } | |
| 113 | + | ||
| 114 | + | @keyframes vev-terminal-blink { | |
| 115 | + | 50% { visibility: hidden; } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | @media (prefers-reduced-motion: reduce) { | |
| 119 | + | .vev-terminal__cell--blink { animation: none; } | |
| 120 | + | } | |
| @@ -0,0 +1,120 @@ | |||||
| 1 | + | .vev-terminal { | |||
| 2 | + | --vev-fg: #d8dee9; | |||
| 3 | + | --vev-bg: #101218; | |||
| 4 | + | --vev-cursor: #ff8700; | |||
| 5 | + | --vev-selection-bg: #394150; | |||
| 6 | + | --vev-selection-fg: #ffffff; | |||
| 7 | + | --vev-color-0: #000000; | |||
| 8 | + | --vev-color-1: #800000; | |||
| 9 | + | --vev-color-2: #008000; | |||
| 10 | + | --vev-color-3: #808000; | |||
| 11 | + | --vev-color-4: #000080; | |||
| 12 | + | --vev-color-5: #800080; | |||
| 13 | + | --vev-color-6: #008080; | |||
| 14 | + | --vev-color-7: #c0c0c0; | |||
| 15 | + | --vev-color-8: #808080; | |||
| 16 | + | --vev-color-9: #ff0000; | |||
| 17 | + | --vev-color-10: #00ff00; | |||
| 18 | + | --vev-color-11: #ffff00; | |||
| 19 | + | --vev-color-12: #0000ff; | |||
| 20 | + | --vev-color-13: #ff00ff; | |||
| 21 | + | --vev-color-14: #00ffff; | |||
| 22 | + | --vev-color-15: #ffffff; | |||
| 23 | + | --vev-font-family: ui-monospace, "Cascadia Mono", "Noto Sans Mono", monospace; | |||
| 24 | + | --vev-font-size: 14px; | |||
| 25 | + | --vev-line-height: 1.2; | |||
| 26 | + | --vev-cell-width: 1ch; | |||
| 27 | + | --vev-cell-height: calc(var(--vev-font-size) * var(--vev-line-height)); | |||
| 28 | + | ||||
| 29 | + | position: relative; | |||
| 30 | + | display: inline-block; | |||
| 31 | + | overflow: hidden; | |||
| 32 | + | color: var(--vev-fg); | |||
| 33 | + | background: var(--vev-bg); | |||
| 34 | + | font-family: var(--vev-font-family); | |||
| 35 | + | font-size: var(--vev-font-size); | |||
| 36 | + | line-height: var(--vev-line-height); | |||
| 37 | + | font-variant-ligatures: none; | |||
| 38 | + | white-space: pre; | |||
| 39 | + | user-select: text; | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | .vev-terminal:focus-within { | |||
| 43 | + | outline: 2px solid var(--vev-cursor); | |||
| 44 | + | outline-offset: 2px; | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | .vev-terminal__viewport { | |||
| 48 | + | position: relative; | |||
| 49 | + | display: grid; | |||
| 50 | + | grid-template-rows: repeat(var(--vev-rows), var(--vev-cell-height)); | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | .vev-terminal__row { | |||
| 54 | + | contain: strict; | |||
| 55 | + | display: grid; | |||
| 56 | + | grid-template-columns: repeat(var(--vev-cols), var(--vev-cell-width)); | |||
| 57 | + | min-width: calc(var(--vev-cols) * var(--vev-cell-width)); | |||
| 58 | + | height: var(--vev-cell-height); | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | .vev-terminal__cell { | |||
| 62 | + | overflow: hidden; | |||
| 63 | + | min-width: 0; | |||
| 64 | + | color: var(--vev-cell-fg, var(--vev-fg)); | |||
| 65 | + | background: var(--vev-cell-bg, var(--vev-bg)); | |||
| 66 | + | text-decoration-color: var(--vev-cell-underline, currentColor); | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | .vev-terminal__cell--bold { font-weight: 700; } | |||
| 70 | + | .vev-terminal__cell--italic { font-style: italic; } | |||
| 71 | + | .vev-terminal__cell--dim { color: color-mix(in srgb, var(--vev-cell-fg, var(--vev-fg)) 50%, var(--vev-cell-bg, var(--vev-bg))); } | |||
| 72 | + | .vev-terminal__cell--strike { text-decoration-line: line-through; } | |||
| 73 | + | .vev-terminal__cell--underline { text-decoration-line: underline; } | |||
| 74 | + | .vev-terminal__cell--strike.vev-terminal__cell--underline { text-decoration-line: line-through underline; } | |||
| 75 | + | .vev-terminal__cell--underline-double { text-decoration-style: double; } | |||
| 76 | + | .vev-terminal__cell--underline-wavy { text-decoration-style: wavy; } | |||
| 77 | + | .vev-terminal__cell--underline-dotted { text-decoration-style: dotted; } | |||
| 78 | + | .vev-terminal__cell--underline-dashed { text-decoration-style: dashed; } | |||
| 79 | + | .vev-terminal__cell--blink { animation: vev-terminal-blink 1s step-end infinite; } | |||
| 80 | + | ||||
| 81 | + | .vev-terminal__cursor { | |||
| 82 | + | position: absolute; | |||
| 83 | + | z-index: 2; | |||
| 84 | + | pointer-events: none; | |||
| 85 | + | background: var(--vev-cursor); | |||
| 86 | + | opacity: 0.75; | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | .vev-terminal__cursor--bar { width: max(1px, calc(var(--vev-cell-width) * 0.12)); } | |||
| 90 | + | .vev-terminal__cursor--underline { height: max(1px, calc(var(--vev-cell-height) * 0.12)); } | |||
| 91 | + | ||||
| 92 | + | .vev-terminal__input { font-size: 16px; } | |||
| 93 | + | ||||
| 94 | + | .vev-terminal__input, | |||
| 95 | + | .vev-terminal__accessible-output { | |||
| 96 | + | position: absolute; | |||
| 97 | + | width: 1px; | |||
| 98 | + | height: 1px; | |||
| 99 | + | padding: 0; | |||
| 100 | + | margin: -1px; | |||
| 101 | + | overflow: hidden; | |||
| 102 | + | clip-path: inset(50%); | |||
| 103 | + | white-space: pre; | |||
| 104 | + | border: 0; | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | .vev-terminal ::selection { | |||
| 108 | + | color: var(--vev-selection-fg); | |||
| 109 | + | background: var(--vev-selection-bg); | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | .vev-terminal--mouse-capture { user-select: none; } | |||
| 113 | + | ||||
| 114 | + | @keyframes vev-terminal-blink { | |||
| 115 | + | 50% { visibility: hidden; } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | @media (prefers-reduced-motion: reduce) { | |||
| 119 | + | .vev-terminal__cell--blink { animation: none; } | |||
| 120 | + | } | |||
A html/theme.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev-vt/core" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Theme contains generic terminal colors. Consumers can override the same CSS | |
| 12 | + | // custom properties directly when product-specific theme ownership is needed. | |
| 13 | + | type Theme struct { | |
| 14 | + | Foreground core.RGB | |
| 15 | + | Background core.RGB | |
| 16 | + | Cursor core.RGB | |
| 17 | + | Selection core.RGB | |
| 18 | + | SelectionText core.RGB | |
| 19 | + | Palette [16]core.RGB | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // MarshalJSON emits the typed object accepted by the embedded browser | |
| 23 | + | // runtime's setTheme method. | |
| 24 | + | func (theme Theme) MarshalJSON() ([]byte, error) { | |
| 25 | + | palette := [16]RGB{} | |
| 26 | + | for index, color := range theme.Palette { | |
| 27 | + | palette[index] = browserRGB(color) | |
| 28 | + | } | |
| 29 | + | return json.Marshal(struct { | |
| 30 | + | Foreground RGB `json:"foreground"` | |
| 31 | + | Background RGB `json:"background"` | |
| 32 | + | Cursor RGB `json:"cursor"` | |
| 33 | + | Selection RGB `json:"selection"` | |
| 34 | + | SelectionText RGB `json:"selectionText"` | |
| 35 | + | Palette [16]RGB `json:"palette"` | |
| 36 | + | }{ | |
| 37 | + | Foreground: browserRGB(theme.Foreground), Background: browserRGB(theme.Background), | |
| 38 | + | Cursor: browserRGB(theme.Cursor), Selection: browserRGB(theme.Selection), | |
| 39 | + | SelectionText: browserRGB(theme.SelectionText), Palette: palette, | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func browserRGB(color core.RGB) RGB { return RGB{R: color.R, G: color.G, B: color.B} } | |
| 44 | + | ||
| 45 | + | // DefaultTheme returns a neutral dark terminal theme. | |
| 46 | + | func DefaultTheme() Theme { | |
| 47 | + | return Theme{ | |
| 48 | + | Foreground: rgb(0xd8, 0xde, 0xe9), | |
| 49 | + | Background: rgb(0x10, 0x12, 0x18), | |
| 50 | + | Cursor: rgb(0xff, 0x87, 0x00), | |
| 51 | + | Selection: rgb(0x39, 0x41, 0x50), | |
| 52 | + | SelectionText: rgb(0xff, 0xff, 0xff), | |
| 53 | + | Palette: [16]core.RGB{ | |
| 54 | + | rgb(0x00, 0x00, 0x00), rgb(0x80, 0x00, 0x00), rgb(0x00, 0x80, 0x00), rgb(0x80, 0x80, 0x00), | |
| 55 | + | rgb(0x00, 0x00, 0x80), rgb(0x80, 0x00, 0x80), rgb(0x00, 0x80, 0x80), rgb(0xc0, 0xc0, 0xc0), | |
| 56 | + | rgb(0x80, 0x80, 0x80), rgb(0xff, 0x00, 0x00), rgb(0x00, 0xff, 0x00), rgb(0xff, 0xff, 0x00), | |
| 57 | + | rgb(0x00, 0x00, 0xff), rgb(0xff, 0x00, 0xff), rgb(0x00, 0xff, 0xff), rgb(0xff, 0xff, 0xff), | |
| 58 | + | }, | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func rgb(r, g, b uint8) core.RGB { return core.RGB{R: r, G: g, B: b} } | |
| 63 | + | ||
| 64 | + | // CSS returns deterministic custom-property declarations containing only | |
| 65 | + | // numeric RGB values. | |
| 66 | + | func (theme Theme) CSS() string { | |
| 67 | + | var builder strings.Builder | |
| 68 | + | builder.WriteString("--vev-fg:") | |
| 69 | + | writeRGB(&builder, theme.Foreground) | |
| 70 | + | builder.WriteString(";--vev-bg:") | |
| 71 | + | writeRGB(&builder, theme.Background) | |
| 72 | + | builder.WriteString(";--vev-cursor:") | |
| 73 | + | writeRGB(&builder, theme.Cursor) | |
| 74 | + | builder.WriteString(";--vev-selection-bg:") | |
| 75 | + | writeRGB(&builder, theme.Selection) | |
| 76 | + | builder.WriteString(";--vev-selection-fg:") | |
| 77 | + | writeRGB(&builder, theme.SelectionText) | |
| 78 | + | for index, color := range theme.Palette { | |
| 79 | + | fmt.Fprintf(&builder, ";--vev-color-%d:", index) | |
| 80 | + | writeRGB(&builder, color) | |
| 81 | + | } | |
| 82 | + | builder.WriteByte(';') | |
| 83 | + | return builder.String() | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func writeRGB(builder *strings.Builder, color core.RGB) { | |
| 87 | + | fmt.Fprintf(builder, "#%02x%02x%02x", color.R, color.G, color.B) | |
| 88 | + | } | |
| @@ -0,0 +1,88 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev-vt/core" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Theme contains generic terminal colors. Consumers can override the same CSS | |||
| 12 | + | // custom properties directly when product-specific theme ownership is needed. | |||
| 13 | + | type Theme struct { | |||
| 14 | + | Foreground core.RGB | |||
| 15 | + | Background core.RGB | |||
| 16 | + | Cursor core.RGB | |||
| 17 | + | Selection core.RGB | |||
| 18 | + | SelectionText core.RGB | |||
| 19 | + | Palette [16]core.RGB | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // MarshalJSON emits the typed object accepted by the embedded browser | |||
| 23 | + | // runtime's setTheme method. | |||
| 24 | + | func (theme Theme) MarshalJSON() ([]byte, error) { | |||
| 25 | + | palette := [16]RGB{} | |||
| 26 | + | for index, color := range theme.Palette { | |||
| 27 | + | palette[index] = browserRGB(color) | |||
| 28 | + | } | |||
| 29 | + | return json.Marshal(struct { | |||
| 30 | + | Foreground RGB `json:"foreground"` | |||
| 31 | + | Background RGB `json:"background"` | |||
| 32 | + | Cursor RGB `json:"cursor"` | |||
| 33 | + | Selection RGB `json:"selection"` | |||
| 34 | + | SelectionText RGB `json:"selectionText"` | |||
| 35 | + | Palette [16]RGB `json:"palette"` | |||
| 36 | + | }{ | |||
| 37 | + | Foreground: browserRGB(theme.Foreground), Background: browserRGB(theme.Background), | |||
| 38 | + | Cursor: browserRGB(theme.Cursor), Selection: browserRGB(theme.Selection), | |||
| 39 | + | SelectionText: browserRGB(theme.SelectionText), Palette: palette, | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func browserRGB(color core.RGB) RGB { return RGB{R: color.R, G: color.G, B: color.B} } | |||
| 44 | + | ||||
| 45 | + | // DefaultTheme returns a neutral dark terminal theme. | |||
| 46 | + | func DefaultTheme() Theme { | |||
| 47 | + | return Theme{ | |||
| 48 | + | Foreground: rgb(0xd8, 0xde, 0xe9), | |||
| 49 | + | Background: rgb(0x10, 0x12, 0x18), | |||
| 50 | + | Cursor: rgb(0xff, 0x87, 0x00), | |||
| 51 | + | Selection: rgb(0x39, 0x41, 0x50), | |||
| 52 | + | SelectionText: rgb(0xff, 0xff, 0xff), | |||
| 53 | + | Palette: [16]core.RGB{ | |||
| 54 | + | rgb(0x00, 0x00, 0x00), rgb(0x80, 0x00, 0x00), rgb(0x00, 0x80, 0x00), rgb(0x80, 0x80, 0x00), | |||
| 55 | + | rgb(0x00, 0x00, 0x80), rgb(0x80, 0x00, 0x80), rgb(0x00, 0x80, 0x80), rgb(0xc0, 0xc0, 0xc0), | |||
| 56 | + | rgb(0x80, 0x80, 0x80), rgb(0xff, 0x00, 0x00), rgb(0x00, 0xff, 0x00), rgb(0xff, 0xff, 0x00), | |||
| 57 | + | rgb(0x00, 0x00, 0xff), rgb(0xff, 0x00, 0xff), rgb(0x00, 0xff, 0xff), rgb(0xff, 0xff, 0xff), | |||
| 58 | + | }, | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func rgb(r, g, b uint8) core.RGB { return core.RGB{R: r, G: g, B: b} } | |||
| 63 | + | ||||
| 64 | + | // CSS returns deterministic custom-property declarations containing only | |||
| 65 | + | // numeric RGB values. | |||
| 66 | + | func (theme Theme) CSS() string { | |||
| 67 | + | var builder strings.Builder | |||
| 68 | + | builder.WriteString("--vev-fg:") | |||
| 69 | + | writeRGB(&builder, theme.Foreground) | |||
| 70 | + | builder.WriteString(";--vev-bg:") | |||
| 71 | + | writeRGB(&builder, theme.Background) | |||
| 72 | + | builder.WriteString(";--vev-cursor:") | |||
| 73 | + | writeRGB(&builder, theme.Cursor) | |||
| 74 | + | builder.WriteString(";--vev-selection-bg:") | |||
| 75 | + | writeRGB(&builder, theme.Selection) | |||
| 76 | + | builder.WriteString(";--vev-selection-fg:") | |||
| 77 | + | writeRGB(&builder, theme.SelectionText) | |||
| 78 | + | for index, color := range theme.Palette { | |||
| 79 | + | fmt.Fprintf(&builder, ";--vev-color-%d:", index) | |||
| 80 | + | writeRGB(&builder, color) | |||
| 81 | + | } | |||
| 82 | + | builder.WriteByte(';') | |||
| 83 | + | return builder.String() | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func writeRGB(builder *strings.Builder, color core.RGB) { | |||
| 87 | + | fmt.Fprintf(builder, "#%02x%02x%02x", color.R, color.G, color.B) | |||
| 88 | + | } | |||
A html/theme_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev-vt/core" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestDefaultThemeMatchesStylesheetFallbacks(t *testing.T) { | |
| 13 | + | stylesheet := strings.NewReplacer(" ", "", "\n", "", "\t", "").Replace(Stylesheet()) | |
| 14 | + | for declaration := range strings.SplitSeq(DefaultTheme().CSS(), ";") { | |
| 15 | + | if declaration != "" { | |
| 16 | + | require.Contains(t, stylesheet, declaration) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestThemeCSSUsesOnlyTypedColorValues(t *testing.T) { | |
| 22 | + | theme := DefaultTheme() | |
| 23 | + | theme.Foreground = core.RGB{R: 1, G: 2, B: 3} | |
| 24 | + | css := theme.CSS() | |
| 25 | + | ||
| 26 | + | require.Contains(t, css, "--vev-fg:#010203") | |
| 27 | + | require.Contains(t, css, "--vev-color-15:") | |
| 28 | + | require.Equal(t, 21, strings.Count(css, "--vev-")) | |
| 29 | + | require.NotEmpty(t, Stylesheet()) | |
| 30 | + | ||
| 31 | + | encoded, err := json.Marshal(theme) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | require.Contains(t, string(encoded), `"foreground":{"r":1,"g":2,"b":3}`) | |
| 34 | + | require.NotContains(t, string(encoded), `"Foreground"`) | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev-vt/core" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestDefaultThemeMatchesStylesheetFallbacks(t *testing.T) { | |||
| 13 | + | stylesheet := strings.NewReplacer(" ", "", "\n", "", "\t", "").Replace(Stylesheet()) | |||
| 14 | + | for declaration := range strings.SplitSeq(DefaultTheme().CSS(), ";") { | |||
| 15 | + | if declaration != "" { | |||
| 16 | + | require.Contains(t, stylesheet, declaration) | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestThemeCSSUsesOnlyTypedColorValues(t *testing.T) { | |||
| 22 | + | theme := DefaultTheme() | |||
| 23 | + | theme.Foreground = core.RGB{R: 1, G: 2, B: 3} | |||
| 24 | + | css := theme.CSS() | |||
| 25 | + | ||||
| 26 | + | require.Contains(t, css, "--vev-fg:#010203") | |||
| 27 | + | require.Contains(t, css, "--vev-color-15:") | |||
| 28 | + | require.Equal(t, 21, strings.Count(css, "--vev-")) | |||
| 29 | + | require.NotEmpty(t, Stylesheet()) | |||
| 30 | + | ||||
| 31 | + | encoded, err := json.Marshal(theme) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | require.Contains(t, string(encoded), `"foreground":{"r":1,"g":2,"b":3}`) | |||
| 34 | + | require.NotContains(t, string(encoded), `"Foreground"`) | |||
| 35 | + | } | |||
A html/update.go +120 -0
| @@ -0,0 +1,120 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev-vt/core" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // UpdateSchemaVersion identifies the browser update contract. | |
| 11 | + | const UpdateSchemaVersion uint16 = 1 | |
| 12 | + | ||
| 13 | + | // CursorStyle is a DECSCUSR cursor shape value from 0 through 6. | |
| 14 | + | type CursorStyle uint8 | |
| 15 | + | ||
| 16 | + | // Cursor is the absolute cursor state associated with an update. StyleSet | |
| 17 | + | // distinguishes an unset style from an explicit DECSCUSR style zero. | |
| 18 | + | type Cursor struct { | |
| 19 | + | Row int `json:"row"` | |
| 20 | + | Column int `json:"column"` | |
| 21 | + | Visible bool `json:"visible"` | |
| 22 | + | Style CursorStyle `json:"style"` | |
| 23 | + | StyleSet bool `json:"styleSet"` | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // ColorKind identifies how a terminal color is resolved. | |
| 27 | + | type ColorKind uint8 | |
| 28 | + | ||
| 29 | + | const ( | |
| 30 | + | ColorDefault ColorKind = iota | |
| 31 | + | ColorIndexed | |
| 32 | + | ColorRGB | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | // RGB is a browser-facing RGB color. | |
| 36 | + | type RGB struct { | |
| 37 | + | R uint8 `json:"r"` | |
| 38 | + | G uint8 `json:"g"` | |
| 39 | + | B uint8 `json:"b"` | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Color is a validated terminal color token. | |
| 43 | + | type Color struct { | |
| 44 | + | Kind ColorKind | |
| 45 | + | Index uint8 | |
| 46 | + | RGB RGB | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (color Color) MarshalJSON() ([]byte, error) { | |
| 50 | + | switch color.Kind { | |
| 51 | + | case ColorDefault: | |
| 52 | + | return []byte(`{"kind":0}`), nil | |
| 53 | + | case ColorIndexed: | |
| 54 | + | return json.Marshal(struct { | |
| 55 | + | Kind ColorKind `json:"kind"` | |
| 56 | + | Index uint8 `json:"index"` | |
| 57 | + | }{Kind: color.Kind, Index: color.Index}) | |
| 58 | + | case ColorRGB: | |
| 59 | + | return json.Marshal(struct { | |
| 60 | + | Kind ColorKind `json:"kind"` | |
| 61 | + | RGB RGB `json:"rgb"` | |
| 62 | + | }{Kind: color.Kind, RGB: color.RGB}) | |
| 63 | + | default: | |
| 64 | + | return nil, fmt.Errorf("html: invalid color kind %d", color.Kind) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Style is the canonical, browser-facing subset of core.Style. | |
| 69 | + | type Style struct { | |
| 70 | + | Bold bool `json:"bold,omitempty"` | |
| 71 | + | Italic bool `json:"italic,omitempty"` | |
| 72 | + | Inverse bool `json:"inverse,omitempty"` | |
| 73 | + | Dim bool `json:"dim,omitempty"` | |
| 74 | + | Blink bool `json:"blink,omitempty"` | |
| 75 | + | Strikethrough bool `json:"strikethrough,omitempty"` | |
| 76 | + | Underline bool `json:"underline,omitempty"` | |
| 77 | + | UnderlineStyle core.UnderlineStyle `json:"underlineStyle,omitempty"` | |
| 78 | + | Foreground Color `json:"foreground"` | |
| 79 | + | Background Color `json:"background"` | |
| 80 | + | UnderlineColor Color `json:"underlineColor"` | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // CellUpdate occupies Width terminal columns beginning at Column. Wide-cell | |
| 84 | + | // continuation markers are represented by the preceding cell's width. | |
| 85 | + | type CellUpdate struct { | |
| 86 | + | Column int `json:"column"` | |
| 87 | + | Width int `json:"width"` | |
| 88 | + | Text string `json:"text"` | |
| 89 | + | Style int `json:"style"` | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // RowUpdate replaces one complete logical terminal row. | |
| 93 | + | type RowUpdate struct { | |
| 94 | + | Row int `json:"row"` | |
| 95 | + | Cells []CellUpdate `json:"cells"` | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // Update is an immutable-by-contract browser update. Snapshot updates contain | |
| 99 | + | // every logical row; incremental updates contain complete changed rows. | |
| 100 | + | type Update struct { | |
| 101 | + | SchemaVersion uint16 `json:"schemaVersion"` | |
| 102 | + | Width int `json:"width"` | |
| 103 | + | Height int `json:"height"` | |
| 104 | + | Snapshot bool `json:"snapshot"` | |
| 105 | + | Rows []RowUpdate `json:"rows"` | |
| 106 | + | Styles []Style `json:"styles"` | |
| 107 | + | Cursor Cursor `json:"cursor"` | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func cloneUpdate(src Update) Update { | |
| 111 | + | dst := src | |
| 112 | + | dst.Styles = make([]Style, len(src.Styles)) | |
| 113 | + | copy(dst.Styles, src.Styles) | |
| 114 | + | dst.Rows = make([]RowUpdate, len(src.Rows)) | |
| 115 | + | for i := range src.Rows { | |
| 116 | + | dst.Rows[i] = src.Rows[i] | |
| 117 | + | dst.Rows[i].Cells = append([]CellUpdate(nil), src.Rows[i].Cells...) | |
| 118 | + | } | |
| 119 | + | return dst | |
| 120 | + | } | |
| @@ -0,0 +1,120 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev-vt/core" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // UpdateSchemaVersion identifies the browser update contract. | |||
| 11 | + | const UpdateSchemaVersion uint16 = 1 | |||
| 12 | + | ||||
| 13 | + | // CursorStyle is a DECSCUSR cursor shape value from 0 through 6. | |||
| 14 | + | type CursorStyle uint8 | |||
| 15 | + | ||||
| 16 | + | // Cursor is the absolute cursor state associated with an update. StyleSet | |||
| 17 | + | // distinguishes an unset style from an explicit DECSCUSR style zero. | |||
| 18 | + | type Cursor struct { | |||
| 19 | + | Row int `json:"row"` | |||
| 20 | + | Column int `json:"column"` | |||
| 21 | + | Visible bool `json:"visible"` | |||
| 22 | + | Style CursorStyle `json:"style"` | |||
| 23 | + | StyleSet bool `json:"styleSet"` | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // ColorKind identifies how a terminal color is resolved. | |||
| 27 | + | type ColorKind uint8 | |||
| 28 | + | ||||
| 29 | + | const ( | |||
| 30 | + | ColorDefault ColorKind = iota | |||
| 31 | + | ColorIndexed | |||
| 32 | + | ColorRGB | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | // RGB is a browser-facing RGB color. | |||
| 36 | + | type RGB struct { | |||
| 37 | + | R uint8 `json:"r"` | |||
| 38 | + | G uint8 `json:"g"` | |||
| 39 | + | B uint8 `json:"b"` | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Color is a validated terminal color token. | |||
| 43 | + | type Color struct { | |||
| 44 | + | Kind ColorKind | |||
| 45 | + | Index uint8 | |||
| 46 | + | RGB RGB | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (color Color) MarshalJSON() ([]byte, error) { | |||
| 50 | + | switch color.Kind { | |||
| 51 | + | case ColorDefault: | |||
| 52 | + | return []byte(`{"kind":0}`), nil | |||
| 53 | + | case ColorIndexed: | |||
| 54 | + | return json.Marshal(struct { | |||
| 55 | + | Kind ColorKind `json:"kind"` | |||
| 56 | + | Index uint8 `json:"index"` | |||
| 57 | + | }{Kind: color.Kind, Index: color.Index}) | |||
| 58 | + | case ColorRGB: | |||
| 59 | + | return json.Marshal(struct { | |||
| 60 | + | Kind ColorKind `json:"kind"` | |||
| 61 | + | RGB RGB `json:"rgb"` | |||
| 62 | + | }{Kind: color.Kind, RGB: color.RGB}) | |||
| 63 | + | default: | |||
| 64 | + | return nil, fmt.Errorf("html: invalid color kind %d", color.Kind) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Style is the canonical, browser-facing subset of core.Style. | |||
| 69 | + | type Style struct { | |||
| 70 | + | Bold bool `json:"bold,omitempty"` | |||
| 71 | + | Italic bool `json:"italic,omitempty"` | |||
| 72 | + | Inverse bool `json:"inverse,omitempty"` | |||
| 73 | + | Dim bool `json:"dim,omitempty"` | |||
| 74 | + | Blink bool `json:"blink,omitempty"` | |||
| 75 | + | Strikethrough bool `json:"strikethrough,omitempty"` | |||
| 76 | + | Underline bool `json:"underline,omitempty"` | |||
| 77 | + | UnderlineStyle core.UnderlineStyle `json:"underlineStyle,omitempty"` | |||
| 78 | + | Foreground Color `json:"foreground"` | |||
| 79 | + | Background Color `json:"background"` | |||
| 80 | + | UnderlineColor Color `json:"underlineColor"` | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // CellUpdate occupies Width terminal columns beginning at Column. Wide-cell | |||
| 84 | + | // continuation markers are represented by the preceding cell's width. | |||
| 85 | + | type CellUpdate struct { | |||
| 86 | + | Column int `json:"column"` | |||
| 87 | + | Width int `json:"width"` | |||
| 88 | + | Text string `json:"text"` | |||
| 89 | + | Style int `json:"style"` | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // RowUpdate replaces one complete logical terminal row. | |||
| 93 | + | type RowUpdate struct { | |||
| 94 | + | Row int `json:"row"` | |||
| 95 | + | Cells []CellUpdate `json:"cells"` | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // Update is an immutable-by-contract browser update. Snapshot updates contain | |||
| 99 | + | // every logical row; incremental updates contain complete changed rows. | |||
| 100 | + | type Update struct { | |||
| 101 | + | SchemaVersion uint16 `json:"schemaVersion"` | |||
| 102 | + | Width int `json:"width"` | |||
| 103 | + | Height int `json:"height"` | |||
| 104 | + | Snapshot bool `json:"snapshot"` | |||
| 105 | + | Rows []RowUpdate `json:"rows"` | |||
| 106 | + | Styles []Style `json:"styles"` | |||
| 107 | + | Cursor Cursor `json:"cursor"` | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func cloneUpdate(src Update) Update { | |||
| 111 | + | dst := src | |||
| 112 | + | dst.Styles = make([]Style, len(src.Styles)) | |||
| 113 | + | copy(dst.Styles, src.Styles) | |||
| 114 | + | dst.Rows = make([]RowUpdate, len(src.Rows)) | |||
| 115 | + | for i := range src.Rows { | |||
| 116 | + | dst.Rows[i] = src.Rows[i] | |||
| 117 | + | dst.Rows[i].Cells = append([]CellUpdate(nil), src.Rows[i].Cells...) | |||
| 118 | + | } | |||
| 119 | + | return dst | |||
| 120 | + | } | |||
A html/validation_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev-vt/core" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestRendererRejectsMalformedOrOverLimitFramesWithoutPendingState(t *testing.T) { | |
| 11 | + | renderer, err := New(Options{Limits: Limits{MaxCells: 2}}) | |
| 12 | + | require.NoError(t, err) | |
| 13 | + | ||
| 14 | + | tooLarge := core.NewFrame(3, 1) | |
| 15 | + | _, err = renderer.Prepare(tooLarge, nil, false, Cursor{}) | |
| 16 | + | require.ErrorIs(t, err, ErrLimitExceeded) | |
| 17 | + | ||
| 18 | + | orphan := core.NewFrame(2, 1) | |
| 19 | + | orphan.Set(0, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |
| 20 | + | _, err = renderer.Prepare(orphan, nil, false, Cursor{}) | |
| 21 | + | require.ErrorContains(t, err, "orphan wide continuation") | |
| 22 | + | ||
| 23 | + | combining := core.NewFrame(1, 1) | |
| 24 | + | combining.Set(0, 0, core.Cell{Rune: '\u0301', Style: core.DefaultStyle()}) | |
| 25 | + | _, err = renderer.Prepare(combining, nil, false, Cursor{}) | |
| 26 | + | require.ErrorContains(t, err, "unsupported zero-width rune") | |
| 27 | + | ||
| 28 | + | wide := core.NewFrame(2, 1) | |
| 29 | + | wide.Set(0, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) | |
| 30 | + | wide.Set(1, 0, core.Cell{Rune: 'X', Continuation: true, Style: core.DefaultStyle()}) | |
| 31 | + | _, err = renderer.Prepare(wide, nil, false, Cursor{}) | |
| 32 | + | require.ErrorContains(t, err, "wide continuation contains a rune") | |
| 33 | + | ||
| 34 | + | alternate := core.DefaultStyle() | |
| 35 | + | alternate.Bold = true | |
| 36 | + | wide.Set(1, 0, core.Cell{Continuation: true, Style: alternate}) | |
| 37 | + | _, err = renderer.Prepare(wide, nil, false, Cursor{}) | |
| 38 | + | require.ErrorContains(t, err, "style differs from its head") | |
| 39 | + | ||
| 40 | + | valid := core.NewFrame(2, 1) | |
| 41 | + | prepared, err := renderer.Prepare(valid, nil, false, Cursor{}) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | require.NoError(t, prepared.Abort()) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestNewRejectsNegativeLimits(t *testing.T) { | |
| 47 | + | _, err := New(Options{Limits: Limits{MaxCells: -1}}) | |
| 48 | + | require.ErrorIs(t, err, ErrInvalidLimits) | |
| 49 | + | require.ErrorContains(t, err, "must not be negative") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestRendererEnforcesEncodedByteAndStyleLimits(t *testing.T) { | |
| 53 | + | frame := core.NewFrame(2, 1) | |
| 54 | + | alternate := core.DefaultStyle() | |
| 55 | + | alternate.Bold = true | |
| 56 | + | frame.Set(1, 0, core.Cell{Rune: 'X', Style: alternate}) | |
| 57 | + | ||
| 58 | + | styleLimited, err := New(Options{Limits: Limits{MaxStyles: 1}}) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | _, err = styleLimited.Prepare(frame, nil, false, Cursor{}) | |
| 61 | + | require.ErrorIs(t, err, ErrLimitExceeded) | |
| 62 | + | ||
| 63 | + | byteLimited, err := New(Options{Limits: Limits{MaxGeneratedBytes: 1}}) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | _, err = byteLimited.Prepare(core.NewFrame(1, 1), nil, false, Cursor{}) | |
| 66 | + | require.ErrorIs(t, err, ErrLimitExceeded) | |
| 67 | + | require.ErrorContains(t, err, "generated update is") | |
| 68 | + | _, err = byteLimited.Prepare(core.NewFrame(1, 1), nil, false, Cursor{}) | |
| 69 | + | require.ErrorIs(t, err, ErrLimitExceeded) | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev-vt/core" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestRendererRejectsMalformedOrOverLimitFramesWithoutPendingState(t *testing.T) { | |||
| 11 | + | renderer, err := New(Options{Limits: Limits{MaxCells: 2}}) | |||
| 12 | + | require.NoError(t, err) | |||
| 13 | + | ||||
| 14 | + | tooLarge := core.NewFrame(3, 1) | |||
| 15 | + | _, err = renderer.Prepare(tooLarge, nil, false, Cursor{}) | |||
| 16 | + | require.ErrorIs(t, err, ErrLimitExceeded) | |||
| 17 | + | ||||
| 18 | + | orphan := core.NewFrame(2, 1) | |||
| 19 | + | orphan.Set(0, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |||
| 20 | + | _, err = renderer.Prepare(orphan, nil, false, Cursor{}) | |||
| 21 | + | require.ErrorContains(t, err, "orphan wide continuation") | |||
| 22 | + | ||||
| 23 | + | combining := core.NewFrame(1, 1) | |||
| 24 | + | combining.Set(0, 0, core.Cell{Rune: '\u0301', Style: core.DefaultStyle()}) | |||
| 25 | + | _, err = renderer.Prepare(combining, nil, false, Cursor{}) | |||
| 26 | + | require.ErrorContains(t, err, "unsupported zero-width rune") | |||
| 27 | + | ||||
| 28 | + | wide := core.NewFrame(2, 1) | |||
| 29 | + | wide.Set(0, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) | |||
| 30 | + | wide.Set(1, 0, core.Cell{Rune: 'X', Continuation: true, Style: core.DefaultStyle()}) | |||
| 31 | + | _, err = renderer.Prepare(wide, nil, false, Cursor{}) | |||
| 32 | + | require.ErrorContains(t, err, "wide continuation contains a rune") | |||
| 33 | + | ||||
| 34 | + | alternate := core.DefaultStyle() | |||
| 35 | + | alternate.Bold = true | |||
| 36 | + | wide.Set(1, 0, core.Cell{Continuation: true, Style: alternate}) | |||
| 37 | + | _, err = renderer.Prepare(wide, nil, false, Cursor{}) | |||
| 38 | + | require.ErrorContains(t, err, "style differs from its head") | |||
| 39 | + | ||||
| 40 | + | valid := core.NewFrame(2, 1) | |||
| 41 | + | prepared, err := renderer.Prepare(valid, nil, false, Cursor{}) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | require.NoError(t, prepared.Abort()) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestNewRejectsNegativeLimits(t *testing.T) { | |||
| 47 | + | _, err := New(Options{Limits: Limits{MaxCells: -1}}) | |||
| 48 | + | require.ErrorIs(t, err, ErrInvalidLimits) | |||
| 49 | + | require.ErrorContains(t, err, "must not be negative") | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestRendererEnforcesEncodedByteAndStyleLimits(t *testing.T) { | |||
| 53 | + | frame := core.NewFrame(2, 1) | |||
| 54 | + | alternate := core.DefaultStyle() | |||
| 55 | + | alternate.Bold = true | |||
| 56 | + | frame.Set(1, 0, core.Cell{Rune: 'X', Style: alternate}) | |||
| 57 | + | ||||
| 58 | + | styleLimited, err := New(Options{Limits: Limits{MaxStyles: 1}}) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | _, err = styleLimited.Prepare(frame, nil, false, Cursor{}) | |||
| 61 | + | require.ErrorIs(t, err, ErrLimitExceeded) | |||
| 62 | + | ||||
| 63 | + | byteLimited, err := New(Options{Limits: Limits{MaxGeneratedBytes: 1}}) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | _, err = byteLimited.Prepare(core.NewFrame(1, 1), nil, false, Cursor{}) | |||
| 66 | + | require.ErrorIs(t, err, ErrLimitExceeded) | |||
| 67 | + | require.ErrorContains(t, err, "generated update is") | |||
| 68 | + | _, err = byteLimited.Prepare(core.NewFrame(1, 1), nil, false, Cursor{}) | |||
| 69 | + | require.ErrorIs(t, err, ErrLimitExceeded) | |||
| 70 | + | } | |||
A internal/htmlharness/README.md +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | # HTML browser harness | |
| 2 | + | ||
| 3 | + | This development-only Playwright harness verifies the embedded HTML frontend. It | |
| 4 | + | loads `html/terminal.css` and `html/browser/terminal.js` directly; no production | |
| 5 | + | server or transport is involved. | |
| 6 | + | ||
| 7 | + | ## Locked environment | |
| 8 | + | ||
| 9 | + | `package-lock.json` pins Playwright 1.62.1 and these browser builds: | |
| 10 | + | ||
| 11 | + | | Engine | Browser version | Playwright revision | | |
| 12 | + | |---|---:|---:| | |
| 13 | + | | Chromium | 151.0.7922.34 | 1234 | | |
| 14 | + | | Firefox | 153.0 | 1538 | | |
| 15 | + | | WebKit | 26.5 | 2336 | | |
| 16 | + | ||
| 17 | + | Run locally when the host supports all browser builds: | |
| 18 | + | ||
| 19 | + | ```sh | |
| 20 | + | npm ci | |
| 21 | + | npm run test:browser:install | |
| 22 | + | npm run test:browser | |
| 23 | + | ``` | |
| 24 | + | ||
| 25 | + | Use the matching official container on unsupported Linux distributions: | |
| 26 | + | ||
| 27 | + | ```sh | |
| 28 | + | npm run test:browser:docker | |
| 29 | + | ``` | |
| 30 | + | ||
| 31 | + | CI installs the same locked browsers and runs the full matrix on Ubuntu. | |
| 32 | + | ||
| 33 | + | ## Evidence | |
| 34 | + | ||
| 35 | + | The three-engine container matrix passes typed snapshot and row application, | |
| 36 | + | hostile-text safety, complete validation before mutation, IME composition, | |
| 37 | + | non-text keys, synchronous default-prevention decisions, pointer, wheel, resize, | |
| 38 | + | focus, typed themes, lifecycle cleanup, and a self-only `script-src`/`style-src` | |
| 39 | + | CSP. The CSP fixture uses external static assets and reports no policy violation. | |
| 40 | + | ||
| 41 | + | The Chromium reference performance gate applies ten sustained 120×40 snapshots, | |
| 42 | + | fifty complete-row updates, and one 240×80 snapshot. The recorded implementation | |
| 43 | + | run produced: | |
| 44 | + | ||
| 45 | + | | Workload | Recorded | Gate | | |
| 46 | + | |---|---:|---:| | |
| 47 | + | | 120×40 snapshot p95 | 28.2 ms | < 100 ms | | |
| 48 | + | | 120-column row replacement p95 | 0.5 ms | < 33 ms | | |
| 49 | + | | 240×80 snapshot | 68.3 ms | < 500 ms | | |
| 50 | + | | Final visible cells/rows | 19,200 / 80 | exactly bounded by the frame | | |
| 51 | + | ||
| 52 | + | Performance numbers are reference evidence, not universal device guarantees. | |
| 53 | + | The functional matrix and serial Chromium performance run write separate JSON | |
| 54 | + | reports under `test-results/`. They are local/CI artifacts rather than committed | |
| 55 | + | files. | |
| 56 | + | ||
| 57 | + | ## Accessibility scope | |
| 58 | + | ||
| 59 | + | Automated checks cover a named group, a labeled keyboard-focusable text input, | |
| 60 | + | visible focus styling, synchronized plain-text output, and reduced-motion CSS. | |
| 61 | + | The visual grid is `aria-hidden` to avoid exposing thousands of fragmented cell | |
| 62 | + | nodes; assistive technology receives the bounded plain-text representation. | |
| 63 | + | `role="application"` is not used, browser zoom remains available, and browser or | |
| 64 | + | system shortcuts are not prevented unless the synchronous consumer decision | |
| 65 | + | requests it. | |
| 66 | + | ||
| 67 | + | No claim of full screen-reader equivalence is made. A product consumer should | |
| 68 | + | run its own assistive-technology acceptance pass with the surrounding labels, | |
| 69 | + | shortcut policy, announcements, and session behavior before presenting the | |
| 70 | + | frontend as a complete terminal replacement. | |
| 71 | + | ||
| 72 | + | ## Inherited output limits | |
| 73 | + | ||
| 74 | + | The current core model drops combining marks and does not coalesce ZWJ | |
| 75 | + | sequences. The harness verifies composed browser input but does not claim that | |
| 76 | + | the renderer can recover combining or ZWJ output absent from `core.Frame`. | |
| @@ -0,0 +1,76 @@ | |||||
| 1 | + | # HTML browser harness | |||
| 2 | + | ||||
| 3 | + | This development-only Playwright harness verifies the embedded HTML frontend. It | |||
| 4 | + | loads `html/terminal.css` and `html/browser/terminal.js` directly; no production | |||
| 5 | + | server or transport is involved. | |||
| 6 | + | ||||
| 7 | + | ## Locked environment | |||
| 8 | + | ||||
| 9 | + | `package-lock.json` pins Playwright 1.62.1 and these browser builds: | |||
| 10 | + | ||||
| 11 | + | | Engine | Browser version | Playwright revision | | |||
| 12 | + | |---|---:|---:| | |||
| 13 | + | | Chromium | 151.0.7922.34 | 1234 | | |||
| 14 | + | | Firefox | 153.0 | 1538 | | |||
| 15 | + | | WebKit | 26.5 | 2336 | | |||
| 16 | + | ||||
| 17 | + | Run locally when the host supports all browser builds: | |||
| 18 | + | ||||
| 19 | + | ```sh | |||
| 20 | + | npm ci | |||
| 21 | + | npm run test:browser:install | |||
| 22 | + | npm run test:browser | |||
| 23 | + | ``` | |||
| 24 | + | ||||
| 25 | + | Use the matching official container on unsupported Linux distributions: | |||
| 26 | + | ||||
| 27 | + | ```sh | |||
| 28 | + | npm run test:browser:docker | |||
| 29 | + | ``` | |||
| 30 | + | ||||
| 31 | + | CI installs the same locked browsers and runs the full matrix on Ubuntu. | |||
| 32 | + | ||||
| 33 | + | ## Evidence | |||
| 34 | + | ||||
| 35 | + | The three-engine container matrix passes typed snapshot and row application, | |||
| 36 | + | hostile-text safety, complete validation before mutation, IME composition, | |||
| 37 | + | non-text keys, synchronous default-prevention decisions, pointer, wheel, resize, | |||
| 38 | + | focus, typed themes, lifecycle cleanup, and a self-only `script-src`/`style-src` | |||
| 39 | + | CSP. The CSP fixture uses external static assets and reports no policy violation. | |||
| 40 | + | ||||
| 41 | + | The Chromium reference performance gate applies ten sustained 120×40 snapshots, | |||
| 42 | + | fifty complete-row updates, and one 240×80 snapshot. The recorded implementation | |||
| 43 | + | run produced: | |||
| 44 | + | ||||
| 45 | + | | Workload | Recorded | Gate | | |||
| 46 | + | |---|---:|---:| | |||
| 47 | + | | 120×40 snapshot p95 | 28.2 ms | < 100 ms | | |||
| 48 | + | | 120-column row replacement p95 | 0.5 ms | < 33 ms | | |||
| 49 | + | | 240×80 snapshot | 68.3 ms | < 500 ms | | |||
| 50 | + | | Final visible cells/rows | 19,200 / 80 | exactly bounded by the frame | | |||
| 51 | + | ||||
| 52 | + | Performance numbers are reference evidence, not universal device guarantees. | |||
| 53 | + | The functional matrix and serial Chromium performance run write separate JSON | |||
| 54 | + | reports under `test-results/`. They are local/CI artifacts rather than committed | |||
| 55 | + | files. | |||
| 56 | + | ||||
| 57 | + | ## Accessibility scope | |||
| 58 | + | ||||
| 59 | + | Automated checks cover a named group, a labeled keyboard-focusable text input, | |||
| 60 | + | visible focus styling, synchronized plain-text output, and reduced-motion CSS. | |||
| 61 | + | The visual grid is `aria-hidden` to avoid exposing thousands of fragmented cell | |||
| 62 | + | nodes; assistive technology receives the bounded plain-text representation. | |||
| 63 | + | `role="application"` is not used, browser zoom remains available, and browser or | |||
| 64 | + | system shortcuts are not prevented unless the synchronous consumer decision | |||
| 65 | + | requests it. | |||
| 66 | + | ||||
| 67 | + | No claim of full screen-reader equivalence is made. A product consumer should | |||
| 68 | + | run its own assistive-technology acceptance pass with the surrounding labels, | |||
| 69 | + | shortcut policy, announcements, and session behavior before presenting the | |||
| 70 | + | frontend as a complete terminal replacement. | |||
| 71 | + | ||||
| 72 | + | ## Inherited output limits | |||
| 73 | + | ||||
| 74 | + | The current core model drops combining marks and does not coalesce ZWJ | |||
| 75 | + | sequences. The harness verifies composed browser input but does not claim that | |||
| 76 | + | the renderer can recover combining or ZWJ output absent from `core.Frame`. | |||
A internal/htmlharness/performance.spec.js +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | import { test, expect } from '@playwright/test'; | |
| 2 | + | import path from 'node:path'; | |
| 3 | + | ||
| 4 | + | const root = process.cwd(); | |
| 5 | + | ||
| 6 | + | /** @param {number} width @param {number} height @param {string} text */ | |
| 7 | + | function snapshot(width, height, text) { | |
| 8 | + | const cells = Array.from({ length: width }, (_, column) => ({ column, width: 1, text, style: 0 })); | |
| 9 | + | return { | |
| 10 | + | schemaVersion: 1, | |
| 11 | + | width, | |
| 12 | + | height, | |
| 13 | + | snapshot: true, | |
| 14 | + | styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }], | |
| 15 | + | rows: Array.from({ length: height }, (_, row) => ({ row, cells })), | |
| 16 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |
| 17 | + | }; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | test('keeps sustained snapshot and row replacement work bounded', async ({ page, browserName }, testInfo) => { | |
| 21 | + | test.skip(browserName !== 'chromium', 'reference performance budget uses pinned Chromium'); | |
| 22 | + | await page.setContent('<div id="terminal"></div>'); | |
| 23 | + | await page.addStyleTag({ path: path.join(root, 'html/terminal.css') }); | |
| 24 | + | await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') }); | |
| 25 | + | ||
| 26 | + | const metrics = await page.evaluate(async ({ first, second, large }) => { | |
| 27 | + | const terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'Performance terminal' }); | |
| 28 | + | const settle = async () => { | |
| 29 | + | document.querySelector('.vev-terminal__viewport').getBoundingClientRect(); | |
| 30 | + | await new Promise(requestAnimationFrame); | |
| 31 | + | }; | |
| 32 | + | terminal.apply(first); | |
| 33 | + | await settle(); | |
| 34 | + | const snapshots = []; | |
| 35 | + | for (let index = 0; index < 10; index += 1) { | |
| 36 | + | const start = performance.now(); | |
| 37 | + | terminal.apply(index % 2 === 0 ? second : first); | |
| 38 | + | await settle(); | |
| 39 | + | snapshots.push(performance.now() - start); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | const rows = []; | |
| 43 | + | for (let index = 0; index < 50; index += 1) { | |
| 44 | + | // Alternate both rows so every timed apply performs a real replacement | |
| 45 | + | // instead of re-applying the same row through the no-op reuse path. | |
| 46 | + | const rowUpdate = { | |
| 47 | + | ...(index % 2 === 0 ? first : second), | |
| 48 | + | snapshot: false, | |
| 49 | + | rows: [(index % 2 === 0 ? second : first).rows[20]] | |
| 50 | + | }; | |
| 51 | + | const start = performance.now(); | |
| 52 | + | terminal.apply(rowUpdate); | |
| 53 | + | await settle(); | |
| 54 | + | rows.push(performance.now() - start); | |
| 55 | + | } | |
| 56 | + | const percentile = (values, ratio) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * ratio) - 1]; | |
| 57 | + | const largeStart = performance.now(); | |
| 58 | + | terminal.apply(large); | |
| 59 | + | await settle(); | |
| 60 | + | const largeApply = performance.now() - largeStart; | |
| 61 | + | return { | |
| 62 | + | snapshotP95: percentile(snapshots, 0.95), | |
| 63 | + | rowP95: percentile(rows, 0.95), | |
| 64 | + | largeApply, | |
| 65 | + | cells: document.querySelectorAll('.vev-terminal__cell').length, | |
| 66 | + | rows: document.querySelectorAll('.vev-terminal__row').length | |
| 67 | + | }; | |
| 68 | + | }, { first: snapshot(120, 40, 'A'), second: snapshot(120, 40, 'B'), large: snapshot(240, 80, 'C') }); | |
| 69 | + | ||
| 70 | + | testInfo.annotations.push({ type: 'performance', description: JSON.stringify(metrics) }); | |
| 71 | + | expect(metrics.snapshotP95).toBeLessThan(100); | |
| 72 | + | expect(metrics.rowP95).toBeLessThan(33); | |
| 73 | + | expect(metrics.largeApply).toBeLessThan(500); | |
| 74 | + | expect(metrics.cells).toBe(240 * 80); | |
| 75 | + | expect(metrics.rows).toBe(80); | |
| 76 | + | }); | |
| @@ -0,0 +1,76 @@ | |||||
| 1 | + | import { test, expect } from '@playwright/test'; | |||
| 2 | + | import path from 'node:path'; | |||
| 3 | + | ||||
| 4 | + | const root = process.cwd(); | |||
| 5 | + | ||||
| 6 | + | /** @param {number} width @param {number} height @param {string} text */ | |||
| 7 | + | function snapshot(width, height, text) { | |||
| 8 | + | const cells = Array.from({ length: width }, (_, column) => ({ column, width: 1, text, style: 0 })); | |||
| 9 | + | return { | |||
| 10 | + | schemaVersion: 1, | |||
| 11 | + | width, | |||
| 12 | + | height, | |||
| 13 | + | snapshot: true, | |||
| 14 | + | styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }], | |||
| 15 | + | rows: Array.from({ length: height }, (_, row) => ({ row, cells })), | |||
| 16 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |||
| 17 | + | }; | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | test('keeps sustained snapshot and row replacement work bounded', async ({ page, browserName }, testInfo) => { | |||
| 21 | + | test.skip(browserName !== 'chromium', 'reference performance budget uses pinned Chromium'); | |||
| 22 | + | await page.setContent('<div id="terminal"></div>'); | |||
| 23 | + | await page.addStyleTag({ path: path.join(root, 'html/terminal.css') }); | |||
| 24 | + | await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') }); | |||
| 25 | + | ||||
| 26 | + | const metrics = await page.evaluate(async ({ first, second, large }) => { | |||
| 27 | + | const terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'Performance terminal' }); | |||
| 28 | + | const settle = async () => { | |||
| 29 | + | document.querySelector('.vev-terminal__viewport').getBoundingClientRect(); | |||
| 30 | + | await new Promise(requestAnimationFrame); | |||
| 31 | + | }; | |||
| 32 | + | terminal.apply(first); | |||
| 33 | + | await settle(); | |||
| 34 | + | const snapshots = []; | |||
| 35 | + | for (let index = 0; index < 10; index += 1) { | |||
| 36 | + | const start = performance.now(); | |||
| 37 | + | terminal.apply(index % 2 === 0 ? second : first); | |||
| 38 | + | await settle(); | |||
| 39 | + | snapshots.push(performance.now() - start); | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | const rows = []; | |||
| 43 | + | for (let index = 0; index < 50; index += 1) { | |||
| 44 | + | // Alternate both rows so every timed apply performs a real replacement | |||
| 45 | + | // instead of re-applying the same row through the no-op reuse path. | |||
| 46 | + | const rowUpdate = { | |||
| 47 | + | ...(index % 2 === 0 ? first : second), | |||
| 48 | + | snapshot: false, | |||
| 49 | + | rows: [(index % 2 === 0 ? second : first).rows[20]] | |||
| 50 | + | }; | |||
| 51 | + | const start = performance.now(); | |||
| 52 | + | terminal.apply(rowUpdate); | |||
| 53 | + | await settle(); | |||
| 54 | + | rows.push(performance.now() - start); | |||
| 55 | + | } | |||
| 56 | + | const percentile = (values, ratio) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * ratio) - 1]; | |||
| 57 | + | const largeStart = performance.now(); | |||
| 58 | + | terminal.apply(large); | |||
| 59 | + | await settle(); | |||
| 60 | + | const largeApply = performance.now() - largeStart; | |||
| 61 | + | return { | |||
| 62 | + | snapshotP95: percentile(snapshots, 0.95), | |||
| 63 | + | rowP95: percentile(rows, 0.95), | |||
| 64 | + | largeApply, | |||
| 65 | + | cells: document.querySelectorAll('.vev-terminal__cell').length, | |||
| 66 | + | rows: document.querySelectorAll('.vev-terminal__row').length | |||
| 67 | + | }; | |||
| 68 | + | }, { first: snapshot(120, 40, 'A'), second: snapshot(120, 40, 'B'), large: snapshot(240, 80, 'C') }); | |||
| 69 | + | ||||
| 70 | + | testInfo.annotations.push({ type: 'performance', description: JSON.stringify(metrics) }); | |||
| 71 | + | expect(metrics.snapshotP95).toBeLessThan(100); | |||
| 72 | + | expect(metrics.rowP95).toBeLessThan(33); | |||
| 73 | + | expect(metrics.largeApply).toBeLessThan(500); | |||
| 74 | + | expect(metrics.cells).toBe(240 * 80); | |||
| 75 | + | expect(metrics.rows).toBe(80); | |||
| 76 | + | }); | |||
A internal/htmlharness/terminal.spec.js +452 -0
| @@ -0,0 +1,452 @@ | |||
| 1 | + | import { test, expect } from '@playwright/test'; | |
| 2 | + | import fs from 'node:fs/promises'; | |
| 3 | + | import path from 'node:path'; | |
| 4 | + | ||
| 5 | + | const root = process.cwd(); | |
| 6 | + | ||
| 7 | + | async function mount(page) { | |
| 8 | + | await page.setContent('<main><div id="terminal"></div></main>'); | |
| 9 | + | await page.addStyleTag({ path: path.join(root, 'html/terminal.css') }); | |
| 10 | + | await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') }); | |
| 11 | + | await page.evaluate(() => { | |
| 12 | + | globalThis.events = []; | |
| 13 | + | globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { | |
| 14 | + | label: 'Development terminal', | |
| 15 | + | decide(event) { | |
| 16 | + | const preventDefault = (event.type === 'key' && event.key === 'ArrowUp') || | |
| 17 | + | (event.type === 'pointer' && event.action === 'move'); | |
| 18 | + | return { emit: true, preventDefault }; | |
| 19 | + | }, | |
| 20 | + | send(event) { globalThis.events.push(event); } | |
| 21 | + | }); | |
| 22 | + | }); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | test('applies the Go renderer contract fixture', async ({ page }) => { | |
| 26 | + | await mount(page); | |
| 27 | + | const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8')); | |
| 28 | + | await page.evaluate(value => terminal.apply(value), fixture); | |
| 29 | + | await expect(page.locator('.vev-terminal__cell')).toHaveText(['A', '界']); | |
| 30 | + | await expect(page.locator('.vev-terminal__cursor')).toBeVisible(); | |
| 31 | + | await page.evaluate(value => terminal.apply({ | |
| 32 | + | ...value, | |
| 33 | + | snapshot: false, | |
| 34 | + | rows: [], | |
| 35 | + | styles: [], | |
| 36 | + | cursor: { ...value.cursor, visible: false } | |
| 37 | + | }), fixture); | |
| 38 | + | await expect(page.locator('.vev-terminal__cursor')).toBeHidden(); | |
| 39 | + | }); | |
| 40 | + | ||
| 41 | + | test('reuses text-only cells but rebuilds changed styles and space runs', async ({ page }) => { | |
| 42 | + | await mount(page); | |
| 43 | + | const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8')); | |
| 44 | + | const result = await page.evaluate(value => { | |
| 45 | + | terminal.apply(value); | |
| 46 | + | const original = document.querySelector('.vev-terminal__cell'); | |
| 47 | + | const next = structuredClone(value); | |
| 48 | + | next.rows[0].cells[0].text = 'B'; | |
| 49 | + | terminal.apply(next); | |
| 50 | + | const reused = original === document.querySelector('.vev-terminal__cell') && original.textContent === 'B'; | |
| 51 | + | const styled = structuredClone(next); | |
| 52 | + | styled.styles[0].bold = !styled.styles[0].bold; | |
| 53 | + | terminal.apply(styled); | |
| 54 | + | const rebuiltStyle = original !== document.querySelector('.vev-terminal__cell'); | |
| 55 | + | const beforeSpace = document.querySelector('.vev-terminal__cell'); | |
| 56 | + | const spaces = structuredClone(styled); | |
| 57 | + | spaces.rows[0].cells[0].text = ' '; | |
| 58 | + | terminal.apply(spaces); | |
| 59 | + | const rebuiltSpace = beforeSpace !== document.querySelector('.vev-terminal__cell'); | |
| 60 | + | terminal.apply({ ...next, snapshot: false }); | |
| 61 | + | return { reused, rebuiltStyle, rebuiltSpace, text: document.querySelector('.vev-terminal__cell').textContent }; | |
| 62 | + | }, fixture); | |
| 63 | + | expect(result).toEqual({ reused: true, rebuiltStyle: true, rebuiltSpace: true, text: 'B' }); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test('default colors inherit without redundant per-cell properties', async ({ page }) => { | |
| 67 | + | await mount(page); | |
| 68 | + | const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8')); | |
| 69 | + | const result = await page.evaluate(value => { | |
| 70 | + | const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }; | |
| 71 | + | terminal.apply({ ...value, styles: [plain], rows: value.rows.map(row => ({ ...row, cells: row.cells.map(cell => ({ ...cell, style: 0 })) })) }); | |
| 72 | + | const cell = document.querySelector('.vev-terminal__cell'); | |
| 73 | + | const properties = ['--vev-cell-fg', '--vev-cell-bg', '--vev-cell-underline'].map(name => cell.style.getPropertyValue(name)); | |
| 74 | + | document.querySelector('#terminal').style.setProperty('--vev-fg', 'rgb(12, 34, 56)'); | |
| 75 | + | document.querySelector('#terminal').style.setProperty('--vev-bg', 'rgb(65, 43, 21)'); | |
| 76 | + | return { properties, foreground: getComputedStyle(cell).color, background: getComputedStyle(cell).backgroundColor }; | |
| 77 | + | }, fixture); | |
| 78 | + | expect(result).toEqual({ properties: ['', '', ''], foreground: 'rgb(12, 34, 56)', background: 'rgb(65, 43, 21)' }); | |
| 79 | + | }); | |
| 80 | + | ||
| 81 | + | test('plain spaces retain grid boxes without per-cell clipping', async ({ page }) => { | |
| 82 | + | await mount(page); | |
| 83 | + | await page.evaluate(() => terminal.apply({ | |
| 84 | + | schemaVersion: 1, width: 5, height: 1, snapshot: true, | |
| 85 | + | styles: [ | |
| 86 | + | { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} }, | |
| 87 | + | { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0}, underline:true } | |
| 88 | + | ], | |
| 89 | + | rows: [{row:0, cells:[ | |
| 90 | + | {column:0,width:1,text:' ',style:0}, | |
| 91 | + | {column:1,width:1,text:'A',style:0}, | |
| 92 | + | {column:2,width:1,text:' ',style:1}, | |
| 93 | + | {column:3,width:1,text:' ',style:0}, | |
| 94 | + | {column:4,width:1,text:' ',style:0} | |
| 95 | + | ]}], | |
| 96 | + | cursor: {row:0,column:0,visible:false,style:0,styleSet:false} | |
| 97 | + | })); | |
| 98 | + | const cells = page.locator('.vev-terminal__cell'); | |
| 99 | + | await expect(cells).toHaveCount(4); | |
| 100 | + | await expect(cells.nth(0)).toHaveCSS('overflow', 'visible'); | |
| 101 | + | await expect(cells.nth(1)).toHaveCSS('overflow', 'hidden'); | |
| 102 | + | await expect(cells.nth(2)).toHaveCSS('overflow', 'hidden'); | |
| 103 | + | expect(await cells.nth(0).evaluate(node => node.getBoundingClientRect().width)).toBeGreaterThan(0); | |
| 104 | + | await expect(cells.nth(3)).toHaveAttribute('data-width', '2'); | |
| 105 | + | expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe(' A '); | |
| 106 | + | }); | |
| 107 | + | ||
| 108 | + | test('keeps wide blanks separate from adjacent space runs', async ({ page }) => { | |
| 109 | + | await mount(page); | |
| 110 | + | const geometry = await page.evaluate(() => { | |
| 111 | + | terminal.apply({ | |
| 112 | + | schemaVersion: 1, width: 4, height: 1, snapshot: true, | |
| 113 | + | styles: [{ foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} }], | |
| 114 | + | rows: [{row:0, cells:[ | |
| 115 | + | {column:0,width:1,text:' ',style:0}, | |
| 116 | + | {column:1,width:2,text:' ',style:0}, | |
| 117 | + | {column:3,width:1,text:' ',style:0} | |
| 118 | + | ]}], | |
| 119 | + | cursor: {row:0,column:0,visible:false,style:0,styleSet:false} | |
| 120 | + | }); | |
| 121 | + | return [...document.querySelectorAll('.vev-terminal__cell')].map(node => { | |
| 122 | + | const range = document.createRange(); | |
| 123 | + | range.selectNodeContents(node); | |
| 124 | + | return {x: range.getBoundingClientRect().x, width: node.getBoundingClientRect().width}; | |
| 125 | + | }); | |
| 126 | + | }); | |
| 127 | + | expect(geometry).toHaveLength(3); | |
| 128 | + | expect(geometry[1].x - geometry[0].x).toBeCloseTo(geometry[0].width, 1); | |
| 129 | + | expect(geometry[2].x - geometry[1].x).toBeCloseTo(geometry[1].width, 1); | |
| 130 | + | expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe(' '); | |
| 131 | + | }); | |
| 132 | + | ||
| 133 | + | test('applies typed rows without interpreting terminal text as markup', async ({ page }) => { | |
| 134 | + | await mount(page); | |
| 135 | + | const hostile = '<img src=x onerror="globalThis.injected=true">'; | |
| 136 | + | const update = { | |
| 137 | + | schemaVersion: 1, | |
| 138 | + | width: 3, | |
| 139 | + | height: 1, | |
| 140 | + | snapshot: true, | |
| 141 | + | styles: [{ | |
| 142 | + | foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } }, | |
| 143 | + | background: { kind: 0 }, | |
| 144 | + | underlineColor: { kind: 0 }, | |
| 145 | + | bold: true | |
| 146 | + | }], | |
| 147 | + | rows: [{ row: 0, cells: [ | |
| 148 | + | { column: 0, width: 1, text: hostile, style: 0 }, | |
| 149 | + | { column: 1, width: 2, text: '界', style: 0 } | |
| 150 | + | ] }], | |
| 151 | + | cursor: { row: 0, column: 1, visible: true, style: 3, styleSet: true } | |
| 152 | + | }; | |
| 153 | + | ||
| 154 | + | await page.evaluate(value => terminal.apply(value), update); | |
| 155 | + | await expect(page.locator('.vev-terminal__cell')).toHaveCount(2); | |
| 156 | + | await expect(page.locator('.vev-terminal__cell').first()).toHaveText(hostile); | |
| 157 | + | await expect(page.locator('img')).toHaveCount(0); | |
| 158 | + | await expect(page.locator('.vev-terminal')).toHaveAttribute('aria-label', 'Development terminal'); | |
| 159 | + | await expect(page.getByRole('textbox', { name: 'Development terminal input' })).toBeAttached(); | |
| 160 | + | await expect(page.locator('.vev-terminal__accessible-output')).toHaveText(`${hostile}界`); | |
| 161 | + | expect(await page.evaluate(() => globalThis.injected)).toBeUndefined(); | |
| 162 | + | }); | |
| 163 | + | ||
| 164 | + | test('keeps explicit terminal columns under fallback fonts and zoom', async ({ page }) => { | |
| 165 | + | await mount(page); | |
| 166 | + | await page.evaluate(() => { | |
| 167 | + | document.body.style.zoom = '125%'; | |
| 168 | + | document.querySelector('#terminal').style.fontFamily = 'serif'; | |
| 169 | + | terminal.apply({ | |
| 170 | + | schemaVersion: 1, width: 4, height: 1, snapshot: true, | |
| 171 | + | styles: [{ | |
| 172 | + | bold: true, italic: true, dim: true, blink: true, strikethrough: true, | |
| 173 | + | underline: true, underlineStyle: 3, | |
| 174 | + | foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } }, | |
| 175 | + | background: { kind: 1, index: 4 }, | |
| 176 | + | underlineColor: { kind: 1, index: 12 } | |
| 177 | + | }], | |
| 178 | + | rows: [{ row: 0, cells: [ | |
| 179 | + | { column: 0, width: 1, text: 'A', style: 0 }, | |
| 180 | + | { column: 1, width: 2, text: '界', style: 0 }, | |
| 181 | + | { column: 3, width: 1, text: 'B', style: 0 } | |
| 182 | + | ] }], | |
| 183 | + | cursor: { row: 0, column: 1, visible: true, style: 5, styleSet: true } | |
| 184 | + | }); | |
| 185 | + | }); | |
| 186 | + | const widths = await page.locator('.vev-terminal__cell').evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().width)); | |
| 187 | + | expect(widths[1] / widths[0]).toBeCloseTo(2, 1); | |
| 188 | + | expect(widths[2] / widths[0]).toBeCloseTo(1, 1); | |
| 189 | + | const firstCell = page.locator('.vev-terminal__cell').first(); | |
| 190 | + | await expect(firstCell).toHaveClass(/vev-terminal__cell--underline-wavy/); | |
| 191 | + | expect(await firstCell.evaluate(node => node.style.getPropertyValue('--vev-cell-fg'))).toBe('rgb(1 2 3)'); | |
| 192 | + | await expect(page.locator('.vev-terminal__cursor')).toHaveClass(/vev-terminal__cursor--bar/); | |
| 193 | + | }); | |
| 194 | + | ||
| 195 | + | test('rejects a malformed update before changing the DOM', async ({ page }) => { | |
| 196 | + | await mount(page); | |
| 197 | + | const snapshot = { | |
| 198 | + | schemaVersion: 1, width: 1, height: 1, snapshot: true, | |
| 199 | + | styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }], | |
| 200 | + | rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }], | |
| 201 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |
| 202 | + | }; | |
| 203 | + | await page.evaluate(value => terminal.apply(value), snapshot); | |
| 204 | + | ||
| 205 | + | const message = await page.evaluate(value => { | |
| 206 | + | try { | |
| 207 | + | terminal.apply({ ...value, rows: [{ row: 0, cells: [{ column: 0, width: 2, text: 'X', style: 0 }] }] }); | |
| 208 | + | return ''; | |
| 209 | + | } catch (error) { | |
| 210 | + | return error.message; | |
| 211 | + | } | |
| 212 | + | }, snapshot); | |
| 213 | + | expect(message).toContain('row coverage'); | |
| 214 | + | await expect(page.locator('.vev-terminal__cell')).toHaveText('A'); | |
| 215 | + | const staleVersion = await page.evaluate(value => { | |
| 216 | + | try { | |
| 217 | + | terminal.apply({ ...value, schemaVersion: 2 }); | |
| 218 | + | return ''; | |
| 219 | + | } catch (error) { | |
| 220 | + | return error.message; | |
| 221 | + | } | |
| 222 | + | }, snapshot); | |
| 223 | + | expect(staleVersion).toContain('unsupported update schema'); | |
| 224 | + | ||
| 225 | + | const duplicateMount = await page.evaluate(() => { | |
| 226 | + | try { | |
| 227 | + | VevTerminal.mount(document.querySelector('#terminal'), { label: 'Duplicate' }); | |
| 228 | + | return ''; | |
| 229 | + | } catch (error) { | |
| 230 | + | return error.message; | |
| 231 | + | } | |
| 232 | + | }); | |
| 233 | + | expect(duplicateMount).toContain('already owns a terminal'); | |
| 234 | + | }); | |
| 235 | + | ||
| 236 | + | test('rejects colors with a payload from another color kind', async ({ page }) => { | |
| 237 | + | await mount(page); | |
| 238 | + | const message = await page.evaluate(() => { | |
| 239 | + | const color = { kind: 1, index: 4, rgb: { r: 1, g: 2, b: 3 } }; | |
| 240 | + | try { | |
| 241 | + | terminal.apply({ | |
| 242 | + | schemaVersion: 1, width: 1, height: 1, snapshot: true, | |
| 243 | + | styles: [{ foreground: color, background: { kind: 0 }, underlineColor: { kind: 0 } }], | |
| 244 | + | rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }], | |
| 245 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |
| 246 | + | }); | |
| 247 | + | return ''; | |
| 248 | + | } catch (error) { | |
| 249 | + | return error.message; | |
| 250 | + | } | |
| 251 | + | }); | |
| 252 | + | expect(message).toContain('indexed color has an rgb payload'); | |
| 253 | + | }); | |
| 254 | + | ||
| 255 | + | test('emits text and synchronously decides key default prevention', async ({ page }) => { | |
| 256 | + | await mount(page); | |
| 257 | + | const input = page.locator('.vev-terminal__input'); | |
| 258 | + | await input.focus(); | |
| 259 | + | await input.evaluate(node => node.dispatchEvent(new InputEvent('beforeinput', { | |
| 260 | + | bubbles: true, | |
| 261 | + | cancelable: true, | |
| 262 | + | inputType: 'insertText', | |
| 263 | + | data: 'é' | |
| 264 | + | }))); | |
| 265 | + | const keyDispatchResult = await input.evaluate(node => node.dispatchEvent(new KeyboardEvent('keydown', { | |
| 266 | + | bubbles: true, | |
| 267 | + | cancelable: true, | |
| 268 | + | key: 'ArrowUp', | |
| 269 | + | code: 'ArrowUp' | |
| 270 | + | }))); | |
| 271 | + | ||
| 272 | + | const events = await page.evaluate(() => globalThis.events); | |
| 273 | + | expect(events.some(event => event.type === 'text' && event.text === 'é')).toBeTruthy(); | |
| 274 | + | expect(events.some(event => event.type === 'key' && event.key === 'ArrowUp')).toBeTruthy(); | |
| 275 | + | expect(keyDispatchResult).toBeFalsy(); | |
| 276 | + | }); | |
| 277 | + | ||
| 278 | + | test('supports soft keyboard editing without unidentified keys or duplicate desktop edits', async ({ page }) => { | |
| 279 | + | await mount(page); | |
| 280 | + | const input = page.locator('.vev-terminal__input'); | |
| 281 | + | await input.focus(); | |
| 282 | + | await input.evaluate(node => { | |
| 283 | + | node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Unidentified', bubbles: true, cancelable: true })); | |
| 284 | + | for (const inputType of ['deleteContentBackward', 'deleteContentForward', 'insertLineBreak']) { | |
| 285 | + | node.dispatchEvent(new InputEvent('beforeinput', { inputType, data: null, bubbles: true, cancelable: true })); | |
| 286 | + | } | |
| 287 | + | }); | |
| 288 | + | await page.keyboard.press('Backspace'); | |
| 289 | + | await page.keyboard.press('Enter'); | |
| 290 | + | const keys = await page.evaluate(() => globalThis.events.filter(event => event.type === 'key').map(event => event.key)); | |
| 291 | + | expect(keys).toEqual(['Backspace', 'Delete', 'Enter', 'Backspace', 'Enter']); | |
| 292 | + | expect(await input.evaluate(node => getComputedStyle(node).fontSize)).toBe('16px'); | |
| 293 | + | }); | |
| 294 | + | ||
| 295 | + | test('rejects oversized input without throwing from DOM callbacks', async ({ page }) => { | |
| 296 | + | const errors = []; | |
| 297 | + | page.on('pageerror', error => errors.push(error.message)); | |
| 298 | + | await mount(page); | |
| 299 | + | await page.evaluate(() => { | |
| 300 | + | terminal.destroy(); | |
| 301 | + | globalThis.events = []; | |
| 302 | + | globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { | |
| 303 | + | label: 'Bounded terminal', | |
| 304 | + | limits: { maxTextBytes: 1, maxPasteBytes: 1 }, | |
| 305 | + | send(event) { globalThis.events.push(event); } | |
| 306 | + | }); | |
| 307 | + | }); | |
| 308 | + | const input = page.locator('.vev-terminal__input'); | |
| 309 | + | const rejected = await input.evaluate(node => { | |
| 310 | + | const textAccepted = node.dispatchEvent(new InputEvent('beforeinput', { | |
| 311 | + | bubbles: true, cancelable: true, inputType: 'insertText', data: 'é' | |
| 312 | + | })); | |
| 313 | + | const paste = new Event('paste', { bubbles: true, cancelable: true }); | |
| 314 | + | Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'xx' } }); | |
| 315 | + | const pasteAccepted = node.dispatchEvent(paste); | |
| 316 | + | return { textAccepted, pasteAccepted }; | |
| 317 | + | }); | |
| 318 | + | expect(rejected).toEqual({ textAccepted: false, pasteAccepted: false }); | |
| 319 | + | expect(await page.evaluate(() => globalThis.events)).toEqual([]); | |
| 320 | + | expect(errors).toEqual([]); | |
| 321 | + | }); | |
| 322 | + | ||
| 323 | + | test('emits one composed text event and bounded pointer, wheel, resize, and focus events', async ({ page }) => { | |
| 324 | + | await mount(page); | |
| 325 | + | const snapshot = { | |
| 326 | + | schemaVersion: 1, width: 2, height: 1, snapshot: true, | |
| 327 | + | styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }], | |
| 328 | + | rows: [{ row: 0, cells: [ | |
| 329 | + | { column: 0, width: 1, text: 'A', style: 0 }, | |
| 330 | + | { column: 1, width: 1, text: 'B', style: 0 } | |
| 331 | + | ] }], | |
| 332 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |
| 333 | + | }; | |
| 334 | + | await page.evaluate(value => terminal.apply(value), snapshot); | |
| 335 | + | const input = page.locator('.vev-terminal__input'); | |
| 336 | + | await input.focus(); | |
| 337 | + | const compositionValue = await input.evaluate(node => { | |
| 338 | + | node.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); | |
| 339 | + | node.value = 'e'; | |
| 340 | + | node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertCompositionText', data: 'e', isComposing: true })); | |
| 341 | + | const retainedValue = node.value; | |
| 342 | + | node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'e' })); | |
| 343 | + | node.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: 'é' })); | |
| 344 | + | node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'é' })); | |
| 345 | + | const paste = new Event('paste', { bubbles: true, cancelable: true }); | |
| 346 | + | Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'paste' } }); | |
| 347 | + | node.dispatchEvent(paste); | |
| 348 | + | return retainedValue; | |
| 349 | + | }); | |
| 350 | + | ||
| 351 | + | const viewport = page.locator('.vev-terminal__viewport'); | |
| 352 | + | const box = await viewport.boundingBox(); | |
| 353 | + | await page.mouse.click(box.x + 1, box.y + 1); | |
| 354 | + | const pointerMoveAccepted = await viewport.evaluate(node => node.dispatchEvent(new PointerEvent('pointermove', { | |
| 355 | + | bubbles: true, cancelable: true, button: -1, buttons: 0, clientX: 1, clientY: 1 | |
| 356 | + | }))); | |
| 357 | + | await viewport.dispatchEvent('wheel', { deltaX: 1, deltaY: 2, deltaMode: 0, clientX: box.x + 1, clientY: box.y + 1 }); | |
| 358 | + | await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'resize'))).toBeTruthy(); | |
| 359 | + | await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'pointer' && event.action === 'move'))).toBeTruthy(); | |
| 360 | + | ||
| 361 | + | const events = await page.evaluate(() => globalThis.events); | |
| 362 | + | expect(compositionValue).toBe('e'); | |
| 363 | + | expect(events.filter(event => event.type === 'text' && event.text === 'é')).toHaveLength(1); | |
| 364 | + | expect(events.some(event => event.type === 'paste' && event.text === 'paste')).toBeTruthy(); | |
| 365 | + | expect(events.some(event => event.type === 'pointer' && event.action === 'down')).toBeTruthy(); | |
| 366 | + | expect(pointerMoveAccepted).toBeFalsy(); | |
| 367 | + | expect(events.some(event => event.type === 'wheel' && event.row === 0 && event.column === 0)).toBeTruthy(); | |
| 368 | + | expect(events.some(event => event.type === 'focus' && event.focused)).toBeTruthy(); | |
| 369 | + | }); | |
| 370 | + | ||
| 371 | + | test('applies dynamic terminal styles under a self-only CSP', async ({ page }) => { | |
| 372 | + | const violations = []; | |
| 373 | + | page.on('console', message => { | |
| 374 | + | if (message.text().includes('Content Security Policy')) violations.push(message.text()); | |
| 375 | + | }); | |
| 376 | + | const [css, script] = await Promise.all([ | |
| 377 | + | fs.readFile(path.join(root, 'html/terminal.css'), 'utf8'), | |
| 378 | + | fs.readFile(path.join(root, 'html/browser/terminal.js'), 'utf8') | |
| 379 | + | ]); | |
| 380 | + | await page.route('http://vev.test/**', async route => { | |
| 381 | + | const pathname = new URL(route.request().url()).pathname; | |
| 382 | + | if (pathname === '/terminal.css') return route.fulfill({ contentType: 'text/css', body: css }); | |
| 383 | + | if (pathname === '/terminal.js') return route.fulfill({ contentType: 'text/javascript', body: script }); | |
| 384 | + | return route.fulfill({ | |
| 385 | + | contentType: 'text/html', | |
| 386 | + | headers: { 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'" }, | |
| 387 | + | body: '<link rel="stylesheet" href="/terminal.css"><div id="terminal"></div><script src="/terminal.js"></script>' | |
| 388 | + | }); | |
| 389 | + | }); | |
| 390 | + | await page.goto('http://vev.test/'); | |
| 391 | + | await page.waitForFunction(() => globalThis.VevTerminal); | |
| 392 | + | await page.evaluate(() => { | |
| 393 | + | globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'CSP terminal' }); | |
| 394 | + | terminal.apply({ | |
| 395 | + | schemaVersion: 1, width: 1, height: 1, snapshot: true, | |
| 396 | + | styles: [{ | |
| 397 | + | foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } }, | |
| 398 | + | background: { kind: 1, index: 4 }, | |
| 399 | + | underlineColor: { kind: 0 } | |
| 400 | + | }], | |
| 401 | + | rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }], | |
| 402 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |
| 403 | + | }); | |
| 404 | + | }); | |
| 405 | + | await expect(page.locator('.vev-terminal__cell')).toHaveCSS('color', 'rgb(1, 2, 3)'); | |
| 406 | + | expect(violations).toEqual([]); | |
| 407 | + | }); | |
| 408 | + | ||
| 409 | + | test('restores host attributes and theme properties on destroy', async ({ page }) => { | |
| 410 | + | await page.setContent('<div id="terminal" role="region" aria-label="Host label" style="--vev-fg:rgb(9 8 7)"></div>'); | |
| 411 | + | await page.addStyleTag({ path: path.join(root, 'html/terminal.css') }); | |
| 412 | + | await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') }); | |
| 413 | + | await page.evaluate(() => { | |
| 414 | + | const instance = VevTerminal.mount(document.querySelector('#terminal'), { label: 'Mounted label' }); | |
| 415 | + | const color = { r: 1, g: 2, b: 3 }; | |
| 416 | + | instance.setTheme({ | |
| 417 | + | foreground: color, background: color, cursor: color, selection: color, | |
| 418 | + | selectionText: color, palette: Array.from({ length: 16 }, () => color) | |
| 419 | + | }); | |
| 420 | + | instance.destroy(); | |
| 421 | + | }); | |
| 422 | + | await expect(page.locator('#terminal')).toHaveAttribute('role', 'region'); | |
| 423 | + | await expect(page.locator('#terminal')).toHaveAttribute('aria-label', 'Host label'); | |
| 424 | + | expect(await page.locator('#terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toBe('rgb(9 8 7)'); | |
| 425 | + | }); | |
| 426 | + | ||
| 427 | + | test('updates complete rows, applies typed themes, and destroys owned state', async ({ page }) => { | |
| 428 | + | await mount(page); | |
| 429 | + | const style = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }; | |
| 430 | + | await page.evaluate(value => terminal.apply(value), { | |
| 431 | + | schemaVersion: 1, width: 1, height: 1, snapshot: true, styles: [style], | |
| 432 | + | rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }], | |
| 433 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |
| 434 | + | }); | |
| 435 | + | await page.evaluate(value => terminal.apply(value), { | |
| 436 | + | schemaVersion: 1, width: 1, height: 1, snapshot: false, styles: [style], | |
| 437 | + | rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'B', style: 0 }] }], | |
| 438 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |
| 439 | + | }); | |
| 440 | + | await expect(page.locator('.vev-terminal__accessible-output')).toHaveText('B'); | |
| 441 | + | ||
| 442 | + | const color = { r: 1, g: 2, b: 3 }; | |
| 443 | + | await page.evaluate(value => terminal.setTheme(value), { | |
| 444 | + | foreground: color, background: color, cursor: color, selection: color, | |
| 445 | + | selectionText: color, palette: Array.from({ length: 16 }, () => color) | |
| 446 | + | }); | |
| 447 | + | expect(await page.locator('.vev-terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toContain('1 2 3'); | |
| 448 | + | ||
| 449 | + | await page.evaluate(() => terminal.destroy()); | |
| 450 | + | await expect(page.locator('.vev-terminal__viewport')).toHaveCount(0); | |
| 451 | + | await expect(page.locator('#terminal')).not.toHaveClass(/vev-terminal/); | |
| 452 | + | }); | |
| @@ -0,0 +1,452 @@ | |||||
| 1 | + | import { test, expect } from '@playwright/test'; | |||
| 2 | + | import fs from 'node:fs/promises'; | |||
| 3 | + | import path from 'node:path'; | |||
| 4 | + | ||||
| 5 | + | const root = process.cwd(); | |||
| 6 | + | ||||
| 7 | + | async function mount(page) { | |||
| 8 | + | await page.setContent('<main><div id="terminal"></div></main>'); | |||
| 9 | + | await page.addStyleTag({ path: path.join(root, 'html/terminal.css') }); | |||
| 10 | + | await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') }); | |||
| 11 | + | await page.evaluate(() => { | |||
| 12 | + | globalThis.events = []; | |||
| 13 | + | globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { | |||
| 14 | + | label: 'Development terminal', | |||
| 15 | + | decide(event) { | |||
| 16 | + | const preventDefault = (event.type === 'key' && event.key === 'ArrowUp') || | |||
| 17 | + | (event.type === 'pointer' && event.action === 'move'); | |||
| 18 | + | return { emit: true, preventDefault }; | |||
| 19 | + | }, | |||
| 20 | + | send(event) { globalThis.events.push(event); } | |||
| 21 | + | }); | |||
| 22 | + | }); | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | test('applies the Go renderer contract fixture', async ({ page }) => { | |||
| 26 | + | await mount(page); | |||
| 27 | + | const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8')); | |||
| 28 | + | await page.evaluate(value => terminal.apply(value), fixture); | |||
| 29 | + | await expect(page.locator('.vev-terminal__cell')).toHaveText(['A', '界']); | |||
| 30 | + | await expect(page.locator('.vev-terminal__cursor')).toBeVisible(); | |||
| 31 | + | await page.evaluate(value => terminal.apply({ | |||
| 32 | + | ...value, | |||
| 33 | + | snapshot: false, | |||
| 34 | + | rows: [], | |||
| 35 | + | styles: [], | |||
| 36 | + | cursor: { ...value.cursor, visible: false } | |||
| 37 | + | }), fixture); | |||
| 38 | + | await expect(page.locator('.vev-terminal__cursor')).toBeHidden(); | |||
| 39 | + | }); | |||
| 40 | + | ||||
| 41 | + | test('reuses text-only cells but rebuilds changed styles and space runs', async ({ page }) => { | |||
| 42 | + | await mount(page); | |||
| 43 | + | const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8')); | |||
| 44 | + | const result = await page.evaluate(value => { | |||
| 45 | + | terminal.apply(value); | |||
| 46 | + | const original = document.querySelector('.vev-terminal__cell'); | |||
| 47 | + | const next = structuredClone(value); | |||
| 48 | + | next.rows[0].cells[0].text = 'B'; | |||
| 49 | + | terminal.apply(next); | |||
| 50 | + | const reused = original === document.querySelector('.vev-terminal__cell') && original.textContent === 'B'; | |||
| 51 | + | const styled = structuredClone(next); | |||
| 52 | + | styled.styles[0].bold = !styled.styles[0].bold; | |||
| 53 | + | terminal.apply(styled); | |||
| 54 | + | const rebuiltStyle = original !== document.querySelector('.vev-terminal__cell'); | |||
| 55 | + | const beforeSpace = document.querySelector('.vev-terminal__cell'); | |||
| 56 | + | const spaces = structuredClone(styled); | |||
| 57 | + | spaces.rows[0].cells[0].text = ' '; | |||
| 58 | + | terminal.apply(spaces); | |||
| 59 | + | const rebuiltSpace = beforeSpace !== document.querySelector('.vev-terminal__cell'); | |||
| 60 | + | terminal.apply({ ...next, snapshot: false }); | |||
| 61 | + | return { reused, rebuiltStyle, rebuiltSpace, text: document.querySelector('.vev-terminal__cell').textContent }; | |||
| 62 | + | }, fixture); | |||
| 63 | + | expect(result).toEqual({ reused: true, rebuiltStyle: true, rebuiltSpace: true, text: 'B' }); | |||
| 64 | + | }); | |||
| 65 | + | ||||
| 66 | + | test('default colors inherit without redundant per-cell properties', async ({ page }) => { | |||
| 67 | + | await mount(page); | |||
| 68 | + | const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8')); | |||
| 69 | + | const result = await page.evaluate(value => { | |||
| 70 | + | const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }; | |||
| 71 | + | terminal.apply({ ...value, styles: [plain], rows: value.rows.map(row => ({ ...row, cells: row.cells.map(cell => ({ ...cell, style: 0 })) })) }); | |||
| 72 | + | const cell = document.querySelector('.vev-terminal__cell'); | |||
| 73 | + | const properties = ['--vev-cell-fg', '--vev-cell-bg', '--vev-cell-underline'].map(name => cell.style.getPropertyValue(name)); | |||
| 74 | + | document.querySelector('#terminal').style.setProperty('--vev-fg', 'rgb(12, 34, 56)'); | |||
| 75 | + | document.querySelector('#terminal').style.setProperty('--vev-bg', 'rgb(65, 43, 21)'); | |||
| 76 | + | return { properties, foreground: getComputedStyle(cell).color, background: getComputedStyle(cell).backgroundColor }; | |||
| 77 | + | }, fixture); | |||
| 78 | + | expect(result).toEqual({ properties: ['', '', ''], foreground: 'rgb(12, 34, 56)', background: 'rgb(65, 43, 21)' }); | |||
| 79 | + | }); | |||
| 80 | + | ||||
| 81 | + | test('plain spaces retain grid boxes without per-cell clipping', async ({ page }) => { | |||
| 82 | + | await mount(page); | |||
| 83 | + | await page.evaluate(() => terminal.apply({ | |||
| 84 | + | schemaVersion: 1, width: 5, height: 1, snapshot: true, | |||
| 85 | + | styles: [ | |||
| 86 | + | { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} }, | |||
| 87 | + | { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0}, underline:true } | |||
| 88 | + | ], | |||
| 89 | + | rows: [{row:0, cells:[ | |||
| 90 | + | {column:0,width:1,text:' ',style:0}, | |||
| 91 | + | {column:1,width:1,text:'A',style:0}, | |||
| 92 | + | {column:2,width:1,text:' ',style:1}, | |||
| 93 | + | {column:3,width:1,text:' ',style:0}, | |||
| 94 | + | {column:4,width:1,text:' ',style:0} | |||
| 95 | + | ]}], | |||
| 96 | + | cursor: {row:0,column:0,visible:false,style:0,styleSet:false} | |||
| 97 | + | })); | |||
| 98 | + | const cells = page.locator('.vev-terminal__cell'); | |||
| 99 | + | await expect(cells).toHaveCount(4); | |||
| 100 | + | await expect(cells.nth(0)).toHaveCSS('overflow', 'visible'); | |||
| 101 | + | await expect(cells.nth(1)).toHaveCSS('overflow', 'hidden'); | |||
| 102 | + | await expect(cells.nth(2)).toHaveCSS('overflow', 'hidden'); | |||
| 103 | + | expect(await cells.nth(0).evaluate(node => node.getBoundingClientRect().width)).toBeGreaterThan(0); | |||
| 104 | + | await expect(cells.nth(3)).toHaveAttribute('data-width', '2'); | |||
| 105 | + | expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe(' A '); | |||
| 106 | + | }); | |||
| 107 | + | ||||
| 108 | + | test('keeps wide blanks separate from adjacent space runs', async ({ page }) => { | |||
| 109 | + | await mount(page); | |||
| 110 | + | const geometry = await page.evaluate(() => { | |||
| 111 | + | terminal.apply({ | |||
| 112 | + | schemaVersion: 1, width: 4, height: 1, snapshot: true, | |||
| 113 | + | styles: [{ foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} }], | |||
| 114 | + | rows: [{row:0, cells:[ | |||
| 115 | + | {column:0,width:1,text:' ',style:0}, | |||
| 116 | + | {column:1,width:2,text:' ',style:0}, | |||
| 117 | + | {column:3,width:1,text:' ',style:0} | |||
| 118 | + | ]}], | |||
| 119 | + | cursor: {row:0,column:0,visible:false,style:0,styleSet:false} | |||
| 120 | + | }); | |||
| 121 | + | return [...document.querySelectorAll('.vev-terminal__cell')].map(node => { | |||
| 122 | + | const range = document.createRange(); | |||
| 123 | + | range.selectNodeContents(node); | |||
| 124 | + | return {x: range.getBoundingClientRect().x, width: node.getBoundingClientRect().width}; | |||
| 125 | + | }); | |||
| 126 | + | }); | |||
| 127 | + | expect(geometry).toHaveLength(3); | |||
| 128 | + | expect(geometry[1].x - geometry[0].x).toBeCloseTo(geometry[0].width, 1); | |||
| 129 | + | expect(geometry[2].x - geometry[1].x).toBeCloseTo(geometry[1].width, 1); | |||
| 130 | + | expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe(' '); | |||
| 131 | + | }); | |||
| 132 | + | ||||
| 133 | + | test('applies typed rows without interpreting terminal text as markup', async ({ page }) => { | |||
| 134 | + | await mount(page); | |||
| 135 | + | const hostile = '<img src=x onerror="globalThis.injected=true">'; | |||
| 136 | + | const update = { | |||
| 137 | + | schemaVersion: 1, | |||
| 138 | + | width: 3, | |||
| 139 | + | height: 1, | |||
| 140 | + | snapshot: true, | |||
| 141 | + | styles: [{ | |||
| 142 | + | foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } }, | |||
| 143 | + | background: { kind: 0 }, | |||
| 144 | + | underlineColor: { kind: 0 }, | |||
| 145 | + | bold: true | |||
| 146 | + | }], | |||
| 147 | + | rows: [{ row: 0, cells: [ | |||
| 148 | + | { column: 0, width: 1, text: hostile, style: 0 }, | |||
| 149 | + | { column: 1, width: 2, text: '界', style: 0 } | |||
| 150 | + | ] }], | |||
| 151 | + | cursor: { row: 0, column: 1, visible: true, style: 3, styleSet: true } | |||
| 152 | + | }; | |||
| 153 | + | ||||
| 154 | + | await page.evaluate(value => terminal.apply(value), update); | |||
| 155 | + | await expect(page.locator('.vev-terminal__cell')).toHaveCount(2); | |||
| 156 | + | await expect(page.locator('.vev-terminal__cell').first()).toHaveText(hostile); | |||
| 157 | + | await expect(page.locator('img')).toHaveCount(0); | |||
| 158 | + | await expect(page.locator('.vev-terminal')).toHaveAttribute('aria-label', 'Development terminal'); | |||
| 159 | + | await expect(page.getByRole('textbox', { name: 'Development terminal input' })).toBeAttached(); | |||
| 160 | + | await expect(page.locator('.vev-terminal__accessible-output')).toHaveText(`${hostile}界`); | |||
| 161 | + | expect(await page.evaluate(() => globalThis.injected)).toBeUndefined(); | |||
| 162 | + | }); | |||
| 163 | + | ||||
| 164 | + | test('keeps explicit terminal columns under fallback fonts and zoom', async ({ page }) => { | |||
| 165 | + | await mount(page); | |||
| 166 | + | await page.evaluate(() => { | |||
| 167 | + | document.body.style.zoom = '125%'; | |||
| 168 | + | document.querySelector('#terminal').style.fontFamily = 'serif'; | |||
| 169 | + | terminal.apply({ | |||
| 170 | + | schemaVersion: 1, width: 4, height: 1, snapshot: true, | |||
| 171 | + | styles: [{ | |||
| 172 | + | bold: true, italic: true, dim: true, blink: true, strikethrough: true, | |||
| 173 | + | underline: true, underlineStyle: 3, | |||
| 174 | + | foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } }, | |||
| 175 | + | background: { kind: 1, index: 4 }, | |||
| 176 | + | underlineColor: { kind: 1, index: 12 } | |||
| 177 | + | }], | |||
| 178 | + | rows: [{ row: 0, cells: [ | |||
| 179 | + | { column: 0, width: 1, text: 'A', style: 0 }, | |||
| 180 | + | { column: 1, width: 2, text: '界', style: 0 }, | |||
| 181 | + | { column: 3, width: 1, text: 'B', style: 0 } | |||
| 182 | + | ] }], | |||
| 183 | + | cursor: { row: 0, column: 1, visible: true, style: 5, styleSet: true } | |||
| 184 | + | }); | |||
| 185 | + | }); | |||
| 186 | + | const widths = await page.locator('.vev-terminal__cell').evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().width)); | |||
| 187 | + | expect(widths[1] / widths[0]).toBeCloseTo(2, 1); | |||
| 188 | + | expect(widths[2] / widths[0]).toBeCloseTo(1, 1); | |||
| 189 | + | const firstCell = page.locator('.vev-terminal__cell').first(); | |||
| 190 | + | await expect(firstCell).toHaveClass(/vev-terminal__cell--underline-wavy/); | |||
| 191 | + | expect(await firstCell.evaluate(node => node.style.getPropertyValue('--vev-cell-fg'))).toBe('rgb(1 2 3)'); | |||
| 192 | + | await expect(page.locator('.vev-terminal__cursor')).toHaveClass(/vev-terminal__cursor--bar/); | |||
| 193 | + | }); | |||
| 194 | + | ||||
| 195 | + | test('rejects a malformed update before changing the DOM', async ({ page }) => { | |||
| 196 | + | await mount(page); | |||
| 197 | + | const snapshot = { | |||
| 198 | + | schemaVersion: 1, width: 1, height: 1, snapshot: true, | |||
| 199 | + | styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }], | |||
| 200 | + | rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }], | |||
| 201 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |||
| 202 | + | }; | |||
| 203 | + | await page.evaluate(value => terminal.apply(value), snapshot); | |||
| 204 | + | ||||
| 205 | + | const message = await page.evaluate(value => { | |||
| 206 | + | try { | |||
| 207 | + | terminal.apply({ ...value, rows: [{ row: 0, cells: [{ column: 0, width: 2, text: 'X', style: 0 }] }] }); | |||
| 208 | + | return ''; | |||
| 209 | + | } catch (error) { | |||
| 210 | + | return error.message; | |||
| 211 | + | } | |||
| 212 | + | }, snapshot); | |||
| 213 | + | expect(message).toContain('row coverage'); | |||
| 214 | + | await expect(page.locator('.vev-terminal__cell')).toHaveText('A'); | |||
| 215 | + | const staleVersion = await page.evaluate(value => { | |||
| 216 | + | try { | |||
| 217 | + | terminal.apply({ ...value, schemaVersion: 2 }); | |||
| 218 | + | return ''; | |||
| 219 | + | } catch (error) { | |||
| 220 | + | return error.message; | |||
| 221 | + | } | |||
| 222 | + | }, snapshot); | |||
| 223 | + | expect(staleVersion).toContain('unsupported update schema'); | |||
| 224 | + | ||||
| 225 | + | const duplicateMount = await page.evaluate(() => { | |||
| 226 | + | try { | |||
| 227 | + | VevTerminal.mount(document.querySelector('#terminal'), { label: 'Duplicate' }); | |||
| 228 | + | return ''; | |||
| 229 | + | } catch (error) { | |||
| 230 | + | return error.message; | |||
| 231 | + | } | |||
| 232 | + | }); | |||
| 233 | + | expect(duplicateMount).toContain('already owns a terminal'); | |||
| 234 | + | }); | |||
| 235 | + | ||||
| 236 | + | test('rejects colors with a payload from another color kind', async ({ page }) => { | |||
| 237 | + | await mount(page); | |||
| 238 | + | const message = await page.evaluate(() => { | |||
| 239 | + | const color = { kind: 1, index: 4, rgb: { r: 1, g: 2, b: 3 } }; | |||
| 240 | + | try { | |||
| 241 | + | terminal.apply({ | |||
| 242 | + | schemaVersion: 1, width: 1, height: 1, snapshot: true, | |||
| 243 | + | styles: [{ foreground: color, background: { kind: 0 }, underlineColor: { kind: 0 } }], | |||
| 244 | + | rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }], | |||
| 245 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |||
| 246 | + | }); | |||
| 247 | + | return ''; | |||
| 248 | + | } catch (error) { | |||
| 249 | + | return error.message; | |||
| 250 | + | } | |||
| 251 | + | }); | |||
| 252 | + | expect(message).toContain('indexed color has an rgb payload'); | |||
| 253 | + | }); | |||
| 254 | + | ||||
| 255 | + | test('emits text and synchronously decides key default prevention', async ({ page }) => { | |||
| 256 | + | await mount(page); | |||
| 257 | + | const input = page.locator('.vev-terminal__input'); | |||
| 258 | + | await input.focus(); | |||
| 259 | + | await input.evaluate(node => node.dispatchEvent(new InputEvent('beforeinput', { | |||
| 260 | + | bubbles: true, | |||
| 261 | + | cancelable: true, | |||
| 262 | + | inputType: 'insertText', | |||
| 263 | + | data: 'é' | |||
| 264 | + | }))); | |||
| 265 | + | const keyDispatchResult = await input.evaluate(node => node.dispatchEvent(new KeyboardEvent('keydown', { | |||
| 266 | + | bubbles: true, | |||
| 267 | + | cancelable: true, | |||
| 268 | + | key: 'ArrowUp', | |||
| 269 | + | code: 'ArrowUp' | |||
| 270 | + | }))); | |||
| 271 | + | ||||
| 272 | + | const events = await page.evaluate(() => globalThis.events); | |||
| 273 | + | expect(events.some(event => event.type === 'text' && event.text === 'é')).toBeTruthy(); | |||
| 274 | + | expect(events.some(event => event.type === 'key' && event.key === 'ArrowUp')).toBeTruthy(); | |||
| 275 | + | expect(keyDispatchResult).toBeFalsy(); | |||
| 276 | + | }); | |||
| 277 | + | ||||
| 278 | + | test('supports soft keyboard editing without unidentified keys or duplicate desktop edits', async ({ page }) => { | |||
| 279 | + | await mount(page); | |||
| 280 | + | const input = page.locator('.vev-terminal__input'); | |||
| 281 | + | await input.focus(); | |||
| 282 | + | await input.evaluate(node => { | |||
| 283 | + | node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Unidentified', bubbles: true, cancelable: true })); | |||
| 284 | + | for (const inputType of ['deleteContentBackward', 'deleteContentForward', 'insertLineBreak']) { | |||
| 285 | + | node.dispatchEvent(new InputEvent('beforeinput', { inputType, data: null, bubbles: true, cancelable: true })); | |||
| 286 | + | } | |||
| 287 | + | }); | |||
| 288 | + | await page.keyboard.press('Backspace'); | |||
| 289 | + | await page.keyboard.press('Enter'); | |||
| 290 | + | const keys = await page.evaluate(() => globalThis.events.filter(event => event.type === 'key').map(event => event.key)); | |||
| 291 | + | expect(keys).toEqual(['Backspace', 'Delete', 'Enter', 'Backspace', 'Enter']); | |||
| 292 | + | expect(await input.evaluate(node => getComputedStyle(node).fontSize)).toBe('16px'); | |||
| 293 | + | }); | |||
| 294 | + | ||||
| 295 | + | test('rejects oversized input without throwing from DOM callbacks', async ({ page }) => { | |||
| 296 | + | const errors = []; | |||
| 297 | + | page.on('pageerror', error => errors.push(error.message)); | |||
| 298 | + | await mount(page); | |||
| 299 | + | await page.evaluate(() => { | |||
| 300 | + | terminal.destroy(); | |||
| 301 | + | globalThis.events = []; | |||
| 302 | + | globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { | |||
| 303 | + | label: 'Bounded terminal', | |||
| 304 | + | limits: { maxTextBytes: 1, maxPasteBytes: 1 }, | |||
| 305 | + | send(event) { globalThis.events.push(event); } | |||
| 306 | + | }); | |||
| 307 | + | }); | |||
| 308 | + | const input = page.locator('.vev-terminal__input'); | |||
| 309 | + | const rejected = await input.evaluate(node => { | |||
| 310 | + | const textAccepted = node.dispatchEvent(new InputEvent('beforeinput', { | |||
| 311 | + | bubbles: true, cancelable: true, inputType: 'insertText', data: 'é' | |||
| 312 | + | })); | |||
| 313 | + | const paste = new Event('paste', { bubbles: true, cancelable: true }); | |||
| 314 | + | Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'xx' } }); | |||
| 315 | + | const pasteAccepted = node.dispatchEvent(paste); | |||
| 316 | + | return { textAccepted, pasteAccepted }; | |||
| 317 | + | }); | |||
| 318 | + | expect(rejected).toEqual({ textAccepted: false, pasteAccepted: false }); | |||
| 319 | + | expect(await page.evaluate(() => globalThis.events)).toEqual([]); | |||
| 320 | + | expect(errors).toEqual([]); | |||
| 321 | + | }); | |||
| 322 | + | ||||
| 323 | + | test('emits one composed text event and bounded pointer, wheel, resize, and focus events', async ({ page }) => { | |||
| 324 | + | await mount(page); | |||
| 325 | + | const snapshot = { | |||
| 326 | + | schemaVersion: 1, width: 2, height: 1, snapshot: true, | |||
| 327 | + | styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }], | |||
| 328 | + | rows: [{ row: 0, cells: [ | |||
| 329 | + | { column: 0, width: 1, text: 'A', style: 0 }, | |||
| 330 | + | { column: 1, width: 1, text: 'B', style: 0 } | |||
| 331 | + | ] }], | |||
| 332 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |||
| 333 | + | }; | |||
| 334 | + | await page.evaluate(value => terminal.apply(value), snapshot); | |||
| 335 | + | const input = page.locator('.vev-terminal__input'); | |||
| 336 | + | await input.focus(); | |||
| 337 | + | const compositionValue = await input.evaluate(node => { | |||
| 338 | + | node.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); | |||
| 339 | + | node.value = 'e'; | |||
| 340 | + | node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertCompositionText', data: 'e', isComposing: true })); | |||
| 341 | + | const retainedValue = node.value; | |||
| 342 | + | node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'e' })); | |||
| 343 | + | node.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: 'é' })); | |||
| 344 | + | node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'é' })); | |||
| 345 | + | const paste = new Event('paste', { bubbles: true, cancelable: true }); | |||
| 346 | + | Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'paste' } }); | |||
| 347 | + | node.dispatchEvent(paste); | |||
| 348 | + | return retainedValue; | |||
| 349 | + | }); | |||
| 350 | + | ||||
| 351 | + | const viewport = page.locator('.vev-terminal__viewport'); | |||
| 352 | + | const box = await viewport.boundingBox(); | |||
| 353 | + | await page.mouse.click(box.x + 1, box.y + 1); | |||
| 354 | + | const pointerMoveAccepted = await viewport.evaluate(node => node.dispatchEvent(new PointerEvent('pointermove', { | |||
| 355 | + | bubbles: true, cancelable: true, button: -1, buttons: 0, clientX: 1, clientY: 1 | |||
| 356 | + | }))); | |||
| 357 | + | await viewport.dispatchEvent('wheel', { deltaX: 1, deltaY: 2, deltaMode: 0, clientX: box.x + 1, clientY: box.y + 1 }); | |||
| 358 | + | await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'resize'))).toBeTruthy(); | |||
| 359 | + | await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'pointer' && event.action === 'move'))).toBeTruthy(); | |||
| 360 | + | ||||
| 361 | + | const events = await page.evaluate(() => globalThis.events); | |||
| 362 | + | expect(compositionValue).toBe('e'); | |||
| 363 | + | expect(events.filter(event => event.type === 'text' && event.text === 'é')).toHaveLength(1); | |||
| 364 | + | expect(events.some(event => event.type === 'paste' && event.text === 'paste')).toBeTruthy(); | |||
| 365 | + | expect(events.some(event => event.type === 'pointer' && event.action === 'down')).toBeTruthy(); | |||
| 366 | + | expect(pointerMoveAccepted).toBeFalsy(); | |||
| 367 | + | expect(events.some(event => event.type === 'wheel' && event.row === 0 && event.column === 0)).toBeTruthy(); | |||
| 368 | + | expect(events.some(event => event.type === 'focus' && event.focused)).toBeTruthy(); | |||
| 369 | + | }); | |||
| 370 | + | ||||
| 371 | + | test('applies dynamic terminal styles under a self-only CSP', async ({ page }) => { | |||
| 372 | + | const violations = []; | |||
| 373 | + | page.on('console', message => { | |||
| 374 | + | if (message.text().includes('Content Security Policy')) violations.push(message.text()); | |||
| 375 | + | }); | |||
| 376 | + | const [css, script] = await Promise.all([ | |||
| 377 | + | fs.readFile(path.join(root, 'html/terminal.css'), 'utf8'), | |||
| 378 | + | fs.readFile(path.join(root, 'html/browser/terminal.js'), 'utf8') | |||
| 379 | + | ]); | |||
| 380 | + | await page.route('http://vev.test/**', async route => { | |||
| 381 | + | const pathname = new URL(route.request().url()).pathname; | |||
| 382 | + | if (pathname === '/terminal.css') return route.fulfill({ contentType: 'text/css', body: css }); | |||
| 383 | + | if (pathname === '/terminal.js') return route.fulfill({ contentType: 'text/javascript', body: script }); | |||
| 384 | + | return route.fulfill({ | |||
| 385 | + | contentType: 'text/html', | |||
| 386 | + | headers: { 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'" }, | |||
| 387 | + | body: '<link rel="stylesheet" href="/terminal.css"><div id="terminal"></div><script src="/terminal.js"></script>' | |||
| 388 | + | }); | |||
| 389 | + | }); | |||
| 390 | + | await page.goto('http://vev.test/'); | |||
| 391 | + | await page.waitForFunction(() => globalThis.VevTerminal); | |||
| 392 | + | await page.evaluate(() => { | |||
| 393 | + | globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'CSP terminal' }); | |||
| 394 | + | terminal.apply({ | |||
| 395 | + | schemaVersion: 1, width: 1, height: 1, snapshot: true, | |||
| 396 | + | styles: [{ | |||
| 397 | + | foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } }, | |||
| 398 | + | background: { kind: 1, index: 4 }, | |||
| 399 | + | underlineColor: { kind: 0 } | |||
| 400 | + | }], | |||
| 401 | + | rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }], | |||
| 402 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |||
| 403 | + | }); | |||
| 404 | + | }); | |||
| 405 | + | await expect(page.locator('.vev-terminal__cell')).toHaveCSS('color', 'rgb(1, 2, 3)'); | |||
| 406 | + | expect(violations).toEqual([]); | |||
| 407 | + | }); | |||
| 408 | + | ||||
| 409 | + | test('restores host attributes and theme properties on destroy', async ({ page }) => { | |||
| 410 | + | await page.setContent('<div id="terminal" role="region" aria-label="Host label" style="--vev-fg:rgb(9 8 7)"></div>'); | |||
| 411 | + | await page.addStyleTag({ path: path.join(root, 'html/terminal.css') }); | |||
| 412 | + | await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') }); | |||
| 413 | + | await page.evaluate(() => { | |||
| 414 | + | const instance = VevTerminal.mount(document.querySelector('#terminal'), { label: 'Mounted label' }); | |||
| 415 | + | const color = { r: 1, g: 2, b: 3 }; | |||
| 416 | + | instance.setTheme({ | |||
| 417 | + | foreground: color, background: color, cursor: color, selection: color, | |||
| 418 | + | selectionText: color, palette: Array.from({ length: 16 }, () => color) | |||
| 419 | + | }); | |||
| 420 | + | instance.destroy(); | |||
| 421 | + | }); | |||
| 422 | + | await expect(page.locator('#terminal')).toHaveAttribute('role', 'region'); | |||
| 423 | + | await expect(page.locator('#terminal')).toHaveAttribute('aria-label', 'Host label'); | |||
| 424 | + | expect(await page.locator('#terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toBe('rgb(9 8 7)'); | |||
| 425 | + | }); | |||
| 426 | + | ||||
| 427 | + | test('updates complete rows, applies typed themes, and destroys owned state', async ({ page }) => { | |||
| 428 | + | await mount(page); | |||
| 429 | + | const style = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }; | |||
| 430 | + | await page.evaluate(value => terminal.apply(value), { | |||
| 431 | + | schemaVersion: 1, width: 1, height: 1, snapshot: true, styles: [style], | |||
| 432 | + | rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }], | |||
| 433 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |||
| 434 | + | }); | |||
| 435 | + | await page.evaluate(value => terminal.apply(value), { | |||
| 436 | + | schemaVersion: 1, width: 1, height: 1, snapshot: false, styles: [style], | |||
| 437 | + | rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'B', style: 0 }] }], | |||
| 438 | + | cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false } | |||
| 439 | + | }); | |||
| 440 | + | await expect(page.locator('.vev-terminal__accessible-output')).toHaveText('B'); | |||
| 441 | + | ||||
| 442 | + | const color = { r: 1, g: 2, b: 3 }; | |||
| 443 | + | await page.evaluate(value => terminal.setTheme(value), { | |||
| 444 | + | foreground: color, background: color, cursor: color, selection: color, | |||
| 445 | + | selectionText: color, palette: Array.from({ length: 16 }, () => color) | |||
| 446 | + | }); | |||
| 447 | + | expect(await page.locator('.vev-terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toContain('1 2 3'); | |||
| 448 | + | ||||
| 449 | + | await page.evaluate(() => terminal.destroy()); | |||
| 450 | + | await expect(page.locator('.vev-terminal__viewport')).toHaveCount(0); | |||
| 451 | + | await expect(page.locator('#terminal')).not.toHaveClass(/vev-terminal/); | |||
| 452 | + | }); | |||
A internal/htmlharness/testdata/snapshot.json +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | { | |
| 2 | + | "schemaVersion": 1, | |
| 3 | + | "width": 3, | |
| 4 | + | "height": 1, | |
| 5 | + | "snapshot": true, | |
| 6 | + | "rows": [ | |
| 7 | + | { | |
| 8 | + | "row": 0, | |
| 9 | + | "cells": [ | |
| 10 | + | { "column": 0, "width": 1, "text": "A", "style": 0 }, | |
| 11 | + | { "column": 1, "width": 2, "text": "界", "style": 0 } | |
| 12 | + | ] | |
| 13 | + | } | |
| 14 | + | ], | |
| 15 | + | "styles": [ | |
| 16 | + | { | |
| 17 | + | "foreground": { "kind": 0 }, | |
| 18 | + | "background": { "kind": 0 }, | |
| 19 | + | "underlineColor": { "kind": 0 } | |
| 20 | + | } | |
| 21 | + | ], | |
| 22 | + | "cursor": { | |
| 23 | + | "row": 0, | |
| 24 | + | "column": 1, | |
| 25 | + | "visible": true, | |
| 26 | + | "style": 3, | |
| 27 | + | "styleSet": true | |
| 28 | + | } | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | { | |||
| 2 | + | "schemaVersion": 1, | |||
| 3 | + | "width": 3, | |||
| 4 | + | "height": 1, | |||
| 5 | + | "snapshot": true, | |||
| 6 | + | "rows": [ | |||
| 7 | + | { | |||
| 8 | + | "row": 0, | |||
| 9 | + | "cells": [ | |||
| 10 | + | { "column": 0, "width": 1, "text": "A", "style": 0 }, | |||
| 11 | + | { "column": 1, "width": 2, "text": "界", "style": 0 } | |||
| 12 | + | ] | |||
| 13 | + | } | |||
| 14 | + | ], | |||
| 15 | + | "styles": [ | |||
| 16 | + | { | |||
| 17 | + | "foreground": { "kind": 0 }, | |||
| 18 | + | "background": { "kind": 0 }, | |||
| 19 | + | "underlineColor": { "kind": 0 } | |||
| 20 | + | } | |||
| 21 | + | ], | |||
| 22 | + | "cursor": { | |||
| 23 | + | "row": 0, | |||
| 24 | + | "column": 1, | |||
| 25 | + | "visible": true, | |||
| 26 | + | "style": 3, | |||
| 27 | + | "styleSet": true | |||
| 28 | + | } | |||
| 29 | + | } | |||
A package-lock.json +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "vev-vt-browser-tests", | |
| 3 | + | "lockfileVersion": 3, | |
| 4 | + | "requires": true, | |
| 5 | + | "packages": { | |
| 6 | + | "": { | |
| 7 | + | "name": "vev-vt-browser-tests", | |
| 8 | + | "devDependencies": { | |
| 9 | + | "@playwright/test": "1.62.1" | |
| 10 | + | } | |
| 11 | + | }, | |
| 12 | + | "node_modules/@playwright/test": { | |
| 13 | + | "version": "1.62.1", | |
| 14 | + | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", | |
| 15 | + | "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", | |
| 16 | + | "dev": true, | |
| 17 | + | "license": "Apache-2.0", | |
| 18 | + | "dependencies": { | |
| 19 | + | "playwright": "1.62.1" | |
| 20 | + | }, | |
| 21 | + | "bin": { | |
| 22 | + | "playwright": "cli.js" | |
| 23 | + | }, | |
| 24 | + | "engines": { | |
| 25 | + | "node": ">=20" | |
| 26 | + | } | |
| 27 | + | }, | |
| 28 | + | "node_modules/fsevents": { | |
| 29 | + | "version": "2.3.2", | |
| 30 | + | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", | |
| 31 | + | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", | |
| 32 | + | "dev": true, | |
| 33 | + | "hasInstallScript": true, | |
| 34 | + | "license": "MIT", | |
| 35 | + | "optional": true, | |
| 36 | + | "os": [ | |
| 37 | + | "darwin" | |
| 38 | + | ], | |
| 39 | + | "engines": { | |
| 40 | + | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | |
| 41 | + | } | |
| 42 | + | }, | |
| 43 | + | "node_modules/playwright": { | |
| 44 | + | "version": "1.62.1", | |
| 45 | + | "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", | |
| 46 | + | "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", | |
| 47 | + | "dev": true, | |
| 48 | + | "license": "Apache-2.0", | |
| 49 | + | "dependencies": { | |
| 50 | + | "playwright-core": "1.62.1" | |
| 51 | + | }, | |
| 52 | + | "bin": { | |
| 53 | + | "playwright": "cli.js" | |
| 54 | + | }, | |
| 55 | + | "engines": { | |
| 56 | + | "node": ">=20" | |
| 57 | + | }, | |
| 58 | + | "optionalDependencies": { | |
| 59 | + | "fsevents": "2.3.2" | |
| 60 | + | } | |
| 61 | + | }, | |
| 62 | + | "node_modules/playwright-core": { | |
| 63 | + | "version": "1.62.1", | |
| 64 | + | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", | |
| 65 | + | "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", | |
| 66 | + | "dev": true, | |
| 67 | + | "license": "Apache-2.0", | |
| 68 | + | "bin": { | |
| 69 | + | "playwright-core": "cli.js" | |
| 70 | + | }, | |
| 71 | + | "engines": { | |
| 72 | + | "node": ">=20" | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| @@ -0,0 +1,76 @@ | |||||
| 1 | + | { | |||
| 2 | + | "name": "vev-vt-browser-tests", | |||
| 3 | + | "lockfileVersion": 3, | |||
| 4 | + | "requires": true, | |||
| 5 | + | "packages": { | |||
| 6 | + | "": { | |||
| 7 | + | "name": "vev-vt-browser-tests", | |||
| 8 | + | "devDependencies": { | |||
| 9 | + | "@playwright/test": "1.62.1" | |||
| 10 | + | } | |||
| 11 | + | }, | |||
| 12 | + | "node_modules/@playwright/test": { | |||
| 13 | + | "version": "1.62.1", | |||
| 14 | + | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", | |||
| 15 | + | "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", | |||
| 16 | + | "dev": true, | |||
| 17 | + | "license": "Apache-2.0", | |||
| 18 | + | "dependencies": { | |||
| 19 | + | "playwright": "1.62.1" | |||
| 20 | + | }, | |||
| 21 | + | "bin": { | |||
| 22 | + | "playwright": "cli.js" | |||
| 23 | + | }, | |||
| 24 | + | "engines": { | |||
| 25 | + | "node": ">=20" | |||
| 26 | + | } | |||
| 27 | + | }, | |||
| 28 | + | "node_modules/fsevents": { | |||
| 29 | + | "version": "2.3.2", | |||
| 30 | + | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", | |||
| 31 | + | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", | |||
| 32 | + | "dev": true, | |||
| 33 | + | "hasInstallScript": true, | |||
| 34 | + | "license": "MIT", | |||
| 35 | + | "optional": true, | |||
| 36 | + | "os": [ | |||
| 37 | + | "darwin" | |||
| 38 | + | ], | |||
| 39 | + | "engines": { | |||
| 40 | + | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | |||
| 41 | + | } | |||
| 42 | + | }, | |||
| 43 | + | "node_modules/playwright": { | |||
| 44 | + | "version": "1.62.1", | |||
| 45 | + | "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", | |||
| 46 | + | "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", | |||
| 47 | + | "dev": true, | |||
| 48 | + | "license": "Apache-2.0", | |||
| 49 | + | "dependencies": { | |||
| 50 | + | "playwright-core": "1.62.1" | |||
| 51 | + | }, | |||
| 52 | + | "bin": { | |||
| 53 | + | "playwright": "cli.js" | |||
| 54 | + | }, | |||
| 55 | + | "engines": { | |||
| 56 | + | "node": ">=20" | |||
| 57 | + | }, | |||
| 58 | + | "optionalDependencies": { | |||
| 59 | + | "fsevents": "2.3.2" | |||
| 60 | + | } | |||
| 61 | + | }, | |||
| 62 | + | "node_modules/playwright-core": { | |||
| 63 | + | "version": "1.62.1", | |||
| 64 | + | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", | |||
| 65 | + | "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", | |||
| 66 | + | "dev": true, | |||
| 67 | + | "license": "Apache-2.0", | |||
| 68 | + | "bin": { | |||
| 69 | + | "playwright-core": "cli.js" | |||
| 70 | + | }, | |||
| 71 | + | "engines": { | |||
| 72 | + | "node": ">=20" | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | } | |||
A package.json +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "vev-vt-browser-tests", | |
| 3 | + | "private": true, | |
| 4 | + | "type": "module", | |
| 5 | + | "scripts": { | |
| 6 | + | "test:browser": "npm run test:browser:functional && npm run test:browser:performance", | |
| 7 | + | "test:browser:functional": "PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/browser-functional.json node node_modules/@playwright/test/cli.js test internal/htmlharness/terminal.spec.js", | |
| 8 | + | "test:browser:performance": "PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/browser-performance.json node node_modules/@playwright/test/cli.js test internal/htmlharness/performance.spec.js --project=chromium --workers=1", | |
| 9 | + | "test:browser:install": "playwright install chromium firefox webkit", | |
| 10 | + | "test:browser:docker": "sh -c 'set -eu; version=$(node -p \"require(\\\"./package-lock.json\\\").packages[\\\"node_modules/@playwright/test\\\"].version\"); docker run --rm --ipc=host --tmpfs /work/node_modules -v \"$PWD\":/work -w /work mcr.microsoft.com/playwright:v${version}-noble sh -c \"npm ci --ignore-scripts && npm run test:browser\"'" | |
| 11 | + | }, | |
| 12 | + | "devDependencies": { | |
| 13 | + | "@playwright/test": "1.62.1" | |
| 14 | + | } | |
| 15 | + | } | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | { | |||
| 2 | + | "name": "vev-vt-browser-tests", | |||
| 3 | + | "private": true, | |||
| 4 | + | "type": "module", | |||
| 5 | + | "scripts": { | |||
| 6 | + | "test:browser": "npm run test:browser:functional && npm run test:browser:performance", | |||
| 7 | + | "test:browser:functional": "PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/browser-functional.json node node_modules/@playwright/test/cli.js test internal/htmlharness/terminal.spec.js", | |||
| 8 | + | "test:browser:performance": "PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/browser-performance.json node node_modules/@playwright/test/cli.js test internal/htmlharness/performance.spec.js --project=chromium --workers=1", | |||
| 9 | + | "test:browser:install": "playwright install chromium firefox webkit", | |||
| 10 | + | "test:browser:docker": "sh -c 'set -eu; version=$(node -p \"require(\\\"./package-lock.json\\\").packages[\\\"node_modules/@playwright/test\\\"].version\"); docker run --rm --ipc=host --tmpfs /work/node_modules -v \"$PWD\":/work -w /work mcr.microsoft.com/playwright:v${version}-noble sh -c \"npm ci --ignore-scripts && npm run test:browser\"'" | |||
| 11 | + | }, | |||
| 12 | + | "devDependencies": { | |||
| 13 | + | "@playwright/test": "1.62.1" | |||
| 14 | + | } | |||
| 15 | + | } | |||
A playwright.config.js +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | import { defineConfig, devices } from '@playwright/test'; | |
| 2 | + | ||
| 3 | + | const evidenceFile = process.env.PLAYWRIGHT_JSON_OUTPUT_NAME || 'test-results/browser-evidence.json'; | |
| 4 | + | ||
| 5 | + | export default defineConfig({ | |
| 6 | + | testDir: './internal/htmlharness', | |
| 7 | + | timeout: 30_000, | |
| 8 | + | fullyParallel: true, | |
| 9 | + | reporter: [['list'], ['json', { outputFile: evidenceFile }]], | |
| 10 | + | use: { | |
| 11 | + | headless: true, | |
| 12 | + | viewport: { width: 1280, height: 720 }, | |
| 13 | + | trace: 'retain-on-failure' | |
| 14 | + | }, | |
| 15 | + | projects: [ | |
| 16 | + | { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, | |
| 17 | + | { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, | |
| 18 | + | { name: 'webkit', use: { ...devices['Desktop Safari'] } } | |
| 19 | + | ] | |
| 20 | + | }); | |
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | import { defineConfig, devices } from '@playwright/test'; | |||
| 2 | + | ||||
| 3 | + | const evidenceFile = process.env.PLAYWRIGHT_JSON_OUTPUT_NAME || 'test-results/browser-evidence.json'; | |||
| 4 | + | ||||
| 5 | + | export default defineConfig({ | |||
| 6 | + | testDir: './internal/htmlharness', | |||
| 7 | + | timeout: 30_000, | |||
| 8 | + | fullyParallel: true, | |||
| 9 | + | reporter: [['list'], ['json', { outputFile: evidenceFile }]], | |||
| 10 | + | use: { | |||
| 11 | + | headless: true, | |||
| 12 | + | viewport: { width: 1280, height: 720 }, | |||
| 13 | + | trace: 'retain-on-failure' | |||
| 14 | + | }, | |||
| 15 | + | projects: [ | |||
| 16 | + | { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, | |||
| 17 | + | { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, | |||
| 18 | + | { name: 'webkit', use: { ...devices['Desktop Safari'] } } | |||
| 19 | + | ] | |||
| 20 | + | }); | |||