diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd1c5b20..91376a02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,9 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - node: ["22.19.0", "24", "26"] + # validate already runs every suite and install-checks the package on + # Node 22.19.0 / Linux. Only add distinct runtime environments here. + node: ["24", "26"] include: - os: macos-latest node: "24" @@ -121,8 +123,11 @@ jobs: bun scripts/check-runtime.ts bun scripts/smoke-package.ts - - name: Run runtime integration tests - run: bun run test:runtime + # Business-logic regressions run once in validate. Exercise the boundaries + # affected by Node/OS differences here: launch, HTTP streaming, app-server + # auth, both terminal modes, and the real SQLite driver below. + - name: Run Node and OS compatibility tests + run: bun run test:runtime:compat - name: Run real Node SQLite concurrency tests run: bun run test:node diff --git a/bun.lock b/bun.lock index dc266bb5..32f92deb 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "zcode-app-cli", "dependencies": { - "@earendil-works/pi-tui": "^0.85.1", + "@earendil-works/pi-tui": "^1.3.0", "playwright-core": "1.59.1", }, "devDependencies": { @@ -15,7 +15,7 @@ "bun-types": "1.4.1", "cli-highlight": "^2.1.11", "diff": "^9.0.0", - "just-bash": "^3.4.2", + "just-bash": "^3.6.0", "mountx": "^0.0.2", "msw": "^2.15.0", "pkg-pr-new": "0.0.88", @@ -25,10 +25,16 @@ }, }, }, + "patchedDependencies": { + "sprintf-js@1.1.3": "patches/sprintf-js@1.1.3.patch", + }, + "overrides": { + "undici": "7.30.0", + }, "packages": { "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.85.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-OIzw9efInmO4WOBnD4TxcTdBjmzvYJpzslkgoUro946nEGoYWg5rwv1p4fDt3/JvMx9QybryUCUwlm7j8Dreig=="], + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@1.3.0", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-AlKvEwEWyVtnuw7SVb4N/mUhlfIohyFslUNWarZVdLHHSdt3ca5EhMLwTzblBpDpaofSz+f+tAHie6CfxVrWKA=="], "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], @@ -46,16 +52,6 @@ "@inquirer/type": ["@inquirer/type@4.1.1", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A=="], - "@jitl/quickjs-ffi-types": ["@jitl/quickjs-ffi-types@0.32.0", "", {}, "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg=="], - - "@jitl/quickjs-wasmfile-debug-asyncify": ["@jitl/quickjs-wasmfile-debug-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw=="], - - "@jitl/quickjs-wasmfile-debug-sync": ["@jitl/quickjs-wasmfile-debug-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q=="], - - "@jitl/quickjs-wasmfile-release-asyncify": ["@jitl/quickjs-wasmfile-release-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q=="], - - "@jitl/quickjs-wasmfile-release-sync": ["@jitl/quickjs-wasmfile-release-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg=="], - "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], "@mongodb-js/zstd": ["@mongodb-js/zstd@7.0.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "prebuild-install": "^7.1.3" } }, "sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA=="], @@ -220,16 +216,12 @@ "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], - "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], @@ -308,6 +300,8 @@ "graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], + "guarded-fetch": ["guarded-fetch@0.1.3", "", { "dependencies": { "ipaddr.js": "2.2.0", "undici": "7.29.0" } }, "sha512-PSaNyJk8VJlUVEhMHPXT/Z/WgNrLBh43WaYQJiu1qvyCuPQA+kgFMNmAOxI3GU3uXrCkO/xRW11XjcswrksYOA=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="], @@ -324,20 +318,20 @@ "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], - "just-bash": ["just-bash@3.4.2", "", { "dependencies": { "diff": "^8.0.4", "fast-xml-parser": "^5.10.1", "file-type": "^21.3.4", "ini": "^6.0.0", "minimatch": "^10.2.6", "modern-tar": "^0.7.7", "papaparse": "^5.6.0", "quickjs-emscripten": "^0.32.0", "re2js": "^1.3.3", "seek-bzip": "^2.0.0", "smol-toml": "^1.8.0", "sprintf-js": "^1.1.3", "sql.js": "^1.14.1", "turndown": "^7.2.4", "undici": "^7.29.0", "yaml": "^2.9.0" }, "optionalDependencies": { "@mongodb-js/zstd": "^7.0.0", "node-liblzma": "^2.2.0" }, "bin": { "just-bash": "dist/bin/just-bash.js", "just-bash-shell": "dist/bin/shell/shell.js" } }, "sha512-T0Vpy7YRgCjxJdqG3tkxn0ZnIDLJvVwb8hH4L+6NVdp+Te27jQxjxnszW9ODjEKbWxWujj83rP5S0GQxCSufgg=="], + "just-bash": ["just-bash@3.6.0", "", { "dependencies": { "diff": "^8.0.4", "fast-xml-parser": "^5.10.1", "file-type": "^21.3.4", "guarded-fetch": "0.1.3", "ini": "^6.0.0", "modern-tar": "^0.7.7", "papaparse": "^5.6.0", "re2js": "^1.3.3", "run": "2.1.3", "seek-bzip": "^2.0.0", "smol-toml": "^1.9.0", "sprintf-js": "^1.1.3", "sql.js": "^1.14.1", "turndown": "^7.2.4", "typescript": "^5.9.3", "yaml": "^2.9.0" }, "optionalDependencies": { "@mongodb-js/zstd": "^7.0.0", "node-liblzma": "^2.2.0" }, "bin": { "just-bash": "dist/bin/just-bash.js", "just-bash-shell": "dist/bin/shell/shell.js" } }, "sha512-2COjEgcHdqKXeQSiNfPAHmpwQx4S8wATaIwog83g2t645U5tom6FED4CBBdr3M+0ioX4xZ26eODw3/nD6RQ6xw=="], - "marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="], "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], @@ -396,10 +390,6 @@ "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "quickjs-emscripten": ["quickjs-emscripten@0.32.0", "", { "dependencies": { "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", "@jitl/quickjs-wasmfile-release-sync": "0.32.0", "quickjs-emscripten-core": "0.32.0" } }, "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA=="], - - "quickjs-emscripten-core": ["quickjs-emscripten-core@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg=="], - "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], "re2js": ["re2js@1.4.0", "", {}, "sha512-KTOIcZTSOpOxbu3i0+T6mFQ6tkxXKlTxfcMFs1trQbsMnG84qNq+DjXr8Afu+FEFjvF1NNlldpC7roPyazFI8g=="], @@ -416,6 +406,8 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.27.9", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.3", "yuku-ast": "^0.1.7", "yuku-codegen": "^0.6.1", "yuku-parser": "^0.6.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": "*", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-d54yt65+ZF/Mk8H6P36As02PAMdaiWRSzVNtJRc1h7nCgUFjuRI4cN2DyTfJyfVpPH6pgy7/2D7YQH1/Rh75Yg=="], + "run": ["run@2.1.3", "", { "peerDependencies": { "typescript": ">=5.1.6 <7" }, "optionalPeers": ["typescript"] }, "sha512-KUurW05zgjtZ2su0cBURC1q2RG/Uq0uzV3qURDsXqiKh03p9IXABQK4Qem56cFMqjT8TdQ2iTWsePrMprQWIRA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "seek-bzip": ["seek-bzip@2.0.0", "", { "dependencies": { "commander": "^6.0.0" }, "bin": { "seek-bunzip": "bin/seek-bunzip", "seek-table": "bin/seek-bzip-table" } }, "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg=="], @@ -430,7 +422,7 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "smol-toml": ["smol-toml@1.9.1", "", {}, "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ=="], "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], @@ -494,7 +486,7 @@ "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], - "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], + "undici": ["undici@7.30.0", "", {}, "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], @@ -526,6 +518,8 @@ "just-bash/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "just-bash/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "msw/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f3b115b5..1a1b705b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -200,8 +200,11 @@ Plugin controls remain under `plugins`, hook declarations under These paths distinguish the packaged CLI from the upstream application. The source references below are pinned to upstream [`29628c9` / `v3.14.3`](https://github.com/zai-org/ZCode/tree/29628c9acdb81b703bbd4080c207a0e7ce5e276e). -This package's runtime lock selects the separate Desktop 3.15.1 artifact; the +This package's runtime lock selects the separate Desktop 3.15.3 artifact; the public source snapshot is not an assertion of that artifact's build commit. +The configuration boundaries were rechecked on 2026-10-16 against +[`aac4755` / `v3.15.1`](https://github.com/zai-org/ZCode/tree/aac4755666d09fdcd70272fcf063c077a639015f). +The pinned links below retain the original audit references. | Client / source | User MCP file | JSON path | | --- | --- | --- | diff --git a/docs/CONFIGURATION.zh-CN.md b/docs/CONFIGURATION.zh-CN.md index 825a4a9f..c09b17d1 100644 --- a/docs/CONFIGURATION.zh-CN.md +++ b/docs/CONFIGURATION.zh-CN.md @@ -173,8 +173,11 @@ MCP 服务器按名称合并。当前 runtime 中,**同名服务器的用户 下面区分本发行包与上游应用。源码引用固定在上游 [`29628c9` / `v3.14.3`](https://github.com/zai-org/ZCode/tree/29628c9acdb81b703bbd4080c207a0e7ce5e276e)。 -本项目 runtime lock 选择的是独立发布的 Desktop 3.15.1 产物,不能据此认定该产物 +本项目 runtime lock 选择的是独立发布的 Desktop 3.15.3 产物,不能据此认定该产物 由这个公开源码提交构建。 +2026-10-16 已针对 +[`aac4755` / `v3.15.1`](https://github.com/zai-org/ZCode/tree/aac4755666d09fdcd70272fcf063c077a639015f) +复核配置边界;下方固定链接保留原始审计出处。 | 客户端/配置源 | 用户级 MCP 文件 | JSON 路径 | | --- | --- | --- | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d0695baf..27069170 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -5,8 +5,10 @@ For installation as an end user, see the [main README](../README.md). ## Prerequisites -Developing or publishing from source requires Bun 1.3 or newer. `7z` is needed -only when downloading and extracting a remote installer. +Use Bun 1.4.1, the version pinned in `packageManager` and CI, for development +and publishing. Older Bun releases are not part of the validated toolchain. +The CLI and SQLite tests require Node >=22.19.0. `7z` is needed only when +downloading and extracting a remote installer. ## Quick start @@ -40,11 +42,20 @@ Run all validation layers: ```bash bun run typecheck -bun test +bun run test:all bun run check bun run check:tui ``` +For a fast feedback loop, use `bun run test:unit`. `test:all` runs the unit, +TUI, runtime and real Node SQLite suites in order; `bun run test:node` runs +the storage layer on its own. Bare `bun test` also discovers the Node-only +tests and is not a substitute for these entry points. + +See [TUI scenario testing](./TUI_SCENARIO_TESTING.md) for fixture and PTY +guidance, and [SQLite concurrency](./SQLITE_CONCURRENCY.md) for storage retry +boundaries and real Node regression coverage. + `check:tui` runs real-PTY scenarios. The official runtime scenario completes masked Coding Plan API-key setup in a temporary home, verifies the official config output, executes `/help`, switches to plan mode, exits, and checks that @@ -58,6 +69,28 @@ Ctrl+C cancellation remain responsive during rapid Bash progress output. The scenarios advance from observed terminal output instead of fixed timers and do not make model API calls. +Dependency security fixes and their regression commands are documented in +[`patches/README.md`](../patches/README.md). Source installs must use Bun so +the recorded `sprintf-js` patch is applied. + +## pi-tui compatibility + +The root package and TUI adapter use pi-tui 1.3.0. Theme detection uses +`queryTerminalColors()` with the scheme-report fallback. Prompt placeholders +and terminal-focus handling recognize pi-tui's fake-cursor APC markers; +the hardware cursor remains the only caret on the focused input. The +`ProcessTerminal` subclass inherits the new `setProgramStatus()` contract. +PTY trace checks share `scripts/terminal-output.ts`, which handles both BEL +and ST control-string terminators without consuming subsequent visible text. + +After changing pi-tui, `bun run check:tui` verifies regular/fullscreen input, +focus, search, selection, images and overlays. `bun run verify:tui-perf` runs +the complete quality and performance gate, including those smoke checks. +The three runtime patches +`http-no-content`, `bigmodel-oauth` and `cli-help-contract` also remain in the +patch plan: Desktop 3.15.3 already supplies their behavior, while their +verification and legacy branches still support older local Desktop artifacts. + ## TUI startup compatibility Recent upstream runtimes pass session metadata and slash commands through @@ -267,6 +300,17 @@ instead. `.github/workflows/ci.yml` runs for pull requests, pushes to `main` and manual dispatches. It validates the project on the minimum supported Node.js 22.19, including the locked runtime build, TypeScript and unit tests, PTY scenarios, -the reviewed npm tarball and an isolated installed-package smoke test. A newer -commit to the same pull request or branch automatically cancels its superseded -CI run; unrelated pull requests continue independently. +the reviewed npm tarball and an isolated installed-package smoke test. +The complete runtime suite runs once in that job. Additional Node 24/26 Linux +and Node 24 macOS jobs reuse the same tarball and run `test:runtime:compat`, +the installed-package checks and the real Node SQLite suite. The compatibility +subset covers launch, HTTP streaming/retry, app-server authentication and both +TUI modes; it does not repeat the entire model/configuration/workflow matrix. + +`check:tui:focus` uses three representative configurations: regular with +notifications off, fullscreen with unfocused BEL notifications, and the built +fullscreen adapter with notifications off. Backend selection is also covered +by the notification unit tests. + +A newer commit to the same pull request or branch automatically cancels its +superseded CI run; unrelated pull requests continue independently. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 21979020..440a8654 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -27,9 +27,11 @@ bun run sync:locked The synchronization command: -1. reads the public stable-channel manifest used by ZCode Desktop, with the - static CDN manifest as a fallback only when the service response is - unavailable or invalid; +1. reads the public stable-channel manifest used by ZCode Desktop; if the + service is unavailable or its response is invalid, synchronization stops + with the original error and instructions to retry or explicitly use + `bun run sync:locked`. The obsolete static CDN manifests are no longer used, + and rebuilding from the lock does not check for a newer version; 2. downloads the matching installer; 3. verifies its SHA-512 from the manifest; 4. extracts `resources/glm`; @@ -55,7 +57,7 @@ It contains only: their original licenses) and the compiled local `@zcode/tui` adapter; - `setting.example.json`, `provider.example.json` and `zcode-runtime.lock.json`; - the English and Simplified Chinese configuration guides and provider field - references in `docs/`; + references in `docs/`, plus `docs/HOST_INTEGRATION.md`; - `docs/THIRD_PARTY_CONTENT.md` and the license texts, upstream notices and source provenance in `LICENSES/`; - `README.md`, `LICENSE` and the required npm `package.json`. diff --git a/docs/SQLITE_CONCURRENCY.md b/docs/SQLITE_CONCURRENCY.md index fac4ebe2..96b17320 100644 --- a/docs/SQLITE_CONCURRENCY.md +++ b/docs/SQLITE_CONCURRENCY.md @@ -38,7 +38,12 @@ capability through this bridge. Cross-process contention is coordinated by SQLite and bounded backoff, not by the process-local queue. The operation allowlist and maintenance SQL were reviewed against upstream -`zai-org/ZCode` commit `29628c9acdb81b703bbd4080c207a0e7ce5e276e`. The required +`zai-org/ZCode` commit `29628c9acdb81b703bbd4080c207a0e7ce5e276e`. +On 2026-10-16 the store implementation was rechecked against +`aac4755666d09fdcd70272fcf063c077a639015f` (public 3.15.1); the +`sqlite-session-store.ts` facade is unchanged between those revisions. These +source references do not identify the separate Desktop artifact's build commit. +The required `sqlite-write-recovery` patch validates both post-migration hooks and the native cleanup transaction. Missing, ambiguous or partially patched anchors stop synchronization. The implementation is in `src/runtime-sqlite-recovery.ts`, @@ -108,13 +113,17 @@ Lock holders use independent processes. One test schedules their release from the writer's event loop to verify that the new recovery yields instead of blocking that timer. Each test creates its own database and holder. -CI builds and packs once, then tests that exact artifact on Node 22.19.0, 24, and -26 on Linux, plus Node 24 on macOS. Bun's `node:sqlite` compatibility implementation +CI runs the full suite on Node 22.19.0 / Linux, then packs that validated runtime. +Additional Node 24/26 Linux and Node 24 macOS jobs reuse the tarball for the +compatibility subset and this SQLite suite. The minimum-version environment +is not repeated in the matrix. Bun's `node:sqlite` compatibility implementation is not used as a substitute for Node's SQLite driver. ## Follow-up boundary -The regressions use the locked Desktop 3.14.3 / runtime 0.16.9 artifact. They do +The current lock selects Desktop 3.15.3 / runtime 0.16.9. Rebuild with +`sync:locked` before release validation; `sync:local` records a local App source +and does not establish the locked installer's provenance. The regressions do not establish that every production workload is free of contention. Keep #162 open for validation on the reported workload. On recovery exhaustion, the error includes `operation`, `attempts`, `elapsedMs` and the original SQLite cause; diff --git a/docs/TUI_SCENARIO_TESTING.md b/docs/TUI_SCENARIO_TESTING.md index 233ffde1..0352bd28 100644 --- a/docs/TUI_SCENARIO_TESTING.md +++ b/docs/TUI_SCENARIO_TESTING.md @@ -6,7 +6,7 @@ can be run by CI or opened interactively for visual inspection. ## Test groups -The project has four execution layers based on cost and environment: +Tests are grouped by execution cost and environment: ```bash # In-process application and pure unit tests. @@ -27,13 +27,20 @@ bun run test:tui # Tests that require vendor/zcode.cjs, Node, sockets, or local networking. bun run test:runtime -# Run the portable layers and runtime integration in order. +# Node/OS compatibility subset used by the additional CI matrix jobs. +bun run test:runtime:compat + +# Storage regressions using real Node SQLite. +bun run test:node + +# Run the portable layers, runtime integration and Node SQLite tests in order. bun run test:all ``` `bun run test` intentionally aliases the unit group for normal development. -The built-in `bun test` command still discovers every `*.test.ts` recursively, -so it behaves like the complete suite and requires a synchronized runtime. +The built-in `bun test` command also discovers the Node-only `.test.cjs` files +and runs them with the wrong SQLite implementation. Use `bun run test:all` +for the complete suite; runtime and Node tests require a synchronized runtime. Run `bun run sync:local` first when testing against an installed macOS App. In a Delta worktree, use `TMPDIR=/tmp bun run test:runtime` to keep Unix socket paths below the platform limit. @@ -91,7 +98,8 @@ behavior without depending on the host terminal's focus-report support. Then type a draft without submitting it and switch to another application or terminal pane while keeping the original pane visible. Both hardware and drawn -carets should disappear and return when you refocus the original pane. Enter +carets should disappear; the focused input's hardware cursor should return when +you refocus the original pane. Enter `/focus-status` to inspect the real focus-report count and last reported state; simulated events are excluded. An `unknown` state means no real report has arrived, so the TUI deliberately retains the caret. A passing simulated cycle @@ -225,5 +233,6 @@ container/VM runtime test instead of weakening the hermetic TUI suite. 2. Add a declaration under `test/tui/scenarios/` with optional baseline files and the automatic interaction. 3. Register it in `test/tui/scenarios/index.ts`. -4. Add a `bun:test` entry when the scenario should run as part of `bun test`. +4. Add a `bun:test` entry and register it in the matching `test:tui` package script + when the scenario should run in the automated suite. 5. Verify both automatic and `--manual` modes before enabling it in CI. diff --git a/package.json b/package.json index d69a2a29..7fa57a27 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "docs/CONFIGURATION.zh-CN.md", "docs/PROVIDER_CONFIG.md", "docs/PROVIDER_CONFIG.zh-CN.md", + "docs/HOST_INTEGRATION.md", "docs/THIRD_PARTY_CONTENT.md", "LICENSES/Apache-2.0.txt", "LICENSES/README.md", @@ -61,14 +62,13 @@ "check": "bun run build && bun scripts/check-runtime.ts", "check:oauth-callback": "bun scripts/smoke-oauth-callback.ts", "check:tui": "bun scripts/smoke-tui.ts && bun scripts/smoke-tui-features.ts && bun scripts/smoke-tui-clear.ts && bun scripts/smoke-tui-session-title.ts && bun scripts/smoke-tui-pressure.ts && bun scripts/smoke-tui-widths.ts && bun scripts/smoke-tui-fullscreen.ts && bun scripts/smoke-tui-fullscreen-switch.ts && bun scripts/smoke-tui-fullscreen-layout.ts && bun run check:tui:focus", - "check:tui:focus": "bun scripts/smoke-tui-focus.ts regular off always && bun scripts/smoke-tui-focus.ts fullscreen off always && bun scripts/smoke-tui-focus.ts regular bel unfocused && bun scripts/smoke-tui-focus.ts fullscreen bel unfocused && bun scripts/smoke-tui-focus.ts fullscreen off always built", + "check:tui:focus": "bun scripts/smoke-tui-focus.ts regular off always && bun scripts/smoke-tui-focus.ts fullscreen bel unfocused && bun scripts/smoke-tui-focus.ts fullscreen off always built", "check:tui:focus:manual": "bun scripts/manual-tui-focus.ts", - "check:tui-scenarios": "bun run test:tui", "test": "bun run test:unit", "test:all": "bun run test:unit && bun run test:tui && bun run test:runtime && bun run test:node", "test:unit": "bun test test/*.test.ts", - "test:fast": "bun run test:unit", "test:runtime": "bun test test/runtime/*.test.ts", + "test:runtime:compat": "bun test test/runtime/launcher.test.ts test/runtime/network-retry.test.ts test/runtime/app-server-auth.test.ts test/runtime/execution-state-tui.test.ts", "test:node": "node --test test/node/*.test.cjs", "test:tui": "bun run test:tui:component && bun run test:tui:e2e", "test:tui:component": "bun test test/tui/scenario-http.test.ts test/tui/scenario-runtime.test.ts test/tui/scenario-shell.test.ts test/tui/scenario-workspace.test.ts test/tui/terminal-screen.test.ts", @@ -93,7 +93,7 @@ "provenance": true }, "dependencies": { - "@earendil-works/pi-tui": "^0.85.1", + "@earendil-works/pi-tui": "^1.3.0", "playwright-core": "1.59.1" }, "devDependencies": { @@ -103,12 +103,18 @@ "bun-types": "1.4.1", "cli-highlight": "^2.1.11", "diff": "^9.0.0", - "just-bash": "^3.4.2", + "just-bash": "^3.6.0", "mountx": "^0.0.2", "msw": "^2.15.0", "pkg-pr-new": "0.0.88", "tsdown": "^0.22.7", "typescript": "^7.0.2", "yaml": "^2.8.1" + }, + "overrides": { + "undici": "7.30.0" + }, + "patchedDependencies": { + "sprintf-js@1.1.3": "patches/sprintf-js@1.1.3.patch" } } diff --git a/packages/zcode-tui/package.json b/packages/zcode-tui/package.json index 14ba35cd..6d5e5017 100644 --- a/packages/zcode-tui/package.json +++ b/packages/zcode-tui/package.json @@ -7,7 +7,7 @@ ".": "./dist/index.js" }, "dependencies": { - "@earendil-works/pi-tui": "^0.85.1" + "@earendil-works/pi-tui": "^1.3.0" }, "license": "MIT", "private": true diff --git a/packages/zcode-tui/src/choice-dialog.ts b/packages/zcode-tui/src/choice-dialog.ts index 0f42901f..b347e76e 100644 --- a/packages/zcode-tui/src/choice-dialog.ts +++ b/packages/zcode-tui/src/choice-dialog.ts @@ -5,6 +5,7 @@ import { Input, isKeyRelease, matchesKey, + renderFakeCursor, SelectList, truncateToWidth, type Component, @@ -616,7 +617,8 @@ class PromptInput extends Input { const lines = super.render(width); if (!value && this.placeholderText && lines[0]) { const placeholder = this.theme.muted(this.placeholderText); - const line = lines[0].replace("\x1b[7m \x1b[27m", `\x1b[7m \x1b[27m${placeholder}`); + const cursor = renderFakeCursor(" "); + const line = lines[0].replace(cursor, `${cursor}${placeholder}`); return [truncateToWidth(line, width, "", true)]; } return lines; @@ -653,7 +655,7 @@ class PromptEditor extends Editor { const lines = super.render(width); if (this.getText() || !this.placeholder) return lines; - const cursor = "\x1b[7m \x1b[0m"; + const cursor = renderFakeCursor(" "); const cursorLine = lines.findIndex((line) => line.includes(cursor)); if (cursorLine >= 0) { lines[cursorLine] = truncateToWidth( diff --git a/packages/zcode-tui/src/color-scheme.ts b/packages/zcode-tui/src/color-scheme.ts index e4b679fb..e3a366a1 100644 --- a/packages/zcode-tui/src/color-scheme.ts +++ b/packages/zcode-tui/src/color-scheme.ts @@ -1,3 +1,5 @@ +import type { TUI } from "@earendil-works/pi-tui"; + export type ZCodeColorScheme = "dark" | "light"; export type ZCodeThemePreference = ZCodeColorScheme | "auto"; @@ -13,6 +15,28 @@ export function colorSchemeFromRgb(color: RgbColor): ZCodeColorScheme { return luminance > 0.5 ? "light" : "dark"; } +/** Query pi-tui's combined palette, retaining the terminal scheme-report fallback. */ +export async function queryTerminalColorScheme( + ui: Pick & { + terminal: Pick; + }, + timeoutMs: number +): Promise { + let reportedScheme: ZCodeColorScheme | undefined; + const unsubscribe = ui.onTerminalColorSchemeChange((scheme) => { + reportedScheme = scheme; + }); + try { + // Ask before the palette query's DA1 sentinel so a scheme-only terminal + // can answer before queryTerminalColors completes. + ui.terminal.write("\x1b[?996n"); + const colors = await ui.queryTerminalColors({ timeoutMs }); + return colors.background ? colorSchemeFromRgb(colors.background) : reportedScheme; + } finally { + unsubscribe(); + } +} + /** * Best-effort synchronous fallback for terminals that expose COLORFGBG. * The last ANSI color is the background; values outside the conventional diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 82539deb..d6648f07 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -56,8 +56,8 @@ import { readBackgroundTaskOutput } from "./background-task-output.ts"; import { BoundedToolText, toolTextValue } from "./bounded-tool-text.ts"; import { choose, promptText, type ChoiceItem } from "./choice-dialog.ts"; import { - colorSchemeFromRgb, initialColorScheme, + queryTerminalColorScheme, themePreference, type ZCodeThemePreference } from "./color-scheme.ts"; @@ -536,9 +536,8 @@ export function suppressTuiAiSdkWarnings(): void { suppressTuiAiSdkWarnings(); /** - * The upstream alternate-screen renderer intentionally reserves Home/End and - * PageUp/PageDown for viewport navigation. ZCode's composer is a real editor, - * so it needs the same priority as a modal overlay while it has focus. + * Give the composer priority for page navigation and modified Home/End keys + * while editing. Bare Home/End are already editor keys in pi-tui 1.x. */ class ZCodeAltScreen extends TuiAltScreen { private viewportInputDeferral?: (data: string) => boolean; @@ -989,11 +988,7 @@ class ZCodeTui { private async resolveTerminalColorScheme(): Promise { if (!this.colorsEnabled || this.themePreference !== "auto") return; try { - const [background, reportedScheme] = await Promise.all([ - this.ui.queryTerminalBackgroundColor({ timeoutMs: terminalThemeQueryTimeoutMs }), - this.ui.queryTerminalColorScheme({ timeoutMs: terminalThemeQueryTimeoutMs }) - ]); - const colorScheme = background ? colorSchemeFromRgb(background) : reportedScheme; + const colorScheme = await queryTerminalColorScheme(this.ui, terminalThemeQueryTimeoutMs); if (colorScheme) this.theme.setColorScheme(colorScheme); } catch { // Terminal color probing is optional; COLORFGBG or the dark fallback remains active. diff --git a/packages/zcode-tui/src/terminal-text.ts b/packages/zcode-tui/src/terminal-text.ts index a87c740c..2abe3274 100644 --- a/packages/zcode-tui/src/terminal-text.ts +++ b/packages/zcode-tui/src/terminal-text.ts @@ -231,13 +231,12 @@ export function removeLastGrapheme(value: string): string { } /** - * Drop the reverse-video SGR pair the editor and input components draw - * around the caret grapheme (Editor resets with 0, Input with 27). Cell - * contents and visible widths are unchanged; only the highlight goes away. - * Input placeholders may add SGR (e.g. dim) inside the caret; retain it. + * Drop pi-tui's fake-cursor APC markers before the renderer turns them into + * reverse video. Retain the hardware cursor marker, text and placeholder styles. + * Also accept the SGR caret form used by custom/legacy input components. */ export function stripCaretHighlight(text: string): string { - return text.replace( + return text.replace(/\x1b_pi:\/?fc\x07/gu, "").replace( /\x1b\[7m((?:\x1b\[(?!(?:0|27)m)[0-9;:]*m|[^\x1b])*)\x1b\[(?:0|27)m/gu, "$1" ); diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 00000000..81c4e0ac --- /dev/null +++ b/patches/README.md @@ -0,0 +1,41 @@ +# Dependency fixes + +Install source dependencies with the pinned Bun 1.4.1 toolchain and +`bun install --frozen-lockfile`. Bun applies the patches recorded in +`package.json` and `bun.lock`; npm does not apply Bun patches. These fixes +belong to the development/test shell, which is not shipped in the CLI runtime. + +## sprintf-js 1.1.3 + +[`GHSA-hp3w-g68c-fv3c`](https://github.com/advisories/GHSA-hp3w-g68c-fv3c) +has no published fixed version as of 2026-10-16. The patch to the Node entry +`src/sprintf.js` clamps `e`/`f` precision to 0–100 and `g` precision to 1–100 +before formatting. It preserves zero precision, ordinary formats, named and +positional arguments, and string truncation. Both `sprintf` and `vsprintf` +use this parser. It does not patch the unused browser/minified entry points. + +The installed `just-bash` Node bundle imports this entry for fallback formats. +Its ordinary floating-point `printf` implementation uses its own formatter +and already converts precision errors into stderr and exit code 1. Its `awk` +formatter rejects precision above 100 with exit code 126. Regression coverage +checks these command boundaries and subsequent successful shell use, as well +as the patched formatter directly: + +```sh +bun test test/sprintf-precision.test.ts test/tui/scenario-shell.test.ts +``` + +`bun audit` still reports this one moderate advisory because the installed +package retains upstream version 1.1.3. The advisory is not ignored or hidden. +Remove the patch only after adopting a fixed upstream version and rerunning +the regressions above. + +## Transitive updates + +`just-bash` 3.6.0 removes `minimatch`/`brace-expansion` and requires the fixed +`smol-toml` 1.9.x parser (the lock selects 1.9.1). + +`guarded-fetch` 0.1.3, pinned by `just-bash`, pins vulnerable `undici` 7.29.0. +The root override selects 7.30.0, which includes the 7.29.1 security fixes. +Remove the override once the shell's dependency chain selects a safe version +on its own, then rerun `bun audit` and the shell scenarios. diff --git a/patches/sprintf-js@1.1.3.patch b/patches/sprintf-js@1.1.3.patch new file mode 100644 index 00000000..29dbf2bf --- /dev/null +++ b/patches/sprintf-js@1.1.3.patch @@ -0,0 +1,19 @@ +diff --git a/src/sprintf.js b/src/sprintf.js +index 65d6324645ef1d0133614a71497751009dc5c912..85b2285aa088ff3195ccbc611b262f4fe130bcd3 100644 +--- a/src/sprintf.js ++++ b/src/sprintf.js +@@ -184,6 +184,13 @@ + throw new Error('[sprintf] mixing positional and named placeholders is not (yet) supported') + } +- ++ ++ // GHSA-hp3w-g68c-fv3c: keep floating-point precision within ++ // the range accepted by toExponential/toFixed/toPrecision. ++ // Preserve string "0" so the formatter still applies %.0f/%.0e. ++ if (match[7] !== undefined && /[efg]/.test(match[8])) { ++ match[7] = String(Math.min(100, Math.max(match[8] === 'g' ? 1 : 0, Number(match[7])))) ++ } ++ + parse_tree.push( + { + placeholder: match[0], diff --git a/scripts/pack-release.ts b/scripts/pack-release.ts index 5c0c3879..0ba4b3d9 100644 --- a/scripts/pack-release.ts +++ b/scripts/pack-release.ts @@ -43,6 +43,7 @@ const requiredFiles = [ "docs/CONFIGURATION.zh-CN.md", "docs/PROVIDER_CONFIG.md", "docs/PROVIDER_CONFIG.zh-CN.md", + "docs/HOST_INTEGRATION.md", "package.json", "vendor/extraction.json", "vendor/node_modules/@zcode/tui/dist/index.js", @@ -63,6 +64,7 @@ const allowedRoots = new Set([ "docs/CONFIGURATION.zh-CN.md", "docs/PROVIDER_CONFIG.md", "docs/PROVIDER_CONFIG.zh-CN.md", + "docs/HOST_INTEGRATION.md", "package.json", "zcode-runtime.lock.json" ]); diff --git a/scripts/package-contents.ts b/scripts/package-contents.ts index 5abf7451..b8a78e48 100644 --- a/scripts/package-contents.ts +++ b/scripts/package-contents.ts @@ -22,6 +22,7 @@ export const publishedFiles = [ "docs/CONFIGURATION.zh-CN.md", "docs/PROVIDER_CONFIG.md", "docs/PROVIDER_CONFIG.zh-CN.md", + "docs/HOST_INTEGRATION.md", ...attributionFiles, "zcode-runtime.lock.json", "README.md", diff --git a/scripts/smoke-prompt-preflight-races.ts b/scripts/smoke-prompt-preflight-races.ts deleted file mode 100644 index d40afd83..00000000 --- a/scripts/smoke-prompt-preflight-races.ts +++ /dev/null @@ -1,56 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const home = await mkdtemp(join(tmpdir(), "zcode-preflight-races-")); -let output = ""; -const decoder = new TextDecoder(); -const terminal = new Bun.Terminal({ - cols: 120, rows: 35, - data(terminal, data) { - const text = decoder.decode(data, { stream: true }); - output += text; - if (text.includes("\x1b[6n")) terminal.write("\x1b[1;1R"); - } -}); -const child = Bun.spawn([process.execPath, join(import.meta.dir, "../test/fixtures/tui-preflight-races.ts")], { - cwd: home, - env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, TERM: "xterm-256color", CI: "1" }, - terminal -}); -async function waitFor(predicate: () => boolean | Promise, label: string) { - const deadline = Date.now() + 5_000; - while (child.exitCode === null && Date.now() < deadline) { - if (await predicate()) return; - await Bun.sleep(20); - } - throw new Error(`Missing ${label}: ${output.slice(-2000)}`); -} -const events = async () => (await readFile(join(home, "events.jsonl"), "utf8").catch(() => "")) - .trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); -try { - await waitFor(() => output.includes("glm-5.3-flash"), "editor"); - terminal.write("original input\r"); - await waitFor(async () => (await events()).some(event => event.check === 1), "pending validation"); - terminal.write("newer draft"); - await Bun.sleep(100); - await writeFile(join(home, "release"), ""); - await waitFor(() => output.includes("PREFLIGHT_REJECTED_1"), "first rejection"); - terminal.write("\r"); - await waitFor(() => output.includes("PREFLIGHT_REJECTED_3"), "queued rejection"); - assert.deepEqual((await events()).filter(event => event.submitted).map(event => event.submitted), ["newer draft"]); - terminal.write("retry trigger\r"); - await waitFor(async () => (await events()).filter(event => event.submitted).length === 3, "retained queue retry"); - assert.deepEqual((await events()).filter(event => event.submitted).map(event => event.submitted), - ["newer draft", "retry trigger", "original input"]); - terminal.write("/exit\r"); - await Promise.race([child.exited, Bun.sleep(2_000)]); - assert.equal(child.exitCode, 0); - console.log("PASS: actual TUI preserves newer draft and rejected queued input; zero model calls"); -} finally { - if (child.exitCode === null) child.kill("SIGKILL"); - await child.exited; - terminal.close(); - await rm(home, { recursive: true, force: true }); -} diff --git a/scripts/smoke-prompt-preflight.ts b/scripts/smoke-prompt-preflight.ts deleted file mode 100644 index b3d23c7c..00000000 --- a/scripts/smoke-prompt-preflight.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { writeProviderFixture } from "../test/fixtures/provider-config.ts"; -import assert from "node:assert/strict"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const home = await mkdtemp(join(tmpdir(), "zcode-keyless-tui-")); -await writeProviderFixture({ HOME: home, USERPROFILE: home }, { providerId: "zai", modelId: "glm-5.3-flash", apiType: "anthropic-messages" }); - -let output = ""; -const decoder = new TextDecoder(); -const terminal = new Bun.Terminal({ - cols: 120, rows: 35, - data(_terminal, data) { output += decoder.decode(data, { stream: true }); } -}); -const child = Bun.spawn([process.execPath, join(import.meta.dir, "../test/fixtures/tui-keyless.ts")], { - cwd: home, - env: { - PATH: process.env.PATH, HOME: home, USERPROFILE: home, TERM: "xterm-256color", CI: "1", - ZCODE_BASE_URL: "https://zcode.z.ai", ZCODE_MODEL_RETRY_MAX_RETRIES: "5" - }, - terminal -}); -async function waitFor(text: string) { - const deadline = Date.now() + 5_000; - while (!output.includes(text) && child.exitCode === null && Date.now() < deadline) await Bun.sleep(25); - assert(output.includes(text), `Missing ${text}: ${output.slice(-2000)}`); -} -try { - await waitFor("glm-5.3-flash"); - terminal.write("offline fixture input\r"); - await waitFor("No model request was sent"); - assert(!output.includes("UNEXPECTED_MODEL_SUBMISSION")); - terminal.write("\x15/exit\r"); - await Promise.race([child.exited, Bun.sleep(2_000)]); - assert.equal(child.exitCode, 0); - console.log("PASS: actual TUI rejects keyless Flash input before submitPrompt; zero model requests"); -} finally { - if (child.exitCode === null) child.kill("SIGKILL"); - await child.exited; - terminal.close(); - await rm(home, { recursive: true, force: true }); -} diff --git a/scripts/smoke-tui-clear.ts b/scripts/smoke-tui-clear.ts index 210331e6..250b4f6e 100644 --- a/scripts/smoke-tui-clear.ts +++ b/scripts/smoke-tui-clear.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { terminalOutputText as plainText } from "./terminal-output.ts"; import { runtimeTestEnv } from "./runtime-test-env.ts"; // Verify /cls clears the transcript locally and /clear is forwarded to the runtime. @@ -34,13 +35,6 @@ const child = Bun.spawn([process.execPath, fixture], { stderr: "ignore" }); -function plainText(value: string): string { - return value - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\r/g, ""); -} async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { const startedAt = Date.now(); diff --git a/scripts/smoke-tui-features.ts b/scripts/smoke-tui-features.ts index 9b087182..90e6f64a 100644 --- a/scripts/smoke-tui-features.ts +++ b/scripts/smoke-tui-features.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { terminalOutputText as plainText } from "./terminal-output.ts"; import { runtimeTestEnv } from "./runtime-test-env.ts"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -36,13 +37,6 @@ const child = Bun.spawn([process.execPath, fixture], { terminal }); -function plainText(value: string): string { - return value - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\r/g, ""); -} async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { const startedAt = Date.now(); diff --git a/scripts/smoke-tui-focus.ts b/scripts/smoke-tui-focus.ts index 076f2f8f..0e12ca40 100644 --- a/scripts/smoke-tui-focus.ts +++ b/scripts/smoke-tui-focus.ts @@ -81,7 +81,7 @@ async function send(input: string): Promise { async function focusCycle(label: string): Promise { assert.equal(cursorVisible, true, `${label}: hardware cursor starts visible`); - assert.equal(caretInverse(), true, `${label}: drawn caret starts visible`); + assert.equal(caretInverse(), false, `${label}: hardware cursor is not doubled by a drawn caret`); const before = screenText(); const position = { x: screen.buffer.active.cursorX, y: screen.buffer.active.cursorY }; terminal.write("\x1b[O"); @@ -91,7 +91,7 @@ async function focusCycle(label: string): Promise { assert.equal(cursorVisible, false, `${label}: repeated focus-out stays hidden`); assert.equal(caretInverse(), false, `${label}: repeated focus-out keeps the drawn caret hidden`); terminal.write("\x1b[I"); - await waitFor(`${label}: both carets restored`, () => cursorVisible && caretInverse()); + await waitFor(`${label}: hardware cursor restored`, () => cursorVisible && !caretInverse()); assert.equal(screenText(), before, `${label}: restoring the caret preserves text`); assert.deepEqual( { x: screen.buffer.active.cursorX, y: screen.buffer.active.cursorY }, @@ -101,7 +101,7 @@ async function focusCycle(label: string): Promise { } async function editorReady(): Promise { - await waitFor("editor focus", () => cursorVisible && caretInverse() && screen.buffer.active.cursorY >= screen.rows - 5); + await waitFor("editor focus", () => cursorVisible && !caretInverse() && screen.buffer.active.cursorY >= screen.rows - 5); } async function openPrompt(mask: boolean): Promise { @@ -110,9 +110,10 @@ async function openPrompt(mask: boolean): Promise { await waitFor("prompt picker", () => screenText().includes("Choose an input.")); await send(mask ? "\x1b[B\r" : "\r"); await waitFor("text prompt", () => screenText().includes(mask ? "Focus masked prompt" : "Focus text prompt")); + assert.ok(screenText().includes(mask ? "Secret placeholder" : "Text placeholder"), "empty prompt retains its placeholder"); assert.equal(cursorVisible || caretInverse(), false, "opening a prompt while unfocused keeps carets hidden"); await send("\x1b[I"); - await waitFor("focused prompt", () => cursorVisible && caretInverse()); + await waitFor("focused prompt", () => cursorVisible && !caretInverse()); } async function resize(cols: number, rows: number): Promise { @@ -148,7 +149,7 @@ async function switchMode(next: "regular" | "fullscreen"): Promise { assert.equal(cursorVisible, false, "mode switch preserves unfocused hardware cursor"); assert.equal(caretInverse(), false, "mode switch preserves unfocused drawn caret"); await send("\x1b[I"); - await waitFor("mode switch restores editor caret", () => cursorVisible && caretInverse()); + await waitFor("mode switch restores editor caret", () => cursorVisible && !caretInverse()); await focusCycle(`editor after switch to ${next}`); if (next === "fullscreen") { await send("\x1b[102;6u"); @@ -159,8 +160,8 @@ async function switchMode(next: "regular" | "fullscreen"): Promise { } try { - await waitFor("interactive editor", () => screenText().includes("alpha/model") && cursorVisible && caretInverse(), 20_000); - // No focus report has arrived: terminals without mode 1004 retain both carets. + await waitFor("interactive editor", () => screenText().includes("alpha/model") && cursorVisible && !caretInverse(), 20_000); + // No focus report has arrived: terminals without mode 1004 retain the hardware cursor. assert.ok(output.includes("\x1b[?1004h"), "focus reporting is enabled regardless of notification settings"); await focusCycle("empty editor"); await send("中文👩‍💻é"); @@ -174,19 +175,24 @@ try { terminal.write("\x1b["); await Bun.sleep(5); // Stay within the terminal parser's incomplete-CSI timeout. await send("I"); - await waitFor("split focus-in", () => cursorVisible && caretInverse()); + await waitFor("split focus-in", () => cursorVisible && !caretInverse()); await send("\x1b[I\x1b[I"); - assert.equal(cursorVisible && caretInverse(), true, "duplicate focus-in preserves the caret"); + assert.equal(cursorVisible && !caretInverse(), true, "duplicate focus-in preserves the hardware cursor without a drawn caret"); await send("Focus transcript fixture\r"); await waitFor("transcript search fixture", () => screenText().includes("Focus fixture response.")); + if (mode === "fullscreen") { + // Focus assertions compare identical content; let the first-turn welcome + // animation finish before taking the screen and cursor snapshots. + await waitFor("fullscreen welcome transition", () => !screenText().includes("Ask a task about this workspace")); + } await send("/search Focus\r"); await waitFor("slash-command search", () => /Search 1\/\d+: Focus/.test(screenText())); await focusCycle("slash-command search with matching text"); await send("\x1b[O"); assert.ok(inverseCount() > 0, "slash-command search highlighting survives focus loss"); await send("\x1b[I"); - await waitFor("editor after slash-command search", () => cursorVisible && caretInverse()); + await waitFor("editor after slash-command search", () => cursorVisible && !caretInverse()); await send("/search clear\r"); await waitFor("slash-command search cleared", () => !/Search \d+\/\d+: Focus/.test(screenText())); @@ -213,7 +219,7 @@ try { await resize(48, 18); assert.equal(cursorVisible || caretInverse(), false, "resizing preserves unfocused carets"); await send("\x1b[I"); - await waitFor("search restored after resize", () => cursorVisible && caretInverse()); + await waitFor("search restored after resize", () => cursorVisible && !caretInverse()); await focusCycle("narrow search"); await resize(100, 32); await send("\x1b"); @@ -232,21 +238,21 @@ try { await send("\x1b"); assert.equal(cursorVisible || caretInverse(), false, "closing a prompt while unfocused keeps carets hidden"); await send("\x1b[I"); - await waitFor("editor after prompt", () => cursorVisible && caretInverse()); + await waitFor("editor after prompt", () => cursorVisible && !caretInverse()); } await send("/focus-stream\r\x1b[O"); await waitFor("background redraw", () => screenText().includes("Stream focus still running finished.")); assert.equal(cursorVisible || caretInverse(), false, "stream redraw stays unfocused"); await send("\x1b[I"); - await waitFor("editor after stream", () => cursorVisible && caretInverse()); + await waitFor("editor after stream", () => cursorVisible && !caretInverse()); // Establish the last known state before the login command disables reports. await send("\x1b[O"); await send("/login\r"); await waitFor("suspended login resumed", () => output.includes("Focus fixture login finished.") && screenText().includes("Login command finished,")); assert.equal(cursorVisible || caretInverse(), false, "login resume preserves last known focus"); await send("\x1b[I"); - await waitFor("editor after login", () => cursorVisible && caretInverse()); + await waitFor("editor after login", () => cursorVisible && !caretInverse()); await focusCycle("editor after suspended login"); await switchMode(mode === "fullscreen" ? "regular" : "fullscreen"); diff --git a/scripts/smoke-tui-fullscreen-layout.ts b/scripts/smoke-tui-fullscreen-layout.ts index eedf2f49..1b6b4845 100644 --- a/scripts/smoke-tui-fullscreen-layout.ts +++ b/scripts/smoke-tui-fullscreen-layout.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { terminalOutputText as plain } from "./terminal-output.ts"; import { runtimeTestEnv } from "./runtime-test-env.ts"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -46,14 +47,6 @@ async function runPhase(options: { copyOnSelect: boolean; multiplexer?: boolean terminal }); - function plain(value: string): string { - return value - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\x1b_p[^\x07]*\x07/g, "") - .replace(/\r/g, ""); - } async function waitFor(pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { const startedAt = Date.now(); diff --git a/scripts/smoke-tui-fullscreen-switch.ts b/scripts/smoke-tui-fullscreen-switch.ts index 18edea7b..757e346f 100644 --- a/scripts/smoke-tui-fullscreen-switch.ts +++ b/scripts/smoke-tui-fullscreen-switch.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { terminalOutputText as plainText } from "./terminal-output.ts"; import { runtimeTestEnv } from "./runtime-test-env.ts"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -35,13 +36,6 @@ const child = Bun.spawn([process.execPath, fixture], { terminal }); -function plainText(value: string): string { - return value - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\r/g, ""); -} function screenRows(): string[] { const rows: string[] = []; diff --git a/scripts/smoke-tui-fullscreen.ts b/scripts/smoke-tui-fullscreen.ts index 17fefecc..fa58ceea 100644 --- a/scripts/smoke-tui-fullscreen.ts +++ b/scripts/smoke-tui-fullscreen.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { terminalOutputText as plainText } from "./terminal-output.ts"; import { runtimeTestEnv } from "./runtime-test-env.ts"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -21,7 +22,7 @@ const terminal = new Bun.Terminal({ } }); -// Pre-write config.json with ui.tuiMode=fullscreen to verify P1-1: the TUI +// Pre-write setting.json with ui.tuiMode=fullscreen to verify P1-1: the TUI // reads the persisted mode on startup even when the vendor runtime does not // forward an initialTuiMode option. await mkdir(configDir, { recursive: true }); @@ -41,13 +42,6 @@ const child = Bun.spawn([process.execPath, fixture], { terminal }); -function plainText(value: string): string { - return value - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\r/g, ""); -} async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { const startedAt = Date.now(); @@ -84,7 +78,8 @@ try { // complete fullscreen frame is painted again. const focusRecoveryStart = output.length; terminal.write("\x1b[O\x1b[I"); - await waitFor("fullscreen focus recovery repaint", /\x1b\[\?2026h\x1b\[2J[\s\S]*alpha\/model/i, focusRecoveryStart); + // pi-tui may delete Kitty image placements before clearing the text screen. + await waitFor("fullscreen focus recovery repaint", /\x1b\[\?2026h[\s\S]*?\x1b\[2J[\s\S]*alpha\/model/i, focusRecoveryStart); // The editor must be constructed against the config-selected fullscreen TUI, // so delayed autocomplete repaints without a follow-up key. const completionStart = output.length; diff --git a/scripts/smoke-tui-pressure.ts b/scripts/smoke-tui-pressure.ts index ee413efd..2cae1dd4 100644 --- a/scripts/smoke-tui-pressure.ts +++ b/scripts/smoke-tui-pressure.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { terminalOutputText as plainText } from "./terminal-output.ts"; import { runtimeTestEnv } from "./runtime-test-env.ts"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -33,13 +34,6 @@ const child = Bun.spawn([process.execPath, fixture], { terminal }); -function plainText(value: string): string { - return value - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\r/g, ""); -} async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 4_000): Promise { const startedAt = Date.now(); diff --git a/scripts/smoke-tui-session-title.ts b/scripts/smoke-tui-session-title.ts index 6c2d6e2b..6c6893ab 100644 --- a/scripts/smoke-tui-session-title.ts +++ b/scripts/smoke-tui-session-title.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { terminalOutputText as plainText } from "./terminal-output.ts"; import { runtimeTestEnv } from "./runtime-test-env.ts"; // Verify terminal session-title lifecycle behavior through a real PTY. @@ -35,13 +36,6 @@ const child = Bun.spawn([process.execPath, fixture], { stderr: "ignore" }); -function plainText(value: string): string { - return value - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\r/g, ""); -} function terminalTitles(value: string): string[] { return [...value.matchAll(/\x1b\]0;([^\x07]*)\x07/gu)].map((match) => match[1] ?? ""); diff --git a/scripts/smoke-tui-widths.ts b/scripts/smoke-tui-widths.ts index 66be9f16..ee25e241 100644 --- a/scripts/smoke-tui-widths.ts +++ b/scripts/smoke-tui-widths.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { terminalOutputText as plainText } from "./terminal-output.ts"; import { runtimeTestEnv } from "./runtime-test-env.ts"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -8,13 +9,6 @@ import { join } from "node:path"; const root = join(import.meta.dir, ".."); const fixture = join(root, "test", "fixtures", "tui-features.ts"); -function plainText(value: string): string { - return value - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/gu, "") - .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/gu, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/gu, "") - .replace(/\r/gu, ""); -} async function verifyWidth( width: number, diff --git a/scripts/smoke-tui.ts b/scripts/smoke-tui.ts index 3b417fc8..27824d8d 100755 --- a/scripts/smoke-tui.ts +++ b/scripts/smoke-tui.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { terminalOutputText as plainText } from "./terminal-output.ts"; import { existsSync } from "node:fs"; import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -72,13 +73,6 @@ const child = Bun.spawn(command, { terminal }); -function plainText(value: string): string { - return value - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\r/g, ""); -} async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { const startedAt = Date.now(); diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 56566162..8d40c684 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -38,7 +38,6 @@ export { } from "./runtime-sqlite-patches.ts"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const cdnRoot = "https://cdn-zcode.z.ai/zcode/electron/releases"; const updateServiceRoot = "https://zcode.z.ai"; const updateServiceManifestPath = "/api/v1/releases/electron/manifest"; const stableReleaseChannel = "1"; @@ -81,7 +80,7 @@ export interface RuntimeLock { export interface RuntimeManifestResolution { lock: RuntimeLock; - source: "service" | "static"; + source: "service"; url: string; } @@ -280,12 +279,6 @@ export function selectRuntimeLock(candidate: RuntimeLock, current?: RuntimeLock) return candidate; } -export function manifestUrl(platform: SyncOptions["platform"], arch: string): string { - if (platform === "darwin") return `${cdnRoot}/update/mac/${arch}/latest-mac.yml`; - if (platform === "linux") return `${cdnRoot}/update/linux/${arch}/latest-linux.yml`; - return `${cdnRoot}/update/win/${arch}/latest.yml`; -} - export function serviceReleasePlatform(platform: SyncOptions["platform"], arch: string): string { const releasePlatform = platform === "darwin" ? "darwin" @@ -367,11 +360,12 @@ export async function resolveLatestRuntimeLock( ); return { lock, source: "service", url: serviceUrl }; } catch (error) { - const fallbackUrl = manifestUrl(options.platform, options.arch); const reason = error instanceof Error ? error.message : String(error); - console.warn(`Stable update service manifest failed (${reason}); falling back to ${fallbackUrl}.`); - const lock = await runtimeLockFromManifest(options, fallbackUrl, fallbackUrl, fetcher); - return { lock, source: "static", url: fallbackUrl }; + throw new Error( + `Could not resolve the latest ZCode runtime for ${options.platform}-${options.arch} from ${serviceUrl}: ${reason}. ` + + "Retry when the stable update service is available, or run bun run sync:locked to rebuild the existing locked artifact without checking for a newer version.", + { cause: error } + ); } } diff --git a/scripts/terminal-output.ts b/scripts/terminal-output.ts new file mode 100644 index 00000000..653b5df6 --- /dev/null +++ b/scripts/terminal-output.ts @@ -0,0 +1,6 @@ +import { sanitizeTerminalText } from "../packages/zcode-tui/src/terminal-text.ts"; + +/** Plain text for PTY traces; keep output after both BEL- and ST-terminated controls. */ +export function terminalOutputText(value: string): string { + return sanitizeTerminalText(value.replaceAll("\r", "")); +} diff --git a/scripts/verify-tui-perf.ts b/scripts/verify-tui-perf.ts index 472530c5..8658cd05 100644 --- a/scripts/verify-tui-perf.ts +++ b/scripts/verify-tui-perf.ts @@ -294,7 +294,7 @@ async function runJsonCommand(label: string, command: string[]): Promise { const bun = process.execPath; - await runCommand("tests", [bun, "test"]); + await runCommand("tests", [bun, "run", "test:all"]); await runCommand("typecheck", [bun, "run", "typecheck"]); await runCommand("build", [bun, "run", "build"]); await runCommand("TUI smoke tests", [bun, "run", "check:tui"]); diff --git a/src/runtime-sqlite-recovery.ts b/src/runtime-sqlite-recovery.ts index e50f77ac..5dae4657 100644 --- a/src/runtime-sqlite-recovery.ts +++ b/src/runtime-sqlite-recovery.ts @@ -21,6 +21,7 @@ export const sqliteWriteRecoveryPolicy = { // Audited against ZCode 29628c9: native transactions roll back before throwing; // nontransactional methods upsert/delete stable IDs and preserve input/message sequences. +// Rechecked 2026-10-16 against aac4755: the session-store facade is unchanged. export const sqliteReplayableWrites = [ "createSession", "updateSession", "createForkedSessionWithMetadata", "commitForkBundle", "commitSharedContextImportBundle", "transitionSharedContextImport", "saveMessage", diff --git a/test/choice-dialog.test.ts b/test/choice-dialog.test.ts index 8d0cf101..598bef66 100644 --- a/test/choice-dialog.test.ts +++ b/test/choice-dialog.test.ts @@ -261,7 +261,7 @@ describe("TUI choice dialog", () => { } } as unknown as TUI; root.addChild(host); - const caret = () => /\x1b\[7m[^\x1b]*\x1b\[(?:0|27)m/.test(root.render(80).join("\n")); + const caret = () => root.render(80).join("\n").includes("\x1b_pi:fc\x07"); let terminalFocused = false; const pending = promptText(ui, host, createTheme(false), { diff --git a/test/fixtures/tui-keyless.ts b/test/fixtures/tui-keyless.ts deleted file mode 100644 index 03acfb40..00000000 --- a/test/fixtures/tui-keyless.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { runTui } from "../../packages/zcode-tui/src/index.ts"; - -await runTui({ - initialModel: "zai/glm-5.3-flash", - initialThoughtLevel: "low", - workspaceDirectory: process.env.HOME, - submitPrompt: async () => { - throw new Error("UNEXPECTED_MODEL_SUBMISSION"); - } -}); diff --git a/test/fixtures/tui-preflight-races.ts b/test/fixtures/tui-preflight-races.ts deleted file mode 100644 index 4e08682c..00000000 --- a/test/fixtures/tui-preflight-races.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { mock } from "bun:test"; -import { appendFile, access } from "node:fs/promises"; -import { join } from "node:path"; - -const home = process.env.HOME!; -const events = join(home, "events.jsonl"); -let checks = 0; -mock.module("../../src/prompt-preflight.ts", () => ({ - missingCodingPlanKey: async () => { - const check = ++checks; - await appendFile(events, JSON.stringify({ check }) + "\n"); - if (check === 1) { - while (!await access(join(home, "release")).then(() => true, () => false)) await Bun.sleep(10); - } - return check === 1 || check === 3 ? `PREFLIGHT_REJECTED_${check}` : undefined; - } -})); - -const { runTui } = await import("../../packages/zcode-tui/src/index.ts"); -await runTui({ - initialModel: "zai/glm-5.3-flash", - initialThoughtLevel: "low", - workspaceDirectory: home, - submitPrompt: async input => { - await appendFile(events, JSON.stringify({ submitted: input }) + "\n"); - return { response: "OFFLINE_MOCK_RESPONSE" }; - } -}); diff --git a/test/notifications.test.ts b/test/notifications.test.ts index 1448d2d6..49778174 100644 --- a/test/notifications.test.ts +++ b/test/notifications.test.ts @@ -325,6 +325,7 @@ describe("TUI turn notifications", () => { const writes: string[] = []; let failing = false; const notifier = new TurnNotifier({ + env: {}, settings: { method: "auto", condition: "always" }, writeTerminal: (data) => { if (failing) { @@ -360,6 +361,7 @@ describe("TUI turn notifications", () => { stdout.on("error", ignoreError); stderr.on("error", ignoreError); const notifier = new TurnNotifier({ + env: {}, settings: { method: "auto", condition: "always" }, writeTerminal: (data) => writes.push(data) }); diff --git a/test/plan-editor.test.ts b/test/plan-editor.test.ts index 31251ef2..df2af1d9 100644 --- a/test/plan-editor.test.ts +++ b/test/plan-editor.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { Editor, visibleWidth, type TUI } from "@earendil-works/pi-tui"; +import { CURSOR_MARKER, Editor, visibleWidth, type TUI } from "@earendil-works/pi-tui"; import { PlanEditor } from "../packages/zcode-tui/src/plan-editor.ts"; import { createTheme } from "../packages/zcode-tui/src/theme.ts"; @@ -30,8 +30,9 @@ test("Plan uses the existing border without moving the editor cursor or adding r if (text) expect(after.slice(1)).toEqual(before.slice(1)); else { expect(editor.getText()).toBe(""); - expect(after[1]?.slice(0, after[1].indexOf("\x1b[7m") + 4)) - .toBe(before[1]?.slice(0, before[1].indexOf("\x1b[7m") + 4)); + expect(after[1]).toContain(CURSOR_MARKER); + expect(after[1]?.slice(0, after[1].indexOf(CURSOR_MARKER) + CURSOR_MARKER.length)) + .toBe(before[1]?.slice(0, before[1].indexOf(CURSOR_MARKER) + CURSOR_MARKER.length)); expect(visibleWidth(after[1]!)).toBe(width); } if (width >= 40 && before[0]?.includes("↑")) expect(after[0]).toContain("↑"); @@ -48,8 +49,8 @@ test("terminal-unfocused rendering drops the drawn caret without changing cells" const focused = editor.render(width); editor.isTerminalFocused = () => false; const unfocused = editor.render(width); - expect(focused.join("").includes("\x1b[7m")).toBe(true); - expect(unfocused.join("").includes("\x1b[7m")).toBe(false); + expect(focused.join("")).toContain("\x1b_pi:fc\x07"); + expect(unfocused.join("")).not.toContain("\x1b_pi:fc\x07"); expect(unfocused.length).toBe(focused.length); for (let i = 0; i < focused.length; i++) { expect(visibleWidth(unfocused[i]!)).toBe(visibleWidth(focused[i]!)); diff --git a/test/release-package.test.ts b/test/release-package.test.ts index 84c1559a..d43bce8b 100644 --- a/test/release-package.test.ts +++ b/test/release-package.test.ts @@ -30,6 +30,7 @@ const requiredPaths = [ "docs/CONFIGURATION.zh-CN.md", "docs/PROVIDER_CONFIG.md", "docs/PROVIDER_CONFIG.zh-CN.md", + "docs/HOST_INTEGRATION.md", "docs/THIRD_PARTY_CONTENT.md", "package.json", "vendor/extraction.json", @@ -73,6 +74,16 @@ describe("release package", () => { } }); + test("ships the host integration contract referenced by the README", () => { + expect(() => validatePackResult(result(), { + name: "zcode-app-cli", version: "3.3.5-1" + })).not.toThrow(); + expect(() => validatePackResult(result(requiredPaths + .filter(path => path !== "docs/HOST_INTEGRATION.md").map(packFile)), { + name: "zcode-app-cli", version: "3.3.5-1" + })).toThrow("missing: docs/HOST_INTEGRATION.md"); + }); + test("marks modified runtime bundles without removing the shebang or upstream text", () => { const body = '"use strict";\n// Original upstream copyright\nreturn 42;\n'; for (const shebang of ["", "#!/usr/bin/env node\n"]) { @@ -143,8 +154,10 @@ describe("release package", () => { if (name === "ci") { const matrix = workflow.jobs["node-runtime"]; expect(matrix.needs).toBe("validate"); - expect(matrix.strategy.matrix.node).toEqual(["22.19.0", "24", "26"]); + expect(matrix.strategy.matrix.node).toEqual(["24", "26"]); expect(matrix.strategy.matrix.include).toContainEqual({ os: "macos-latest", node: "24" }); + expect(matrix.steps.some((step: { run?: string }) => step.run === "bun run test:runtime:compat")).toBe(true); + expect(matrix.steps.some((step: { run?: string }) => step.run === "bun run test:runtime")).toBe(false); expect(matrix.steps.some((step: { run?: string }) => step.run === "bun run test:node")).toBe(true); expect(matrix.steps.some((step: { uses?: string }) => step.uses?.startsWith("actions/download-artifact@"))).toBe(true); } @@ -219,6 +232,7 @@ describe("release package", () => { "docs/CONFIGURATION.zh-CN.md": "# Fixture documentation\n", "docs/PROVIDER_CONFIG.md": "# Fixture documentation\n", "docs/PROVIDER_CONFIG.zh-CN.md": "# Fixture documentation\n", + "docs/HOST_INTEGRATION.md": "# Fixture host integration contract\n", "docs/THIRD_PARTY_CONTENT.md": "# Fixture documentation\n", "LICENSES/Apache-2.0.txt": "license\n", "LICENSES/README.md": "notice provenance\n", diff --git a/test/runtime/context-window-projection.test.ts b/test/runtime/context-window-projection.test.ts index 7daa3d1b..7f8691ab 100644 --- a/test/runtime/context-window-projection.test.ts +++ b/test/runtime/context-window-projection.test.ts @@ -1,3 +1,4 @@ +import { terminalOutputText as plainText } from "../../scripts/terminal-output.ts"; import { expect, test } from "bun:test"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -12,10 +13,6 @@ const node = Bun.which("node")!; const configuredContextWindow = 1_000_000; const fixtureReply = "CONTEXT_WINDOW_REPLY"; -function plainText(text: string): string { - return text.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/\r/g, ""); -} /** A real runtime and session store; only the model HTTP endpoint is mocked. */ async function fixture() { @@ -117,4 +114,3 @@ test.skipIf(process.platform === "win32")( }, 40_000 ); - diff --git a/test/runtime/model-catalog-tui.test.ts b/test/runtime/model-catalog-tui.test.ts index e8f57697..6d1dd1a4 100644 --- a/test/runtime/model-catalog-tui.test.ts +++ b/test/runtime/model-catalog-tui.test.ts @@ -1,3 +1,4 @@ +import { terminalOutputText as plainText } from "../../scripts/terminal-output.ts"; import { expect, test } from "bun:test"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -8,10 +9,6 @@ import { writeProviderFixture } from "../fixtures/provider-config.ts"; import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; import { requestAppServer } from "../../src/app-server-client.ts"; -function plainText(text: string): string { - return text.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/\r/g, ""); -} test.skipIf(process.platform === "win32").each([false, true])("registry TUI starts and reloads personal models (first install: %p)", async (firstInstall) => { const node = Bun.which("node")!; diff --git a/test/sprintf-precision.test.ts b/test/sprintf-precision.test.ts new file mode 100644 index 00000000..bf6a5bca --- /dev/null +++ b/test/sprintf-precision.test.ts @@ -0,0 +1,32 @@ +import { createRequire } from "node:module"; + +import { describe, expect, test } from "bun:test"; + +// Resolve the formatter through the shell dependency, including nested installs. +const require = createRequire(import.meta.url); +const shellRequire = createRequire(require.resolve("just-bash")); +const { sprintf, vsprintf } = shellRequire("sprintf-js") as { + sprintf(format: string, ...values: unknown[]): string; + vsprintf(format: string, values: unknown[]): string; +}; + +describe("patched shell floating-point formatting", () => { + test.each(["e", "f", "g"])("bounds oversized %s precision without a RangeError", (specifier) => { + for (const precision of ["101", "999999", "9".repeat(310)]) { + const format = `%.${precision}${specifier}`; + const expected = sprintf(`%.100${specifier}`, 1.25); + expect(sprintf(format, 1.25)).toBe(expected); + expect(vsprintf(format, [1.25])).toBe(expected); + expect(expected.length).toBeLessThanOrEqual(106); + } + }); + + test("preserves ordinary precision, zero precision and string truncation", () => { + expect(sprintf("%.2f %.0f %.0e %.3g", 1.25, 1.25, 1.25, 1.25)).toBe("1.25 1 1e+0 1.25"); + expect(sprintf("%.0g", 1.25)).toBe("1"); + expect(sprintf("%08.2f %d %.3s", 1.25, 42, "hello")).toBe("00001.25 42 hel"); + expect(sprintf("%.101s", "x".repeat(102))).toBe("x".repeat(101)); + expect(sprintf("%2$.101f", 0, 1.25)).toBe(sprintf("%.100f", 1.25)); + expect(sprintf("%(value).101f", { value: 1.25 })).toBe(sprintf("%.100f", 1.25)); + }); +}); diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts index ef2bb87a..bd3358db 100644 --- a/test/sync-runtime.test.ts +++ b/test/sync-runtime.test.ts @@ -20,7 +20,6 @@ import { hasRuntimeSqliteBusyTimeout, hasRuntimeStreamEofFinishGuard, installRuntimeProviderConfig, - manifestUrl, parseArgs, parseRuntimePatchReports, parseRuntimeLock, @@ -905,12 +904,6 @@ describe("runtime synchronization", () => { } }); - test("maps supported static updater manifests", () => { - expect(manifestUrl("linux", "x64")).toMatch(/update\/linux\/x64\/latest-linux\.yml$/); - expect(manifestUrl("darwin", "arm64")).toMatch(/update\/mac\/arm64\/latest-mac\.yml$/); - expect(manifestUrl("win32", "x64")).toMatch(/update\/win\/x64\/latest\.yml$/); - }); - test("maps platforms to the Desktop stable update service", () => { expect(serviceReleasePlatform("linux", "x64")).toBe("linux-x86_64"); expect(serviceReleasePlatform("darwin", "arm64")).toBe("darwin-aarch64"); @@ -957,48 +950,54 @@ describe("runtime synchronization", () => { expect(headers.get("X-Device-Mid")).toBeNull(); }); - test("falls back to the static manifest when the service manifest is unusable", async () => { + test("rejects an unusable service manifest without trying an obsolete CDN fallback", async () => { const calls: string[] = []; const sha512 = Buffer.alloc(64, 6).toString("base64"); - const result = await resolveLatestRuntimeLock( + const resolution = resolveLatestRuntimeLock( { platform: "linux", arch: "x64" }, async (url) => { calls.push(url); - if (calls.length === 1) { - return JSON.stringify({ - version: "3.7.3", - files: [{ url: "ZCode.AppImage", sha512 }] - }); - } return JSON.stringify({ - version: "3.6.5", - files: [{ url: "ZCode-3.6.5-linux-x64.deb", sha512 }] + version: "3.7.3", + files: [{ url: "ZCode.AppImage", sha512 }] }); } ); - const fallbackUrl = manifestUrl("linux", "x64"); - expect(calls).toEqual([serviceManifestUrl("linux", "x64"), fallbackUrl]); - expect(result).toEqual({ - source: "static", - url: fallbackUrl, - lock: { - schemaVersion: 1, - appVersion: "3.6.5", - platform: "linux", - arch: "x64", - url: "https://cdn-zcode.z.ai/zcode/electron/releases/update/linux/x64/ZCode-3.6.5-linux-x64.deb", - sha512 - } - }); + await expect(resolution).rejects.toThrow("No .deb artifact with sha512"); + await expect(resolution).rejects.toThrow("bun run sync:locked"); + expect(calls).toEqual([serviceManifestUrl("linux", "x64")]); }); + test.each(["linux", "darwin", "win32"] as const)( + "preserves %s service failures and explains how to use the existing lock explicitly", + async (platform) => { + const calls: string[] = []; + const cause = new Error("HTTP 503 Service Unavailable"); + const resolution = resolveLatestRuntimeLock({ platform, arch: "x64" }, async (url) => { + calls.push(url); + throw cause; + }); + try { + await resolution; + throw new Error("Expected manifest resolution to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).cause).toBe(cause); + expect((error as Error).message).toContain(`latest ZCode runtime for ${platform}-x64`); + expect((error as Error).message).toContain("bun run sync:locked"); + expect((error as Error).message).toContain("without checking for a newer version"); + } + expect(calls).toEqual([serviceManifestUrl(platform, "x64")]); + } + ); + test("resolves relative and absolute updater artifact URLs", () => { - const manifest = manifestUrl("linux", "x64"); + const manifest = "https://zcode.z.ai/releases/latest.yml"; const absolute = "https://cdn-zcode.z.ai/zcode/electron/releases/3.3.6/linux-x64/ZCode.deb"; expect(resolveArtifactUrl(manifest, "ZCode.deb")).toBe( - "https://cdn-zcode.z.ai/zcode/electron/releases/update/linux/x64/ZCode.deb" + "https://zcode.z.ai/releases/ZCode.deb" ); expect(resolveArtifactUrl(manifest, absolute)).toBe(absolute); }); diff --git a/test/terminal-text.test.ts b/test/terminal-text.test.ts index ffc6a188..48231100 100644 --- a/test/terminal-text.test.ts +++ b/test/terminal-text.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { CURSOR_MARKER, Input, visibleWidth } from "@earendil-works/pi-tui"; +import { CURSOR_MARKER, Input, renderFakeCursor, visibleWidth } from "@earendil-works/pi-tui"; +import { terminalOutputText } from "../scripts/terminal-output.ts"; import { sanitizeTerminalText, @@ -12,6 +13,15 @@ import { } from "../packages/zcode-tui/src/terminal-text.ts"; describe("terminal text safety", () => { + test("keeps PTY text after an ST-terminated status query followed by a BEL-terminated title", () => { + const trace = "\x1b]7501;?\x1b\\\x1b[?1;2c" + + "\x1b[32m╭─ ◆ ZCODE\x1b[0m\r\n" + + "\x1b]0;ZCode\x07Ask a task about this workspace\r\n"; + expect(terminalOutputText(trace)).toBe("╭─ ◆ ZCODE\nAsk a task about this workspace\n"); + expect(terminalOutputText("before\x1bPpayload\x1b\\middle\x1b]9;notice\x07after")) + .toBe("beforemiddleafter"); + }); + test("removes cursor, screen, OSC and string control sequences", () => { const unsafe = [ "before", @@ -91,12 +101,25 @@ describe("terminal carets", () => { input.focused = focused; const rendered = input.render(40)[0]!; const stripped = stripCaretHighlight(rendered); - expect(stripped).toBe(rendered.replace("\x1b[7m", "").replace("\x1b[27m", "")); + expect(stripped).not.toContain("\x1b_pi:fc\x07"); + expect(stripped).not.toContain("\x1b_pi:/fc\x07"); expect(stripped).toContain(`${focused ? CURSOR_MARKER : ""}\x1b[2mF\x1b[22m`); expect(visibleWidth(stripped)).toBe(visibleWidth(rendered)); } }); + test("removes pi-tui fake cursors while retaining styled Unicode and hardware cursor positions", () => { + for (const grapheme of [" ", "a", "中", "👩‍💻", "é"]) { + const styled = `\x1b[2m${grapheme}\x1b[22m`; + expect(stripCaretHighlight(renderFakeCursor(styled))).toBe(styled); + expect(stripCaretHighlight(`${CURSOR_MARKER}${renderFakeCursor(styled)}`)) + .toBe(`${CURSOR_MARKER}${styled}`); + // Width truncation can remove the closing APC marker at the right edge. + expect(stripCaretHighlight(`${CURSOR_MARKER}\x1b_pi:fc\x07${styled}`)) + .toBe(`${CURSOR_MARKER}${styled}`); + } + }); + test("handles editor/input reset forms and Unicode with or without keyboard focus", () => { for (const reset of [0, 27]) { for (const grapheme of [" ", "a", "中", "👩‍💻", "é"]) { diff --git a/test/theme.test.ts b/test/theme.test.ts index 0fe40a0a..774a11cb 100644 --- a/test/theme.test.ts +++ b/test/theme.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test"; +import type { TerminalColors } from "@earendil-works/pi-tui"; import { colorSchemeFromColorFgBg, colorSchemeFromRgb, initialColorScheme, + queryTerminalColorScheme, themePreference } from "../packages/zcode-tui/src/color-scheme.ts"; import { createTheme } from "../packages/zcode-tui/src/theme.ts"; @@ -70,6 +72,35 @@ describe("TUI terminal theme", () => { expect(initialColorScheme("auto", "invalid")).toBe("dark"); }); + test.each([ + [{ background: { r: 0, g: 0, b: 0 } }, "light", "dark"], + [{ background: { r: 255, g: 255, b: 255 } }, "dark", "light"], + [{}, "light", "light"], + [{}, undefined, undefined] + ] as const)("uses the palette background or scheme-only terminal reply: %j, %s", async (colors, report, expected) => { + let listener: ((scheme: "dark" | "light") => void) | undefined; + const ui = { + terminal: { write: () => { if (report) listener?.(report); } }, + onTerminalColorSchemeChange: (callback: typeof listener) => { + listener = callback; + return () => { listener = undefined; }; + }, + queryTerminalColors: async (): Promise => colors + }; + expect(await queryTerminalColorScheme(ui, 100)).toBe(expected); + expect(listener).toBeUndefined(); + }); + + test("cleans up the theme listener when the palette query fails", async () => { + let removed = false; + await expect(queryTerminalColorScheme({ + terminal: { write: () => {} }, + onTerminalColorSchemeChange: () => () => { removed = true; }, + queryTerminalColors: async () => { throw new Error("terminal closed"); } + }, 100)).rejects.toThrow("terminal closed"); + expect(removed).toBe(true); + }); + test("pairs every card background with a readable foreground", () => { const theme = createTheme(true, "dark"); const surface = theme.toolErrorBackground; diff --git a/test/tui/harness/terminal-session.ts b/test/tui/harness/terminal-session.ts index 088096a0..19d3b873 100644 --- a/test/tui/harness/terminal-session.ts +++ b/test/tui/harness/terminal-session.ts @@ -1,3 +1,4 @@ +import { terminalOutputText as terminalPlainText } from "../../../scripts/terminal-output.ts"; import { ScenarioJournal } from "./scenario-journal.ts"; import type { ScenarioWorkspace } from "./scenario-workspace.ts"; import { TerminalScreen, type TerminalScreenSnapshot } from "./terminal-screen.ts"; @@ -5,13 +6,6 @@ import { TerminalScreen, type TerminalScreenSnapshot } from "./terminal-screen.t const defaultWaitMilliseconds = 8_000; const renderSettleMilliseconds = 30; -function terminalPlainText(value: string): string { - return value - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\r/g, ""); -} export interface TerminalSessionOptions { command: string[]; diff --git a/test/tui/input-actions.test.ts b/test/tui/input-actions.test.ts index cf9f9ea5..de32614f 100644 --- a/test/tui/input-actions.test.ts +++ b/test/tui/input-actions.test.ts @@ -111,3 +111,27 @@ test("ctrl+f replaces editor text with the /search prefill on screen", async () await session.settle(); await session.exit(); }, 20_000); + +test("fragmented terminal palette replies do not enter the draft or trigger input actions", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-color-replies-" }); + await using session = TerminalSession.start({ + command: [process.execPath, join(import.meta.dir, "fixtures/input-actions.ts")], + workspace, + env: { NO_COLOR: "", COLORFGBG: "15;0", EDITOR: "true", VISUAL: "true" } + }); + await session.waitForScreen("ready", /Original answer/u); + session.send("retained draft"); + await session.waitForScreen("draft visible", /retained draft/u); + for (const fragment of [ + "\x1b]10;rgb:", "1111/2222/3333", "\x07", + "\x1b]11;rgb:ffff", "/eeee/dddd", "\x1b\\", + "\x1b]4;2;rgb:", "1234/5678/9abc", "\x07", + "\x1b[?1;2c" + ]) { + session.send(fragment); + await session.settle(); + } + await session.assertScreenExcludes("palette replies stay out of the draft", /rgb:|1111|2222|ffff|9abc/u); + await session.sendAndWait(" tail\r", "unaltered draft submitted", /Sent: retained draft tail/u); + await session.exit(); +}, 20_000); diff --git a/test/tui/model-resume.test.ts b/test/tui/model-resume.test.ts index 804c7df5..6792d1e0 100644 --- a/test/tui/model-resume.test.ts +++ b/test/tui/model-resume.test.ts @@ -11,7 +11,8 @@ test("TUI restores the last model selected in a resumed session", async () => { await runAutomatedTuiScenario(modelResumeScenario); }); -test.each(["glm", "GLM"])("TUI saves the default model after searching for %s and uses it in a new session", async (query) => { +test("TUI saves a default model after a mixed-case search and uses it in a new session", async () => { + const query = "gLm"; await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-default-model-" }); const command = [process.execPath, modelResumeScenario.fixture]; await using session = TerminalSession.start({ command, workspace }); diff --git a/test/tui/scenario-shell.test.ts b/test/tui/scenario-shell.test.ts index d3497dab..04f54da9 100644 --- a/test/tui/scenario-shell.test.ts +++ b/test/tui/scenario-shell.test.ts @@ -81,6 +81,41 @@ test("scenario shell rejects an empty command allowlist", async () => { })).toThrow("requires at least one allowed command"); }); +test("scenario shell contains floating-point precision failures and remains usable", async () => { + await using workspace = await ScenarioWorkspace.create(); + const shell = createScenarioShell({ workspaceDirectory: workspace.directory }); + + for (const specifier of ["e", "f", "g"]) { + expect((await shell.exec(`printf '%.100${specifier}' 1.25`)).stdout.length).toBeLessThanOrEqual(107); + // just-bash implements ordinary float formats itself and contains their + // RangeErrors at the command boundary; sprintf-js handles fallback formats. + for (const command of [ + `printf '%.101${specifier}' 1.25`, + `printf '%.*${specifier}' 101 1.25`, + `printf -v formatted '%.*${specifier}' 101 1.25` + ]) { + const result = await shell.exec(command, { expectedExitCode: 1 }); + expect(result.stdout).toBe(""); + expect(result.stderr).toMatch(/^printf: .+argument must be between /u); + } + } + expect(await shell.exec("printf '%.0g %.0f' 1.25 1.25")).toMatchObject({ + exitCode: 0, stdout: "1 1", stderr: "" + }); + expect((await shell.exec("echo ready")).stdout).toBe("ready\n"); +}); + +test("scenario shell rejects excessive awk printf and sprintf precision before formatting", async () => { + await using workspace = await ScenarioWorkspace.create(); + const shell = createScenarioShell({ workspaceDirectory: workspace.directory, commands: ["awk"] }); + for (const expression of ['printf "%.101f", 1.25', 'print sprintf("%.101f", 1.25)']) { + const result = await shell.exec(`awk 'BEGIN { ${expression} }'`, { expectedExitCode: 126 }); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("floating-point precision limit exceeded (100 digits)"); + } + expect((await shell.exec(`awk 'BEGIN { printf "%.2f", 1.25 }'`)).stdout).toBe("1.25"); +}); + test("scenario shell rejects ambiguous journals and invalid exit expectations", async () => { await using workspace = await ScenarioWorkspace.create(); const journalPath = workspace.runtimeJournalPath;