diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91a60a1..6b342ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,38 @@ jobs: - name: Generate the third-party license file run: bun scripts/generate-third-party-licenses.ts --out "$RUNNER_TEMP/THIRD_PARTY_LICENSES.txt" --target x86_64-unknown-linux-gnu + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: { bun-version: 1.3.14 } + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: { toolchain: '1.91', targets: wasm32-unknown-unknown } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: taiki-e/install-action@76c2e6406e52637deed7160d77bded76bd83e06e # v2.87.14 + with: { tool: wasm-bindgen-cli@0.2.127 } + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: bun-${{ runner.os }}- + - run: bun install --frozen-lockfile + - name: Install Chromium for Playwright + run: bun x playwright install --with-deps chromium + working-directory: apps/app + - name: Run end-to-end tests against the browser build + run: bun run --cwd apps/app test:e2e + - name: Keep traces of failed tests + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-traces + path: apps/app/test-results/ + retention-days: 7 + rust: strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index 2fededf..9cb792c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ crates/workspace-format/bindings/ # Agent worktrees .claude/worktrees/ +# Playwright output +test-results/ +playwright-report/ diff --git a/apps/app/package.json b/apps/app/package.json index 663fe97..da2570f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -8,7 +8,8 @@ "build": "bun run wasm:build && vite build", "build:hosted": "bun run wasm:build && NOURA_HOSTED_BUILD=1 vite build", "check": "bun run wasm:build && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "lint": "prettier --check src", + "lint": "prettier --check src tests", + "test:e2e": "playwright test", "tauri": "tauri" }, "dependencies": { @@ -37,6 +38,7 @@ "jsdom": "30.0.1", "@fontsource-variable/public-sans": "^5.3.0", "@internationalized/date": "^3.12.0", + "@playwright/test": "1.62.1", "@sveltejs/adapter-static": "latest", "@sveltejs/kit": "latest", "@sveltejs/vite-plugin-svelte": "latest", diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts new file mode 100644 index 0000000..dbc0722 --- /dev/null +++ b/apps/app/playwright.config.ts @@ -0,0 +1,47 @@ +import { defineConfig } from '@playwright/test'; + +const port = Number(process.env.NOURA_E2E_PORT ?? 4317); +const baseURL = `http://localhost:${port}`; + +/** + * End-to-end tests drive the browser build of the app: the same shell, + * routes and editor as the desktop app, backed by the browser workspace + * worker and its origin-private file system. Every test gets a fresh browser + * context, so every test starts from an empty workspace list. + */ +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.e2e.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + // One retry on CI marks a timing-sensitive test as flaky instead of failing + // the build; the report still lists it so it gets fixed. + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + timeout: 60_000, + expect: { timeout: 10_000 }, + reporter: process.env.CI ? [['list'], ['github']] : 'list', + use: { + baseURL, + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + // Chromium's own user agent, not a device preset: the app reads the + // platform to pick Cmd or Ctrl, and so does ControlOrMeta. + use: { + browserName: 'chromium', + viewport: { width: 1400, height: 900 }, + }, + }, + ], + webServer: { + command: `bun run build && bun x vite preview --port ${port} --strictPort`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + stdout: 'ignore', + stderr: 'pipe', + }, +}); diff --git a/apps/app/src/app.css b/apps/app/src/app.css index dfb659e..a5b930b 100644 --- a/apps/app/src/app.css +++ b/apps/app/src/app.css @@ -36,6 +36,12 @@ --chart-3: oklch(0.439 0 0); --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); + /* Code highlighting, readable on --muted in each theme. */ + --syntax-keyword: #6f42c1; + --syntax-string: #116329; + --syntax-number: #953800; + --syntax-type: #0550ae; + --syntax-definition: #0a3069; --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); @@ -71,6 +77,11 @@ --chart-3: oklch(0.439 0 0); --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); + --syntax-keyword: #ff7b72; + --syntax-string: #a5d6ff; + --syntax-number: #79c0ff; + --syntax-type: #ffa657; + --syntax-definition: #d2a8ff; --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.922 0 0); @@ -240,6 +251,7 @@ text-decoration: line-through; } .live-md .cm-md-checkbox { + position: relative; display: inline-block; width: 0.9rem; height: 0.9rem; @@ -250,9 +262,18 @@ vertical-align: text-bottom; } .live-md .cm-md-checkbox[aria-checked='true'] { - background: var(--primary); + background-color: var(--primary); border-color: var(--primary); } +/* The check mark takes the theme's contrast color, like the Checkbox. */ +.live-md .cm-md-checkbox[aria-checked='true']::after { + content: ''; + position: absolute; + inset: 0; + background-color: var(--primary-foreground); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E") + center / 100% 100% no-repeat; +} .live-md .cm-md-image { display: block; padding: 0.35rem 0; diff --git a/apps/app/src/lib/app-shortcuts.test.ts b/apps/app/src/lib/app-shortcuts.test.ts index 35f23fb..3c20369 100644 --- a/apps/app/src/lib/app-shortcuts.test.ts +++ b/apps/app/src/lib/app-shortcuts.test.ts @@ -1,5 +1,20 @@ import { expect, test } from 'bun:test'; -import { shortcutCommand, type ShortcutEvent } from './app-shortcuts'; +import { + shortcutCommand, + shortcutOnce, + type ShortcutEvent, +} from './app-shortcuts'; + +test('a menu report of the same press runs once; repeated presses all run', () => { + const once = shortcutOnce(300); + expect(once('close-tab', 'key', 0)).toBe(true); + expect(once('close-tab', 'menu', 10)).toBe(false); + expect(once('close-tab', 'key', 100)).toBe(true); + expect(once('close-tab', 'key', 150)).toBe(true); + expect(once('close-tab', 'menu', 500)).toBe(true); + expect(once('close-tab', 'key', 520)).toBe(false); + expect(once('new-note', 'menu', 530)).toBe(true); +}); function press(key: string, extra: Partial = {}): ShortcutEvent { return { diff --git a/apps/app/src/lib/app-shortcuts.ts b/apps/app/src/lib/app-shortcuts.ts index ff7d632..331db6f 100644 --- a/apps/app/src/lib/app-shortcuts.ts +++ b/apps/app/src/lib/app-shortcuts.ts @@ -52,6 +52,26 @@ export function shortcutCommand( return GO_TO[key] ?? null; } +/** Where a command came from: a key press or the macOS menu bar. */ +export type ShortcutSource = 'key' | 'menu'; + +/** + * On a Mac the menu bar and the key press can both report one shortcut. + * The returned check lets the second report of a pair through only when it + * comes from the same source, so pressing a shortcut twice quickly (Cmd-W + * to close two tabs) still runs it twice. + */ +export function shortcutOnce(windowMs = 300) { + const last = new Map(); + return (command: string, source: ShortcutSource, now: number): boolean => { + const previous = last.get(command); + if (previous && previous.source !== source && now - previous.at < windowMs) + return false; + last.set(command, { at: now, source }); + return true; + }; +} + /** Shortcuts as listed in Settings, in the order they are shown. */ export const APP_SHORTCUTS: ReadonlyArray<{ label: string; @@ -62,6 +82,7 @@ export const APP_SHORTCUTS: ReadonlyArray<{ { label: 'Open a file by name', keys: ['Mod', 'O'] }, { label: 'Search file contents', keys: ['Mod', 'Shift', 'F'] }, { label: 'Search and run commands', keys: ['Mod', 'K'] }, + { label: 'Rename the open file', keys: ['F2'] }, { label: 'Close tab', keys: ['Mod', 'W'] }, { label: 'Next tab', keys: ['Ctrl', 'Tab'] }, { label: 'Previous tab', keys: ['Ctrl', 'Shift', 'Tab'] }, diff --git a/apps/app/src/lib/command-palette.svelte.ts b/apps/app/src/lib/command-palette.svelte.ts index cb23ae7..40983b5 100644 --- a/apps/app/src/lib/command-palette.svelte.ts +++ b/apps/app/src/lib/command-palette.svelte.ts @@ -12,6 +12,31 @@ type CommandPaletteMode = 'all' | 'files' | 'search'; class CommandPaletteStore { open = $state(false); mode = $state('all'); + /** Where focus was before the palette opened, to return it on close. */ + returnFocus: HTMLElement | null = null; + #stopTypeAhead: (() => void) | null = null; + /** Characters typed before the palette's field took focus. */ + #typedAhead = ''; + #receiver: ((text: string) => void) | null = null; + + /** + * The palette adds characters typed before its field had focus to its + * query through `receive`, including any typed before it mounted. + */ + receiveTypedAhead(receive: (text: string) => void): () => void { + this.#receiver = receive; + this.#deliver(); + return () => { + if (this.#receiver === receive) this.#receiver = null; + }; + } + + #deliver() { + if (!this.#receiver || !this.#typedAhead) return; + const text = this.#typedAhead; + this.#typedAhead = ''; + this.#receiver(text); + } toggle() { if (this.open) this.close(); @@ -20,12 +45,47 @@ class CommandPaletteStore { close() { this.open = false; + this.#stopTypeAhead?.(); } show(mode: CommandPaletteMode = 'all') { this.mode = mode; + if (!this.open) this.#takeKeys(); this.open = true; } + + /** + * The palette's field takes focus a moment after the palette opens, and + * people start typing right after the shortcut. Until the field has + * focus, the element that had it lets go, so those keys cannot edit the + * document behind the palette, and the palette collects them instead. + */ + #takeKeys() { + if (typeof document === 'undefined') return; + this.#stopTypeAhead?.(); + const active = document.activeElement; + this.returnFocus = + active instanceof HTMLElement && active !== document.body ? active : null; + this.returnFocus?.blur(); + this.#typedAhead = ''; + const collect = (event: KeyboardEvent) => { + if (event.target !== document.body) return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (event.key.length !== 1) return; + event.preventDefault(); + this.#typedAhead += event.key; + this.#deliver(); + }; + const stop = () => { + window.removeEventListener('keydown', collect, true); + document.removeEventListener('focusin', stop, true); + this.#stopTypeAhead = null; + }; + window.addEventListener('keydown', collect, true); + // Once anything takes focus, keys go where they belong again. + document.addEventListener('focusin', stop, true); + this.#stopTypeAhead = stop; + } } export const commandPalette = new CommandPaletteStore(); diff --git a/apps/app/src/lib/components/app-shortcuts.svelte b/apps/app/src/lib/components/app-shortcuts.svelte index d27be75..2912c9f 100644 --- a/apps/app/src/lib/components/app-shortcuts.svelte +++ b/apps/app/src/lib/components/app-shortcuts.svelte @@ -3,7 +3,11 @@ import { page } from '$app/state'; import { onMount } from 'svelte'; import { subscribeAppMenu, type AppMenuCommand } from '@noura/workspace'; - import { shortcutCommand } from '$lib/app-shortcuts'; + import { + shortcutCommand, + shortcutOnce, + type ShortcutSource, + } from '$lib/app-shortcuts'; import { commandPalette } from '$lib/command-palette.svelte'; import { createFolder, createNote } from '$lib/file-actions'; import { hostOs } from '$lib/host-os'; @@ -29,13 +33,11 @@ }; // On a Mac the menu bar and the key press can both report one shortcut; - // run each command once. - const lastRun: Partial> = {}; + // run each press once. + const once = shortcutOnce(); - async function run(command: AppMenuCommand) { - const now = performance.now(); - if (now - (lastRun[command] ?? -Infinity) < 300) return; - lastRun[command] = now; + async function run(command: AppMenuCommand, source: ShortcutSource) { + if (!once(command, source, performance.now())) return; const path = ROUTES[command]; if (path) { const pluginId = routePlugin(path); @@ -81,14 +83,14 @@ const command = shortcutCommand(event, os); if (!command) return; event.preventDefault(); - void run(command); + void run(command, 'key'); } onMount(() => { if (getAppPlatform() !== 'desktop') return; let disposed = false; let unsubscribe: (() => void) | undefined; - void subscribeAppMenu((command) => void run(command)) + void subscribeAppMenu((command) => void run(command, 'menu')) .then((stop) => { if (disposed) stop(); else unsubscribe = stop; diff --git a/apps/app/src/lib/components/command-palette.svelte b/apps/app/src/lib/components/command-palette.svelte index 85dec90..584bdec 100644 --- a/apps/app/src/lib/components/command-palette.svelte +++ b/apps/app/src/lib/components/command-palette.svelte @@ -1,6 +1,6 @@ + +
{#if crumbs.length > 0}