mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
* feat(voice): add a Voice settings page with provider choice and a local model catalog Each host gets a Voice page under host settings. Dictation and voice mode each pick a provider (Local or OpenAI) and a model. Provider settings sit in one group of collapsible rows: local speech-to-text and text-to-speech list every catalog model with its size, languages and description before download, and OpenAI takes a write-only API key and an optional base URL. The local catalog grows from Parakeet/Kokoro to Whisper, SenseVoice, Dolphin, GigaAM, Parakeet Japanese, multilingual Kokoro, Kitten and Piper voices. Each entry declares its sherpa engine family, so the recognizer and TTS build the matching model config and the downloader verifies the files that family needs. Kitten v0.8 is left out: it crashes the pinned sherpa-onnx 1.12.28. Voice mode is now opt-in. The default lived only in code (true), never in config.json, so hosts that never toggled it find it off after upgrading. OpenAI speech-to-text resolved one model for both features, with voice mode's choice taking precedence, so dictation silently used voice mode's model. The resolved config now carries a model per feature; STT_MODEL still overrides both. Rows revealed under a collapsible settings row are a nested density level set by the container: tighter padding, smaller text, and xs controls, with controls taking their size from the row instead of each caller. * test(app): pre-bundle the JSX dev runtime for browser tests Compiling test JSX with the automatic runtime imports react/jsx-dev-runtime. Vite discovered it mid-run on a cold cache and reloaded the page, which failed the first browser test file to import it. * refactor(voice): keep speech provider settings at each use site Store credentials and endpoint options beside the speech feature that consumes them so saved page values cannot be shadowed by a separate provider block. Normalize existing config on load and mirror the inline shape on config reads and patches. This is the config, resolver and wire phase; the provider sheet follows separately. * fix(voice): preserve saved keys when resaving provider settings Forms send the selected provider on each save but cannot read back its API key. Merge patches for the existing provider so these saves retain credentials; replace the site only when the provider changes. * feat(voice): configure each speech use site from a provider sheet The Voice page now lists Speech-to-text, Listening and Speaking as one-line summaries. Tapping one opens a sheet with the provider choice and only that provider's settings: the local catalog (size, languages and description before download, download state per model) or the OpenAI key, base URL, model and language or voice. The sheet's form model sends only the fields the user changed, so re-saving never clears a saved key. Fields an env var decides are read-only with a "Set by" hint; the API key and base URL stay editable because a saved value wins over the env fallback. Save failures show a toast and keep the sheet open with the input. E2E daemons pin speech providers through env vars to avoid model downloads, so the browser spec covers the locked-provider path, the key fallback and the failed save; provider switching is covered by the form model tests. * refactor(voice): press settings and catalog rows through PressHighlight Settings rows that lead somewhere and local model rows now use the standard row press primitive, so native shows the finger-down flash the rest of the app's rows have. Web hover and press styling are unchanged. * fix(voice): preserve STT language for local providers Language belongs to every STT use site. Preserve legacy local language settings through config loading and saving, resolve them without provider gating, and expose them in the provider form.
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
import { defineConfig, configDefaults } from "vitest/config";
|
|
import { playwright } from "@vitest/browser-playwright";
|
|
import path from "path";
|
|
import fs from "fs";
|
|
import { transformSync } from "@babel/core";
|
|
import { build } from "esbuild";
|
|
|
|
// Set this before Vite resolves dependencies and Vitest starts its workers.
|
|
Object.assign(process.env, { NODE_ENV: "test" });
|
|
|
|
const appNodeModules = path.resolve(__dirname, "node_modules");
|
|
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
|
|
const resolvePackageEntry = (packageName: string) => {
|
|
const appPackagePath = path.resolve(appNodeModules, packageName);
|
|
return fs.existsSync(appPackagePath)
|
|
? appPackagePath
|
|
: path.resolve(rootNodeModules, packageName);
|
|
};
|
|
|
|
export default defineConfig({
|
|
plugins: [
|
|
{
|
|
name: "markdown-wasm-commonjs-export",
|
|
transform(source, id) {
|
|
// The ESM package contains a CommonJS WASM factory. Metro handles the
|
|
// interop; Vite needs an ESM export instead of the CommonJS trailer.
|
|
if (!id.endsWith("/lib/module/web/wasm/md4c.js")) return;
|
|
const trailer = source.lastIndexOf("\nif (typeof exports === 'object'");
|
|
if (trailer < 0) throw new Error("Markdown WASM CommonJS trailer was not found");
|
|
return {
|
|
code: `${source.slice(0, trailer)}\nexport default createMd4cModule;`,
|
|
map: null,
|
|
};
|
|
},
|
|
},
|
|
{
|
|
name: "compile-usage-components",
|
|
enforce: "pre",
|
|
transform(source, id) {
|
|
// These labels must react to time and language in the compiled app too.
|
|
if (!id.includes("/src/usage/") || !id.endsWith(".tsx") || id.includes(".test.")) return;
|
|
const result = transformSync(source, {
|
|
filename: id,
|
|
babelrc: false,
|
|
configFile: false,
|
|
parserOpts: { plugins: ["typescript", "jsx"] },
|
|
plugins: [["babel-plugin-react-compiler", { target: "19" }]],
|
|
sourceMaps: true,
|
|
});
|
|
if (result?.code) return { code: result.code, map: result.map };
|
|
},
|
|
},
|
|
{
|
|
name: "bundle-i18n-resources",
|
|
// Metro resolves literal requires lazily. Vite's TS transform leaves them
|
|
// to Node, which cannot resolve the resource modules. Bundle this boundary
|
|
// so unit and browser consumers exercise the same deferred module loading.
|
|
async transform(_source, id) {
|
|
if (path.resolve(id) !== path.resolve(__dirname, "src/i18n/i18next.ts")) return;
|
|
const result = await build({
|
|
entryPoints: [id],
|
|
bundle: true,
|
|
format: "esm",
|
|
platform: "neutral",
|
|
external: ["i18next", "react-i18next"],
|
|
write: false,
|
|
});
|
|
return { code: result.outputFiles[0].text, map: null };
|
|
},
|
|
},
|
|
],
|
|
test: {
|
|
environment: "node",
|
|
exclude: [...configDefaults.exclude, "e2e/**"],
|
|
projects: [
|
|
{
|
|
extends: true,
|
|
test: {
|
|
name: "unit",
|
|
environment: "node",
|
|
include: [
|
|
"src/**/*.{test,spec}.{ts,tsx}",
|
|
"native-release-version.test.ts",
|
|
"app.config.test.ts",
|
|
"vitest-environment.test.ts",
|
|
],
|
|
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
|
|
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
|
|
},
|
|
},
|
|
{
|
|
extends: true,
|
|
test: {
|
|
name: "browser",
|
|
fileParallelism: false,
|
|
include: ["src/**/*.browser.{test,spec}.{ts,tsx}"],
|
|
browser: {
|
|
enabled: true,
|
|
provider: playwright(),
|
|
headless: true,
|
|
connectTimeout: 180_000,
|
|
instances: [{ browser: "chromium" }],
|
|
screenshotDirectory: ".vitest-screenshots",
|
|
},
|
|
globalSetup: path.resolve(__dirname, "src/runtime/websocket-test-global-setup.ts"),
|
|
},
|
|
},
|
|
],
|
|
/**
|
|
* Expo pulls in native tooling (xcode, etc.) that executes files relying on `process.send`.
|
|
* Vitest's default worker pool uses worker_threads, which intentionally stub that API and
|
|
* immediately throw `Unexpected call to process.send`. Running the suite in forked processes
|
|
* keeps `process.send` intact so the app tests can boot before hitting the intentional failures.
|
|
*/
|
|
pool: "forks",
|
|
maxWorkers: 2,
|
|
server: {
|
|
deps: {
|
|
fallbackCJS: true,
|
|
inline: [
|
|
"react-native-enriched-markdown",
|
|
"zustand",
|
|
"@tanstack/react-query",
|
|
"react-native-web",
|
|
"react-native-reanimated",
|
|
"react-native-worklets",
|
|
"react-native-gesture-handler",
|
|
"react-native-keyboard-controller",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
// Reanimated and gesture-handler pick platform files by extension
|
|
// (e.g. `GestureHandlerRootView.web.js`). Vite's optimizer does not apply `resolve.extensions`,
|
|
// so it scans the native files and dies on imports react-native-web has no answer for.
|
|
// Unbundled, the same imports go through the resolver below and land on the web files.
|
|
optimizeDeps: {
|
|
// Expo's public navigation barrel also loads screens. Match Metro's web-first resolution
|
|
// while optimizing its CJS dependencies, including DebugContainer.web.js.
|
|
esbuildOptions: {
|
|
resolveExtensions: [
|
|
".web.js",
|
|
".web.jsx",
|
|
".web.ts",
|
|
".web.tsx",
|
|
".js",
|
|
".jsx",
|
|
".ts",
|
|
".tsx",
|
|
".json",
|
|
],
|
|
},
|
|
// Bundle the CJS dependencies of the excluded gesture-handler package for the browser.
|
|
include: [
|
|
"react/jsx-runtime",
|
|
"react/compiler-runtime",
|
|
"expo-router/react-navigation",
|
|
"@testing-library/react",
|
|
"@tanstack/react-query",
|
|
"react-i18next",
|
|
"i18next",
|
|
"@marijn/find-cluster-break",
|
|
"tiny-invariant",
|
|
"react/jsx-dev-runtime",
|
|
"react-native-gesture-handler > hoist-non-react-statics",
|
|
"react-native-gesture-handler > invariant",
|
|
],
|
|
exclude: [
|
|
"react-native-enriched-markdown",
|
|
"react-native-reanimated",
|
|
"react-native-gesture-handler",
|
|
"react-native-keyboard-controller",
|
|
],
|
|
},
|
|
// babel-preset-expo compiles JSX with the automatic runtime, so app modules don't import React.
|
|
esbuild: { jsx: "automatic" },
|
|
// The globals a React Native bundler defines, which esbuild is no longer there to supply for
|
|
// the package excluded above.
|
|
define: {
|
|
"process.env.JEST_WORKER_ID": "undefined",
|
|
__DEV__: "false",
|
|
global: "globalThis",
|
|
},
|
|
resolve: {
|
|
extensions: [
|
|
".web.mjs",
|
|
".web.js",
|
|
".web.mts",
|
|
".web.ts",
|
|
".web.jsx",
|
|
".web.tsx",
|
|
".mjs",
|
|
".js",
|
|
".mts",
|
|
".ts",
|
|
".jsx",
|
|
".tsx",
|
|
".json",
|
|
],
|
|
alias: [
|
|
{
|
|
find: /^@getpaseo\/relay\/e2ee$/,
|
|
replacement: path.resolve(__dirname, "../relay/src/e2ee.ts"),
|
|
},
|
|
{
|
|
find: /^@getpaseo\/relay$/,
|
|
replacement: path.resolve(__dirname, "../relay/src/index.ts"),
|
|
},
|
|
{ find: "@", replacement: path.resolve(__dirname, "src") },
|
|
// Keep keyboard-controller's imports in Vite so native aliases and platform extensions apply.
|
|
{
|
|
find: /^react-native-keyboard-controller$/,
|
|
replacement: path.resolve(
|
|
resolvePackageEntry("react-native-keyboard-controller"),
|
|
"lib/module/index.js",
|
|
),
|
|
},
|
|
// The CJS entry bypasses Vite's React Native alias and web-extension resolution.
|
|
{
|
|
find: /^react-native-gesture-handler$/,
|
|
replacement: path.resolve(
|
|
rootNodeModules,
|
|
"react-native-gesture-handler/lib/module/index.js",
|
|
),
|
|
},
|
|
// Must precede the `react-native` alias: a string `find` matches by prefix, so this subpath
|
|
// would otherwise resolve inside a react-native-web *file* and break the dependency scan.
|
|
// Reanimated only imports it on the native path, which no test takes.
|
|
{
|
|
find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"),
|
|
},
|
|
// Point to the ESM build so Vite can transform its imports and apply the
|
|
// react alias below (the CJS build uses require('react') which bypasses
|
|
// Vite alias resolution).
|
|
{
|
|
find: "react-native",
|
|
replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
|
|
},
|
|
{ find: "react", replacement: resolvePackageEntry("react") },
|
|
{
|
|
find: "react-dom",
|
|
replacement: resolvePackageEntry("react-dom"),
|
|
},
|
|
{
|
|
find: /^@xterm\/addon-ligatures\/lib\/addon-ligatures\.mjs$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/xterm-addon-ligatures.ts"),
|
|
},
|
|
{
|
|
find: /^@xterm\/addon-ligatures$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/xterm-addon-ligatures.ts"),
|
|
},
|
|
{
|
|
find: /^react-native-unistyles$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/react-native-unistyles.ts"),
|
|
},
|
|
{
|
|
find: /^react-native-svg$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/react-native-svg.ts"),
|
|
},
|
|
// Both ship untranspiled Flow and fail to parse on import, which takes out any test that
|
|
// mounts a menu surface.
|
|
{
|
|
find: /^react-native-safe-area-context$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/react-native-safe-area-context.ts"),
|
|
},
|
|
{
|
|
find: /^@gorhom\/bottom-sheet$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/gorhom-bottom-sheet.ts"),
|
|
},
|
|
{
|
|
find: /^react-native-reanimated\/scripts\/validate-worklets-version$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/reanimated-validate-worklets-version.ts"),
|
|
},
|
|
{
|
|
find: /^expo-clipboard$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/expo-clipboard.ts"),
|
|
},
|
|
{
|
|
find: /^expo-linking$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"),
|
|
},
|
|
// No Node implementation: every call on the real module rejects, so a test
|
|
// touching a persisted store drowns in unhandled rejections.
|
|
{
|
|
find: /^@react-native-async-storage\/async-storage$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/async-storage.ts"),
|
|
},
|
|
{
|
|
find: /^lucide-react-native$/,
|
|
replacement: path.resolve(__dirname, "test-stubs/lucide-react-native.ts"),
|
|
},
|
|
],
|
|
},
|
|
});
|