feat(frontend): U6 tauri-auth adapter + vitest unit tests

- src/api/tauri-auth.ts: abstract Keychain (Tauri) / localStorage (Web)
  behind a single async API (set/get/clear refresh token). Falls back
  to localStorage with a console.warn when the Keychain is unavailable
  (KTD-confirmed decision: silent localStorage fallback).
- tests/unit/api/tauri-auth.test.ts: 13 vitest cases covering both
  Tauri and Web code paths plus the failure / fallback behaviour.
- vitest.config.ts + tsconfig.test.json: minimal Vitest setup
  (happy-dom env, @ alias). Adds test:unit, test:unit:watch, and a
  typecheck alias that includes the test tree.

Refs: U6 in docs/plans/2026-06-20-002-feat-centralized-auth-token-persistence-plan.md
This commit is contained in:
chiguyong 2026-06-21 01:42:50 +08:00
parent 7e7a841f78
commit e39bf56248
6 changed files with 840 additions and 1 deletions

View File

@ -29,10 +29,12 @@
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.1.0",
"echarts": "^6.1.0",
"happy-dom": "^15.11.7",
"typescript": "^5.6.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^5.4.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.0"
}
},
@ -1261,6 +1263,133 @@
"vue": "^3.2.25"
}
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner/node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot/node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.15",
"resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz",
@ -1588,6 +1717,16 @@
"integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==",
"license": "MIT"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
@ -1611,6 +1750,43 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
@ -1775,6 +1951,34 @@
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/dom-align": {
"version": "1.12.4",
"resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz",
@ -1819,6 +2023,13 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
@ -1881,6 +2092,16 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz",
@ -1921,6 +2142,34 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/happy-dom": {
"version": "15.11.7",
"resolved": "https://registry.npmmirror.com/happy-dom/-/happy-dom-15.11.7.tgz",
"integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^4.5.0",
"webidl-conversions": "^7.0.0",
"whatwg-mimetype": "^3.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/happy-dom/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
@ -2023,6 +2272,13 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@ -2125,6 +2381,13 @@
"pathe": "^2.0.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
@ -2184,6 +2447,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@ -2425,6 +2698,13 @@
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
"license": "MIT"
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2434,6 +2714,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz",
@ -2462,6 +2756,20 @@
"node": ">=12.22"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
@ -2479,6 +2787,36 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
@ -2713,6 +3051,109 @@
}
}
},
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-node/node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vitest/node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
@ -2823,6 +3264,16 @@
"loose-envify": "^1.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
@ -2830,6 +3281,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz",

View File

@ -11,7 +11,10 @@
"preview": "vite preview",
"tauri": "tauri",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
"test:e2e:ui": "playwright test --ui",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"typecheck": "vue-tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.0",
@ -35,10 +38,12 @@
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.1.0",
"echarts": "^6.1.0",
"happy-dom": "^15.11.7",
"typescript": "^5.6.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^5.4.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.0"
}
}

View File

