mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +00:00
* feat(keyboard): support hardware keyboards on iPad Register app shortcuts as iOS UIKeyCommands so a paired hardware keyboard drives the same bindings as web and desktop. Overlays (menus, comboboxes, modals, command center) get first refusal on bare keys, and a focused terminal keeps the keys it needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(keyboard): record iOS hardware key observation spike With a UITextView focused on iPad Pro M4/iOS 18.6, window and controller observers received Cmd+B, Cmd+P and Escape. Escape also invoked UIKeyCommand when registered, so the stream must be the sole emitter. Use observation for shortcuts and commands only for text-control ownership. Simulator intercepted Cmd+K as HID184; input capture blocked synthetic typing entirely. Keep capture off and verify the toggle via a rebind during simulator QA. * refactor(keyboard): give overlays one key registration API Overlay ownership now follows one layer-ordered registry on every runtime. Web retains focus trapping and restoration through its adapter; native registers the same key handler without a DOM scope. Picker navigation and dismissal no longer require a parallel native stack. Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com> * feat(keyboard): route native hardware keys through the shared resolver Observe each physical press once, including native modal windows, and derive focus from declared composer and terminal surfaces. Native bindings and rebinds now use the same resolver and overlay precedence as browser and Electron input. Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com> * feat(keyboard): show shortcuts when hardware keyboards are present Keep routing independent of compact layout and detect hardware capability for hints and settings. Rebind capture uses the shared overlay interface on native too. Keep the filtered sidebar model available for keyboard navigation while its panel is hidden. Co-authored-by: Les Stroud <public@lesstroud.com> Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com> * test(keyboard): allow shortcut hints in compact menu assertions Compact menus now display hardware keyboard hints. Assert action labels, order and uniqueness without treating a trailing shortcut badge as part of the action label. * fix(keyboard): retain an overlay's opening layer in state Capture the registry's current top when an overlay opens and retain that layer through rerenders. React Compiler removed the previous useMemo cache after the registry getter moved across modules, allowing a parent modal to rise above its child and start an infinite focus loop. Compile this hook in Vitest as Expo does. The new hook regression failed first with layer 220 instead of 20, then passed with explicit opening state. Focused unit and browser tests pass, and the previously failing onboarding E2E completes through the real Expo compiler. Typecheck, lint and format pass. * refactor(keyboard): let overlays own native hardware input Remove the TextInput key handlers that duplicate declared overlay arrows and Escape in Add Project and Command Center. Remove Add Project's hidden responder now that native window sources observe keys without it. Keep soft-keyboard submit editing behavior. * fix(keyboard): close Add Project outside its state updater Root Escape previously called the parent close action from a React state updater, producing a cross-component render warning when navigation was still queued. Compute the previous page in the event handler and close outside the updater. The existing Escape/Back E2E now batches navigation and dismissal through the DOM key source. It failed first with the AddProjectFlowHost render warning, then passed after the correction. Typecheck, lint and format pass; native Escape checks pass on Android and iPad. * fix(keyboard): preserve Android dead-key composition * fix(keyboard): recognize focused native text inputs * test(keyboard): replace JSDOM coverage with browser tests * refactor(menu): spell out arrow navigation branches * fix(composer): claim fullscreen Escape on native * refactor(composer): one fullscreen Escape module for every platform The shared registration lived in fullscreen-exit.shared.ts with two one-line re-exports (fullscreen-exit.ts and fullscreen-exit.native.ts). Metro needs no native override now that the Android back button is its own adapter, so the implementation moves into fullscreen-exit.ts and the browser test imports the module every platform resolves. --------- Co-authored-by: Ubuntu <jeroenfrenken@icloud.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>
304 lines
11 KiB
TypeScript
304 lines
11 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.
|
|
// Vite can request the file with a version query (`md4c.js?v=<hash>`), so match the
|
|
// path without it.
|
|
if (!id.split("?")[0].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-react-components",
|
|
enforce: "pre",
|
|
transform(source, id) {
|
|
// Exercise compiler-sensitive labels and overlay lifetime in the same form as Expo.
|
|
const usageComponent = id.includes("/src/usage/") && id.endsWith(".tsx");
|
|
const overlayLayer = id.endsWith("/src/lib/overlay-root.ts");
|
|
if ((!usageComponent && !overlayLayer) || 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}",
|
|
"modules/**/*.{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({
|
|
// Exercise real WebGL glyph rendering on GPU-less CI runners.
|
|
launchOptions: { args: ["--enable-unsafe-swiftshader"] },
|
|
}),
|
|
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"),
|
|
},
|
|
],
|
|
},
|
|
});
|