@ -0,0 +1,119 @@
/**
* Tauri-aware refresh token storage adapter.
*
* In Tauri mode the refresh token is written to the OS Keychain via the
* `store_refresh_token` / `load_refresh_token` / `clear_refresh_token`
* Rust commands (see `src-tauri/src/auth.rs`). In plain Web mode
* (no Tauri runtime) it falls back to `localStorage` with the
* documented degraded security model.
*
* The async API surface is identical in both modes, so the auth store
* (and any other caller) does not need to branch on the environment.
*
* Keychain failures are logged and transparently downgraded to
* `localStorage` so a broken OS credential service never blocks login
* (KTD-confirmed decision: silent localStorage fallback).
*/
const STORAGE_KEY = 'agentkit.refresh_token'
/** Detect whether the current page is running inside a Tauri WebView. */
export function isTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
}
/**
* Lazily import the Tauri `invoke` API.
*
* The dynamic import keeps the `@tauri-apps/api` bundle out of the Web
* build when it is not needed and lets us `catch` import-time errors
* that occur when the WebView is not actually Tauri-backed.
*/
async function tauriInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const mod = await import('@tauri-apps/api/core')
return mod.invoke<T>(cmd, args)
}
function localGet(): string | null {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
function localSet(value: string): void {
try {
localStorage.setItem(STORAGE_KEY, value)
} catch {
/* localStorage may be unavailable in some sandboxed contexts */
}
}
function localRemove(): void {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
/* ignore */
}
}
export const tauriAuthStorage = {
/**
* Persist the refresh token.
*
* Tauri OS Keychain via `store_refresh_token`. Web localStorage.
* On Keychain failure (or any Tauri error), the value is written to
* localStorage as a fallback and a warning is logged.
*/
async setRefreshToken(token: string): Promise<void> {
if (isTauri()) {
try {
await tauriInvoke<void>('store_refresh_token', { token })
return
} catch (e) {
console.warn('[auth] Keychain write failed, falling back to localStorage', e)
}
}
localSet(token)
},
/**
* Read the persisted refresh token.
*
* Tauri OS Keychain via `load_refresh_token`. Web localStorage.
* Returns `null` when no token has been stored. On Keychain failure
* (or any Tauri error), falls back to localStorage and logs a warning.
*/
async getRefreshToken(): Promise<string | null> {
if (isTauri()) {
try {
const value = await tauriInvoke<string | null>('load_refresh_token')
return value ?? null
} catch (e) {
console.warn('[auth] Keychain read failed, falling back to localStorage', e)
}
}
return localGet()
},
/**
* Remove the persisted refresh token.
*
* Tauri OS Keychain via `clear_refresh_token`. Web localStorage.
* On Keychain failure, the localStorage copy is also cleared as a
* best-effort defence-in-depth.
*/
async clearRefreshToken(): Promise<void> {
if (isTauri()) {
try {
await tauriInvoke<void>('clear_refresh_token')
} catch (e) {
console.warn('[auth] Keychain clear failed, falling back to localStorage clear', e)
}
}
localRemove()
},
}
export type TauriAuthStorage = typeof tauriAuthStorage

View File

@ -0,0 +1,199 @@
/**
* Unit tests for the tauri-auth adapter.
*
* The adapter is a thin wrapper over the Tauri `invoke` API and
* `localStorage`. We mock `@tauri-apps/api/core` so we can assert
* which command is dispatched and simulate the two relevant
* failure modes (Keychain unavailable, invoke throws).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Mock the @tauri-apps/api/core module BEFORE importing the SUT.
const mockInvoke = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvoke(...args),
}))
// Import after the mock is in place.
const { isTauri, tauriAuthStorage } = await import('@/api/tauri-auth')
const STORAGE_KEY = 'agentkit.refresh_token'
/**
* Polyfill a minimal in-memory `localStorage` for the test environment.
*
* Some happy-dom versions ship a stub that lacks `.clear()` (a known
* regression in vitest 2.x with happy-dom). We work around it by
* providing our own implementation that has the full Storage interface.
*/
const _store = new Map<string, string>()
const _localStoragePolyfill: Storage = {
get length() {
return _store.size
},
clear() {
_store.clear()
},
getItem(key: string) {
return _store.has(key) ? (_store.get(key) as string) : null
},
key(index: number) {
return Array.from(_store.keys())[index] ?? null
},
removeItem(key: string) {
_store.delete(key)
},
setItem(key: string, value: string) {
_store.set(key, String(value))
},
}
beforeEach(() => {
;(globalThis as { localStorage?: Storage }).localStorage = _localStoragePolyfill
mockInvoke.mockReset()
_store.clear()
setTauri(false)
})
afterEach(() => {
setTauri(false)
_store.clear()
vi.restoreAllMocks()
})
/** Inject / remove the Tauri runtime marker on `window`. */
function setTauri(present: boolean): void {
if (present) {
;(window as unknown as { __TAURI_INTERNALS__?: object }).__TAURI_INTERNALS__ = {}
} else {
delete (window as unknown as { __TAURI_INTERNALS__?: object }).__TAURI_INTERNALS__
}
}
beforeEach(() => {
mockInvoke.mockReset()
localStorage.clear()
setTauri(false)
})
afterEach(() => {
setTauri(false)
localStorage.clear()
vi.restoreAllMocks()
})
describe('isTauri', () => {
it('returns true when __TAURI_INTERNALS__ is present on window', () => {
setTauri(true)
expect(isTauri()).toBe(true)
})
it('returns false when __TAURI_INTERNALS__ is missing', () => {
setTauri(false)
expect(isTauri()).toBe(false)
})
})
describe('tauriAuthStorage.setRefreshToken', () => {
it('writes to localStorage in Web mode (no Tauri)', async () => {
await tauriAuthStorage.setRefreshToken('web-token')
expect(localStorage.getItem(STORAGE_KEY)).toBe('web-token')
expect(mockInvoke).not.toHaveBeenCalled()
})
it('calls invoke("store_refresh_token") in Tauri mode', async () => {
setTauri(true)
mockInvoke.mockResolvedValueOnce(undefined)
await tauriAuthStorage.setRefreshToken('keychain-token')
expect(mockInvoke).toHaveBeenCalledTimes(1)
expect(mockInvoke).toHaveBeenCalledWith('store_refresh_token', { token: 'keychain-token' })
// Web fallback should NOT have been used.
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
})
it('falls back to localStorage when invoke throws in Tauri mode', async () => {
setTauri(true)
mockInvoke.mockRejectedValueOnce(new Error('keychain unavailable'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await tauriAuthStorage.setRefreshToken('fallback-token')
expect(warnSpy).toHaveBeenCalled()
expect(localStorage.getItem(STORAGE_KEY)).toBe('fallback-token')
})
})
describe('tauriAuthStorage.getRefreshToken', () => {
it('returns null from localStorage in Web mode when nothing is stored', async () => {
const result = await tauriAuthStorage.getRefreshToken()
expect(result).toBeNull()
expect(mockInvoke).not.toHaveBeenCalled()
})
it('returns the value from localStorage in Web mode', async () => {
localStorage.setItem(STORAGE_KEY, 'stored-web')
const result = await tauriAuthStorage.getRefreshToken()
expect(result).toBe('stored-web')
expect(mockInvoke).not.toHaveBeenCalled()
})
it('returns the value from invoke("load_refresh_token") in Tauri mode', async () => {
setTauri(true)
mockInvoke.mockResolvedValueOnce('keychain-value')
const result = await tauriAuthStorage.getRefreshToken()
expect(result).toBe('keychain-value')
expect(mockInvoke).toHaveBeenCalledTimes(1)
// The first argument is the command name; the second may be undefined
// when no args are needed.
expect(mockInvoke.mock.calls[0]?.[0]).toBe('load_refresh_token')
})
it('normalizes a null invoke response to null in Tauri mode', async () => {
setTauri(true)
mockInvoke.mockResolvedValueOnce(null)
const result = await tauriAuthStorage.getRefreshToken()
expect(result).toBeNull()
})
it('falls back to localStorage when invoke throws in Tauri mode', async () => {
setTauri(true)
localStorage.setItem(STORAGE_KEY, 'web-copy')
mockInvoke.mockRejectedValueOnce(new Error('read failed'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const result = await tauriAuthStorage.getRefreshToken()
expect(warnSpy).toHaveBeenCalled()
expect(result).toBe('web-copy')
})
})
describe('tauriAuthStorage.clearRefreshToken', () => {
it('removes the entry from localStorage in Web mode', async () => {
localStorage.setItem(STORAGE_KEY, 'to-remove')
await tauriAuthStorage.clearRefreshToken()
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
expect(mockInvoke).not.toHaveBeenCalled()
})
it('calls invoke("clear_refresh_token") in Tauri mode', async () => {
setTauri(true)
mockInvoke.mockResolvedValueOnce(undefined)
await tauriAuthStorage.clearRefreshToken()
expect(mockInvoke).toHaveBeenCalledTimes(1)
expect(mockInvoke.mock.calls[0]?.[0]).toBe('clear_refresh_token')
})
it('still removes the localStorage copy when invoke throws in Tauri mode', async () => {
setTauri(true)
localStorage.setItem(STORAGE_KEY, 'still-here')
mockInvoke.mockRejectedValueOnce(new Error('clear failed'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await tauriAuthStorage.clearRefreshToken()
expect(warnSpy).toHaveBeenCalled()
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
})
})

View File

@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"env.d.ts"
]
}

View File

@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
/**
* Vitest config for frontend unit tests.
*
* The unit tests cover pure-TS modules (no Vue components) and run in a
* happy-dom environment so the few modules that touch `window`,
* `localStorage`, or `__TAURI_INTERNALS__` work the same way as in the
* browser.
*/
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
test: {
environment: 'happy-dom',
globals: false,
include: ['tests/unit/**/*.test.ts'],
// Keep CI noise low: fail fast on the first failure.
reporters: process.env.CI ? ['default'] : ['default'],
},
})