diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000000..f88fe24bc8 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,59 @@ +--- +name: verify +description: Drive the real Electron Fiddle app (test build) to verify a change end to end, from a sandboxed agent session on macOS or in the Linux container. +--- + +# Verify a change in the running app + +The handle is the in-house driver (`packages/app/tools/driver.ts`, docs in +`packages/app/e2e/README.md`). It builds `packages/app/out/test-build`, launches +real Electron in test mode, and drives the page through CDP. + +## Where Electron can run + +- **Linux container:** run driver and e2e commands outside the Bash sandbox + (Xvfb per app). `yarn start:xvfb` gives a headless dev run with + `FIDDLE_DEV_SCREENSHOT`, `FIDDLE_DEV_QUIT`, `FIDDLE_DEV_MENU_DUMP`. +- **macOS agent session (Claude desktop):** Electron cannot start inside the + Bash sandbox (mach port / `listen EPERM`). Run everything through the user's + Terminal panel (`mcp__terminal__run_in_terminal`, then `read_terminal` or a + tee'd log). Each call opens a tab and the panel has a small tab limit, so put + the whole drive in ONE script (see below). The test app runs as a background + accessory app and doesn't steal focus. + +## Recipe + +```bash +cat > /tmp/drive.sh <<'SH' +cd +d() { yarn driver "$@" 2>&1; } # NOT `yarn -s` (Yarn Berry has no -s) +d launch # builds the test build (~3 s) and starts the app +d wait-idle +d screenshot /tmp/01.png # capturePage: vibrancy areas render white +d snapshot > /tmp/01-a11y.txt # role "name" tree; use it to find targets +d click button "Add file" # role + accessible name (regex: "/^Electron [0-9]/") +d eval "JSON.stringify(document.querySelector('input')?.value)" +d press Escape +d run-command view.toggleSplit # any command ID from src/shared/commands.ts +d stores > /tmp/stores.json # App + Window store JSON +d press "CmdOrCtrl+Shift+O" # accelerators go through CDP key events +d violations; d logs +d quit +FIDDLE_DEV_MENU_DUMP=1 FIDDLE_DEV_QUIT=1 yarn start:xvfb > /tmp/menu.log 2>&1 # native menu template +SH +``` + +Run it with `run_in_terminal 'bash /tmp/drive.sh 2>&1 | tee /tmp/drive.log'`, +poll `read_terminal`, then read the log and PNGs from the sandbox. + +## Gotchas + +- `press Shift` alone is not a key; tooltips can be opened by focusing a button + after any key press (keyboard modality) and measured with `eval` + + `getBoundingClientRect()`. +- The menu dump prints before a window has focus, so most items show + `(disabled)`; structure and accelerators are what to read. +- Fixture data is small (a few releases), so list virtualization is better judged + by the jsdom tests; the picker's look and width are visible here. +- `yarn test:e2e ` is the regression net; on macOS it also must run from + the Terminal panel. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5e4251f206..d0050206b8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,16 @@ version: 2 updates: - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "daily" + interval: 'daily' + cooldown: + default-days: 7 + # Matches `npmMinimalAgeGate` in .yarnrc.yml. Forge, its plugins and Vite are + # pinned exactly, so each upgrade arrives as its own PR. + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'weekly' cooldown: default-days: 7 diff --git a/.github/images/linux.png b/.github/images/linux.png deleted file mode 100644 index 6d9e0c07db..0000000000 Binary files a/.github/images/linux.png and /dev/null differ diff --git a/.github/images/macos.png b/.github/images/macos.png deleted file mode 100644 index 0ea4bfdec2..0000000000 Binary files a/.github/images/macos.png and /dev/null differ diff --git a/.github/images/windows.png b/.github/images/windows.png deleted file mode 100644 index b8673e8319..0000000000 Binary files a/.github/images/windows.png and /dev/null differ diff --git a/.github/workflows/add-to-project.yml b/.github/workflows/add-to-project.yml index 254b6d9f2a..03fa8bf177 100644 --- a/.github/workflows/add-to-project.yml +++ b/.github/workflows/add-to-project.yml @@ -12,6 +12,8 @@ permissions: {} jobs: add-to-project: + # The project and its app credentials exist only in the electron org. + if: github.repository_owner == 'electron' runs-on: ubuntu-latest steps: - name: Generate GitHub App token diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 7ce23d4f3a..0000000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Build - -# Called from ci -on: - workflow_call: - -jobs: - build: - name: Build - strategy: - fail-fast: false - matrix: - os: - - macos-15-intel - - ubuntu-latest - - windows-latest - arch: - - x64 - include: - - os: macos-latest - arch: arm64 - - os: ubuntu-24.04-arm - arch: armv7l - runs-on: '${{ matrix.os }}' - steps: - - run: git config --global core.autocrlf input - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: .nvmrc - cache: 'yarn' - - name: Install dependencies - run: yarn --immutable - - run: yarn run contributors - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: yarn run electron-releases - # Artifacts generated are unsigned - - name: Run Forge makers - run: yarn run make diff --git a/.github/workflows/check-download-links.yml b/.github/workflows/check-download-links.yml index bf99ae1329..77458d8fa8 100644 --- a/.github/workflows/check-download-links.yml +++ b/.github/workflows/check-download-links.yml @@ -51,4 +51,4 @@ jobs: name: audit-results path: audit-results.md archive: false - if-no-files-found: warn \ No newline at end of file + if-no-files-found: warn diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f36304ad3..15fa03ecf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,25 +5,132 @@ on: push: branches: - main + # Called from release.yml, so tests gate every release. + workflow_call: -permissions: - contents: read +permissions: {} + +# A new push cancels the pull request run it supersedes. Runs on main and on +# release tags are never cancelled. +concurrency: + group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: test: + name: Test (${{ matrix.os }}) + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + runs-on: ${{ matrix.os }} permissions: contents: read - uses: ./.github/workflows/test.yml - build: + steps: + - run: git config --global core.autocrlf input + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: yarn + # The app's postinstall and `yarn package` download the same Electron zip. + - name: Cache the Electron download + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/electron + ~/Library/Caches/electron + ~/AppData/Local/electron/Cache + key: electron-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: electron-${{ runner.os }}- + # The e2e tests need the Electron binary that the app's postinstall downloads. + - name: Install dependencies + run: yarn install --immutable + # `git add -N` makes a generated file that was never committed show up in + # the diff. + - name: Generated source is up to date + shell: bash + run: | + yarn generate + git add --intent-to-add . + git diff --exit-code + # Missing or unused keys, placeholder, tag and plural mismatches, and the + # translate script's own tests. + - name: i18n catalogs + shell: bash + run: | + yarn i18n:check + yarn i18n:test + - name: Format + run: yarn format:check + - name: Lint + run: yarn lint + - name: Typecheck + run: yarn typecheck + - name: Unit tests + run: yarn test + # Fiddles start through this helper on macOS. The self-check proves it + # disclaims responsibility on this macOS version. + - name: Privacy helper self-check (macOS) + if: runner.os == 'macOS' + run: | + packages/app/native/disclaim/build.sh + packages/app/native/disclaim/build/selfcheck packages/app/native/disclaim/build/fiddle-disclaim + # On Linux the e2e driver starts its own Xvfb display per app. + - name: End-to-end tests + run: yarn test:e2e + # Unsigned: no signing variables are set here. + - name: Package smoke test + run: yarn package + # Also checks that the e2e test build (out/test-build) still has every test-only marker. + - name: Test-only code stays out of the package and in the test build + run: node tools/release-check-asar.mjs packages/app/out --test-build + - name: Check the privacy helper in the package (macOS) + if: runner.os == 'macOS' + run: | + for app in packages/app/out/*/"Electron Fiddle.app"; do + packages/app/native/disclaim/check-packaged.sh "$app" + done + + identity: + name: App identity + runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read - uses: ./.github/workflows/build.yml + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: yarn + - name: Install dependencies + run: yarn install --immutable --mode=skip-build + # The identity must match the previous release, or its settings, + # credentials and installs aren't found. + - name: Check app identity + run: node tools/release-identity.mjs + gha-done: name: GitHub Actions Completed runs-on: ubuntu-latest - needs: [test, build] - if: always() && !contains(needs.*.result, 'failure') + needs: + - test + - identity + if: always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') + timeout-minutes: 5 + permissions: {} steps: - - name: GitHub Actions Jobs Done - run: | - echo "All GitHub Actions Jobs are done" + - run: echo "All GitHub Actions jobs are done" diff --git a/.github/workflows/coveralls.yml b/.github/workflows/coveralls.yml deleted file mode 100644 index 46a42e7e66..0000000000 --- a/.github/workflows/coveralls.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Coveralls - -on: - push: - branches: - - main - pull_request: - -permissions: read-all - -jobs: - test: - name: Check Coverage - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 22.x - cache: yarn - - name: Install - run: yarn --immutable - - run: yarn run contributors - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: yarn run electron-releases - - name: test - run: yarn test:ci - - name: Coveralls - uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2.3.8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/refresh-data.yml b/.github/workflows/refresh-data.yml new file mode 100644 index 0000000000..005c5d2b7a --- /dev/null +++ b/.github/workflows/refresh-data.yml @@ -0,0 +1,47 @@ +name: Refresh bundled data + +# The Electron release list and the contributors list are committed snapshots +# that this bot PR refreshes weekly. Run it by hand before a release. +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: {} + +jobs: + refresh: + name: Refresh releases and contributors + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + # Plain Node with no dependencies: nothing is installed, so no + # dependency code runs in a job that can push. + - name: Refresh data + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node tools/release-data.mjs + # PRs opened with GITHUB_TOKEN don't trigger CI. Close and reopen the PR + # to run it. + - name: Open pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + branch: bot/refresh-data + delete-branch: true + commit-message: 'chore: refresh bundled Electron releases and contributors' + title: 'chore: refresh bundled Electron releases and contributors' + body: Automated refresh of `packages/app/static/releases.json` and `packages/app/static/contributors.json` by `tools/release-data.mjs`. + add-paths: | + packages/app/static/releases.json + packages/app/static/contributors.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0589020c50..7cbbc68913 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,12 +7,20 @@ on: permissions: {} -# Release paths never touch the Actions cache; enforced regardless of per-step settings. -cache-mode: none +# Each platform runs `publish --dry-run`; `release` runs `--from-dry-run`. +# Jobs with secrets install with `--mode=skip-build`, so no dependency scripts +# run there. Only `build-windows` (Azure) and `attest` (Sigstore) can request +# OIDC tokens; `attest` runs no repository or dependency code. jobs: + test: + name: Test + permissions: + contents: read + uses: ./.github/workflows/ci.yml + build: - name: Build + name: Build (${{ matrix.os }}, ${{ matrix.arch }}) strategy: fail-fast: false matrix: @@ -28,9 +36,8 @@ jobs: arch: armv7l - os: ubuntu-24.04-arm arch: arm64 - runs-on: "${{ matrix.os }}" + runs-on: ${{ matrix.os }} permissions: - actions: write contents: read environment: release steps: @@ -40,46 +47,58 @@ jobs: with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc package-manager-cache: false - - run: yarn install --immutable - - run: yarn run contributors - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: yarn run electron-releases - - name: Install dependencies (Linux) - if: ${{ startsWith(matrix.os, 'ubuntu-') }} - run: sudo apt-get update && sudo apt install rpm squashfs-tools + - name: Install dependencies + run: yarn install --immutable --mode=skip-build + - name: Install packaging tools (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y rpm squashfs-tools - name: Load certificates (macOS) - if: ${{ startsWith(matrix.os, 'macos-') }} + if: runner.os == 'macOS' env: MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} - run: chmod +x tools/add-macos-cert.sh && . ./tools/add-macos-cert.sh + run: bash packages/app/build/add-macos-cert.sh - name: Build (macOS) - if: ${{ startsWith(matrix.os, 'macos-') }} + if: runner.os == 'macOS' env: + APPLE_SIGNING_IDENTITY: 'Developer ID Application: OpenJS Foundation, Inc. (UY52UFTVTM)' APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - run: yarn run publish --arch=${{ matrix.arch }} --dry-run # zizmor: ignore[use-trusted-publishing] + run: yarn workspace electron-fiddle run publish --arch=${{ matrix.arch }} --dry-run # zizmor: ignore[use-trusted-publishing] + - name: Check the app is notarized (macOS) + if: runner.os == 'macOS' + run: | + for app in packages/app/out/*/"Electron Fiddle.app"; do + xcrun stapler validate "$app" + done + - name: Check the privacy helper is signed (macOS) + if: runner.os == 'macOS' + run: | + for app in packages/app/out/*/"Electron Fiddle.app"; do + packages/app/native/disclaim/check-packaged.sh "$app" --release + done - name: Build (Linux) - if: ${{ startsWith(matrix.os, 'ubuntu-') }} - run: yarn run publish --arch=${{ matrix.arch }} --dry-run # zizmor: ignore[use-trusted-publishing] - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: runner.os == 'Linux' + run: yarn workspace electron-fiddle run publish --arch=${{ matrix.arch }} --dry-run # zizmor: ignore[use-trusted-publishing] + - name: Check that the e2e driver is not packaged + run: node tools/release-check-asar.mjs packages/app/out + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: build-artifacts-${{ matrix.os }}-${{ matrix.arch }} - path: out - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - if: ${{ startsWith(matrix.os, 'ubuntu-') && matrix.arch == 'x64' }} + path: packages/app/out + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ matrix.os == 'ubuntu-latest' && matrix.arch == 'x64' }} with: - name: webpack-source-maps - path: .webpack + name: vite-source-maps + path: packages/app/.vite include-hidden-files: true build-windows: - name: Build (Windows) + name: Build (windows-latest, ${{ matrix.arch }}) strategy: fail-fast: false matrix: @@ -88,10 +107,8 @@ jobs: - ia32 runs-on: windows-latest permissions: - actions: write contents: read - # Required for the OIDC login to Azure used for code signing. Kept in - # its own job so the macOS and Linux runners can't request ID tokens. + # For the Azure OIDC login. Only this job can request ID tokens. id-token: write environment: release steps: @@ -101,15 +118,12 @@ jobs: with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc package-manager-cache: false - - run: yarn install --immutable - - run: yarn run contributors - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: yarn run electron-releases + - name: Install dependencies + run: yarn install --immutable --mode=skip-build - name: Azure login uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: @@ -136,35 +150,59 @@ jobs: "WINDOWS_SIGNTOOL_PATH=$($signtool.FullName)" >> $env:GITHUB_ENV - name: Build (Windows) env: - # AZURE_CODE_SIGNING_DLIB and WINDOWS_SIGNTOOL_PATH come from the - # previous step via GITHUB_ENV. The values below identify the Trusted - # Signing account and are not secrets; the endpoint must match the - # region the account was created in. + # The DLIB and signtool paths come from the previous step. The values + # below are not secrets; the endpoint must match the account's region. AZURE_CODE_SIGNING_ENDPOINT: https://eus.codesigning.azure.net AZURE_CODE_SIGNING_ACCOUNT_NAME: OpenJS-CodeSigning AZURE_CODE_SIGNING_CERTIFICATE_PROFILE_NAME: Electron - run: yarn run publish --arch=${{ matrix.arch }} --dry-run # zizmor: ignore[use-trusted-publishing] - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + run: yarn workspace electron-fiddle run publish --arch=${{ matrix.arch }} --dry-run # zizmor: ignore[use-trusted-publishing] + - name: Check that the e2e driver is not packaged + run: node tools/release-check-asar.mjs packages/app/out + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: build-artifacts-windows-latest-${{ matrix.arch }} - path: out + path: packages/app/out - test: + attest: + name: Attest build provenance + runs-on: ubuntu-latest + needs: + - build + - build-windows permissions: - contents: read - uses: ./.github/workflows/test.yml + id-token: write + attestations: write + steps: + - name: Download all artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: out + pattern: build-artifacts-* + merge-multiple: true + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + out/make/**/*.zip + out/make/**/*.exe + out/make/**/*.nupkg + out/make/**/RELEASES + out/make/**/*.msix + out/make/**/*.deb + out/make/**/*.rpm + out/make/**/*.AppImage release: name: Release runs-on: ubuntu-latest needs: + - test - build - build-windows - - test + - attest environment: release permissions: - contents: read - id-token: write + # Creates the draft release with the job's own token. + contents: write steps: - run: git config --global core.autocrlf input - name: Checkout @@ -172,31 +210,29 @@ jobs: with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '22.17.x' + node-version-file: .nvmrc package-manager-cache: false - - run: yarn install --immutable - - name: Download All Artifacts - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + - name: Install dependencies + run: yarn install --immutable --mode=skip-build + - name: Download all artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - path: out + path: packages/app/out pattern: build-artifacts-* merge-multiple: true - - name: Get GitHub app token - id: secret-service - uses: electron/secret-service-action@3476425e8b30555aac15b1b7096938e254b0e155 # v1.0.0 - name: Publish to GitHub env: - GITHUB_TOKEN: ${{ fromJSON(steps.secret-service.outputs.secrets).GITHUB_TOKEN }} - run: yarn run publish --from-dry-run + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: yarn workspace electron-fiddle run publish --from-dry-run # zizmor: ignore[use-trusted-publishing] notify-sentry-deploy: name: Notify Sentry Deploy runs-on: ubuntu-latest needs: release permissions: - actions: read + contents: read steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -204,17 +240,33 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Download source maps artifact - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: vite-source-maps + path: packages/app/.vite + # No `v` prefix: the release name is the one the app reports to Sentry. + - name: Read version + id: version + run: echo "version=$(node -p "require('./packages/app/package.json').version")" >> "$GITHUB_OUTPUT" + # Frame URLs: the renderer is served from app://main/, so its maps match + # `~/assets/...`; main and preload frames read app:///.vite/build/.... + - uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3.7.0 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: electronjs + SENTRY_PROJECT: electron-fiddle with: - name: webpack-source-maps - path: .webpack - - uses: getsentry/action-release@6fb9aeade7061441cf3864e34fbe11a1457ad7dc # v3.7.1 + release: Electron-Fiddle@${{ steps.version.outputs.version }} + sourcemaps: ./packages/app/.vite/renderer/main_window + url_prefix: '~/' + - uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3.7.0 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: electronjs SENTRY_PROJECT: electron-fiddle with: environment: production - release: Electron-Fiddle@${{ github.ref_name }} - sourcemaps: ./.webpack/ - url_prefix: '~/.webpack' + release: Electron-Fiddle@${{ steps.version.outputs.version }} + set_commits: skip + sourcemaps: ./packages/app/.vite/build + url_prefix: '~/.vite/build' diff --git a/.github/workflows/semantic.yml b/.github/workflows/semantic.yml index 56e07e2f0a..95094e3867 100644 --- a/.github/workflows/semantic.yml +++ b/.github/workflows/semantic.yml @@ -1,4 +1,4 @@ -name: "Check Semantic Commit" +name: 'Check Semantic Commit' on: pull_request: @@ -13,8 +13,8 @@ permissions: jobs: main: permissions: - pull-requests: read # for amannn/action-semantic-pull-request to analyze PRs - statuses: write # for amannn/action-semantic-pull-request to mark status of analyzed PR + pull-requests: read # for amannn/action-semantic-pull-request to analyze PRs + statuses: write # for amannn/action-semantic-pull-request to mark status of analyzed PR name: Validate PR Title runs-on: ubuntu-latest steps: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 1a2ef46557..0000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Called during ci and release -name: Test - -on: - workflow_call: - -jobs: - test: - name: Test - strategy: - fail-fast: false - matrix: - os: - - macos-15-intel - - ubuntu-latest - - windows-latest - arch: - - x64 - include: - - os: macos-latest - arch: arm64 - - os: ubuntu-24.04-arm - arch: armv7l - runs-on: '${{ matrix.os }}' - steps: - - run: git config --global core.autocrlf input - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: .nvmrc - cache: 'yarn' - - name: Install dependencies - run: yarn --immutable - - run: yarn run contributors - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: yarn run electron-releases - - name: Lint and format - run: yarn run lint && yarn run format - - name: Test - run: yarn tsc && yarn test:ci diff --git a/.gitignore b/.gitignore index 19581d3430..c4ca293c84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,54 +1,12 @@ -.DS_Store -.env -.gclient_done -**/.npmrc -.tags* -.vs/ -.vscode/ -*.log -*.pyc -*.sln -*.swp -*.VC.db -*.VC.VC.opendb -*.vcxproj -*.vcxproj.filters -*.vcxproj.user -*.xcodeproj -/.idea/ -/external_binaries/ -/out/ node_modules/ -SHASUMS256.txt -compile_commands.json -.envrc -.cache -yarn-error.log -*.p12 - -# Coverage -coverage - -# Test report -report.json - -# npm package -/npm/dist -/npm/path.txt - -# Electron Fiddle-Specific -/static/contributors.json -/static/css/root.css -/static/releases.json - -# Webpack configurations -.webpack - -# Yarn v4 -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions \ No newline at end of file +out/ +dist/ +.vite/ +coverage/ +*.log +.DS_Store +.eslintcache +*.tsbuildinfo +/test-results/ +.yarn/install-state.gz +/packages/app/native/disclaim/build/ diff --git a/.husky/.gitignore b/.husky/.gitignore deleted file mode 100644 index 31354ec138..0000000000 --- a/.husky/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 1f1b426d4e..0000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -yarn run lint-staged diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index 5336389f14..0000000000 --- a/.markdownlint.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "@electron/lint-roller/configs/markdownlint.json" -} diff --git a/.nvmrc b/.nvmrc index e763ca155d..a45fd52cc5 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.17 \ No newline at end of file +24 diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index edae635b5b..0000000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript", "import", "react", "vitest"], - "categories": { - "correctness": "error" - }, - "env": { - "browser": true, - "node": true, - "es2024": true - }, - "rules": { - "no-unused-vars": [ - "warn", - { - "argsIgnorePattern": "^_", - "caughtErrors": "none" - } - ], - "sort-imports": [ - "error", - { - "ignoreCase": false, - "ignoreDeclarationSort": true, - "ignoreMemberSort": false, - "memberSyntaxSortOrder": ["none", "all", "multiple", "single"], - "allowSeparatedGroups": true - } - ], - "import/default": "error", - "import/no-duplicates": "warn", - "import/no-named-as-default": "warn", - "import/namespace": "off", - "import/no-named-as-default-member": "off", - "react/react-in-jsx-scope": "error", - "react/display-name": "error", - "react/jsx-no-comment-textnodes": "error", - "react/jsx-no-target-blank": "error", - "react/no-unescaped-entities": "error", - "react/no-unknown-property": "error", - "typescript/no-array-constructor": "error", - "typescript/no-namespace": "error", - "typescript/no-unnecessary-type-constraint": "error", - "typescript/no-unsafe-function-type": "error", - "vitest/require-mock-type-parameters": "off", - "react/no-did-mount-set-state": "off", - "vitest/expect-expect": "off", - "vitest/no-conditional-expect": "off", - "vitest/no-standalone-expect": "off", - "vitest/require-to-throw-message": "off" - }, - "ignorePatterns": [ - "/out", - "/.webpack", - "/coverage", - "/node_modules", - "/.yarn" - ] -} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..ecdcb1c365 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +.yarn/ +node_modules/ +out/ +dist/ +.vite/ +coverage/ +docs/design/ +packages/app/static/ +packages/app/src/ipc/generated/ +packages/app/src/i18n/generated/ +packages/app/src/main/migration/fixtures/ +THIRD_PARTY_NOTICES.md diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index dcbb3f84de..0000000000 --- a/.prettierrc.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - trailingComma: 'all', - singleQuote: true, - overrides: [ - { - // The bundled templates are semicolon-free, unlike src/. - files: 'static/**/*.js', - options: { - semi: false, - trailingComma: 'none', - }, - }, - ], -}; diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000000..cc82d22d7f --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 90 +} diff --git a/.stylelintrc b/.stylelintrc deleted file mode 100644 index a021f77b93..0000000000 --- a/.stylelintrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "stylelint-config-standard", - "customSyntax": "postcss-less", - "ignoreFiles": [ - "./src/less/*-vendor.less" - ], - "rules": { - "alpha-value-notation": null, - "color-function-notation": null, - "import-notation": "string" - } -} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..918ccde042 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# Electron Fiddle + +- **Design:** "Lucent", in `docs/design/`: + - `lucent-handover.html` is the design system reference: principles, materials, layout, components and specimens. + - `lucent-tokens.css` holds the tokens, verbatim. + +## Rules + +- **Prefer the simple option.** Over-engineering is the main risk. If a simple option works, use it. +- **Tokens:** every colour, radius, shadow and duration in component styles comes from a `--lu-*` token. No literals. +- **Strings:** every user-visible string comes from the i18n catalog, in sentence case. Edit `packages/app/src/i18n/locales//.json`, give every English key a `description`, then run `yarn generate`. +- **IPC:** only through EIPC-generated bindings. Never expose `ipcRenderer`. Errors are `FiddleError`s with a stable `code`, thrown and caught normally. +- **State:** main owns all state (`App` and `Window` stores through the `StateHub`). Renderers render and request changes. +- **Fiddle logic:** `packages/app/src/fiddle/` must not import `electron`, so it runs under plain Node in tests. +- **Persistence:** `createJsonStore()` for every JSON file, and `writeAtomic()` for any other file main replaces. +- **CSP:** add a Trusted Types policy name in `src/main/csp.ts` before calling `trustedTypes.createPolicy`. +- **Dev tooling:** new dev-only commands are marked `devOnly` in `src/shared/commands.ts` and go in the Develop menu. +- **Tests:** no Playwright or WebDriver. End-to-end tests use the in-house driver. +- **Installs:** run `yarn install` only through `flock /tmp/fiddle-2027-yarn.lock yarn install`, because several agents share this checkout. +- **Git:** pull before you start changing code, and pull with rebase before you commit. Commit when asked. + - Stage your own files by name. Several agents share this checkout, so leave their changes out. + - `origin` uses SSH, which the Bash sandbox blocks. Fetch `https://github.com/electron/fiddle.git main` instead, then rebase onto `FETCH_HEAD`. + +## Commands + +Run from the repo root. + +- `yarn start`: dev run. Forge serves the renderer from the Vite dev server with hot reload. +- `yarn start:xvfb`: headless dev run for agents. It builds main, preload and renderer in development mode, then starts Electron under `xvfb-run`, loading `app://main` exactly like a packaged build. Extra arguments go to Electron. + - `FIDDLE_DEV_SCREENSHOT=/tmp/x.png FIDDLE_DEV_QUIT=1 yarn start:xvfb` saves a screenshot of the first window, then quits. + - `FIDDLE_DEV_MENU_DUMP=1` logs the application menu and the context menus' templates. + - Logs are prefixed `[fiddle]`. + - In this container, run it outside the Bash sandbox, because Xvfb can't start inside it. +- `yarn generate`: EIPC bindings and compiled i18n catalogs. It's offline and idempotent, and start, typecheck, lint, test, package and make all run it. +- `yarn typecheck` (`tsc -b`), `yarn lint`, `yarn format`. +- `yarn test`: every Vitest project (`core`, `app:node`, `app:jsdom`). For one project: `yarn vitest run --project app:node`. `yarn test --coverage` adds a line-coverage report. +- `yarn package`, `yarn make`: Forge 8, output in `packages/app/out/`. +- `yarn fiddle `: the headless CLI against the dev build. See "Headless CLI" below. +- `yarn test:e2e`, `yarn driver `: end-to-end tests and the interactive driver. See "E2E" below. + +## E2E + +The full guide, with a template spec, is in `packages/app/e2e/README.md`. + +- **Run.** `yarn test:e2e` builds the test build once (`packages/app/out/test-build`, Vite mode `test`), then runs every `packages/app/e2e/*.e2e.ts` in parallel forks. Each spec file gets its own app, temp dir and fixture server. + - Run it outside the Bash sandbox (on macOS, in the Terminal panel: Electron can't start in the sandbox). + - `yarn test:e2e smoke` runs one file. `FIDDLE_E2E_SKIP_BUILD=1` reuses the last build, and `FIDDLE_E2E_VERBOSE=1` echoes the app's output. + - `FIDDLE_E2E_WORKERS=` sets the parallelism. On macOS the app runs in the background and never takes focus. `FIDDLE_E2E_FOREGROUND=1` shows the windows in front. +- **Explore.** Start with `yarn driver launch`, then run `snapshot`, `click button Settings`, `type`, `press Enter`, `screenshot` or `logs`, and finish with `yarn driver quit`. Every command prints JSON, and the app stays up between commands. The full list is in the header of `packages/app/tools/driver.ts`. +- **Write a spec.** `const app = useApp()`, from `e2e/harness.ts`: + - Find elements by role and name, for example `app().click(role('button', 'Run'))`. Queries auto-wait, so never sleep. + - `runCommand(id)` runs a command, `stores()` reads state, and `queueDialog()` answers the next native dialog. + - A key the page doesn't handle never reaches the native menu, so shortcuts under test go through the renderer's keybinding dispatcher. +- **Test mode** (`src/main/test-mode.ts`) is on only in test builds launched with `FIDDLE_TEST_MODE=1`, which the launcher sets. Every feature must use: + - `getEndpoints()` for every network URL (`src/shared/endpoints.ts`). In test mode these point at the fixture server in `e2e/fixtures/`, and any non-loopback request fails the test. + - `getCacheRoot()` for the `core` cache. + - `isTestMode()` to skip what tests must not trigger: updates, Sentry, first-run prompts and the tour. + - Native dialogs through Electron's `dialog` module, so the driver can script them. + - `shell`, protocol, recent-document, notification and `Menu.popup` calls through Electron as usual. Test mode records and stubs them. + - `win.show()` and `win.focus()` for windows, never `app.focus()` or `webContents.focus()`, which would pull the app in front of whoever runs the tests on macOS. +- **Release builds.** Test-only code is guarded by `TEST_BUILD` and compiled out. `node tools/release-check-asar.mjs` verifies that after `yarn package`, and after `yarn test:e2e` also that the test build still has every marker. + +## Dev differences + +These apply only to unpackaged runs, and the first two only to `yarn start`. `yarn start:xvfb` loads `app://main` like a packaged build. + +- The renderer comes from the Vite dev server (`http://localhost:`) instead of `app://main`. The EIPC validator accepts that origin only when `is_packaged is false`. +- The CSP is added to dev-server responses by `webRequest` (`src/main/csp.ts`). It allows React Refresh's inline preamble and the hot-reload websocket. +- On macOS, every unpackaged run (`yarn start`, `yarn fiddle`, the driver and e2e) uses Chromium's mock keychain (`--use-mock-keychain`), so it never triggers a Keychain prompt. Its GitHub token is then only obfuscated and lives in `credentials/github-dev` instead of the installed app's `credentials/github`, and the first-launch import can't read the old app's token. + +## Headless CLI + +The code is `packages/app/src/main/cli/`. To add a command, add a descriptor in `descriptors.ts`, its help strings (`mainCli` keys `cmd` and `arg`) and a handler in `commands.ts`. + +- **Run.** `yarn fiddle [--json]` builds main in development mode, then runs `electron --headless ` in your directory. `yarn fiddle --help` and ` --help` list the commands and their options. `FIDDLE_CLI_SKIP_BUILD=1` reuses the last build, and `FIDDLE_CLI_VERBOSE=1` shows main's logs on stderr. Run `yarn generate` first if the catalogs changed. + - Run it outside the Bash sandbox. Headless mode needs no display, but a fiddle that opens windows does: use `xvfb-run -a yarn fiddle run ...`, with `FIDDLE_DEV_ELECTRON_FLAGS=--no-sandbox` as root. +- **Trust.** `run`, `bisect`, `package` and `make` on a remote fiddle (a gist, or `electron:/`) need `--trust` or a "y" at the TTY prompt. Without a TTY they fail with `untrusted` before anything runs. +- **Token.** `GITHUB_TOKEN` is the only token the CLI uses. + +## Where things are + +Paths are under `packages/app/` unless noted. + +- `src/main/`: the main process. `index.ts` is the entry, `services.ts` creates the services, `state-hub.ts` owns the stores, `app-commands.ts` has the command handlers. +- `src/renderer/`: the React renderer. `src/ui/`: the Lucent component library. +- `src/shared/`: types shared by both sides: stores, commands and endpoints. +- `src/ipc/fiddle.eipc`: the IPC schema. The bindings in `src/ipc/generated/` are committed. +- `src/fiddle/`: fiddle logic, plain Node. `src/i18n/`: catalogs and tooling. +- `e2e/` and `tools/`: end-to-end specs, the driver and the build tooling. +- `packages/core/`: the `fiddle-core` port. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1f1d08926..2e087546ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,95 +1,87 @@ # Contributing to Electron Fiddle Electron Fiddle is a community-driven project, overseen by the [Electron Ecosystem Working -Group](https://github.com/electron/governance/tree/main/wg-ecosystem#readme). As such, we welcome -and encourage all sorts of contributions. They include, but are not limited to: +Group](https://github.com/electron/governance/tree/main/wg-ecosystem#readme). We welcome all +sorts of contributions, including constructive feedback, bug reports, documentation changes, +feature requests and [pull requests](#filing-pull-requests). -- Constructive feedback -- Bug reports / technical issues -- Documentation changes -- Feature requests -- [Pull requests](#filing-pull-requests) +Before filing an issue, please search the existing issues to see if someone has already filed it. -We strongly suggest that before filing an issue, you search through the existing issues to see -if it has already been filed by someone else. +All contributions follow [Electron's code of conduct](https://github.com/electron/electron/blob/main/CODE_OF_CONDUCT.md). -This project is a part of the Electron ecosystem. As such, all contributions to this project follow -[Electron's code of conduct](https://github.com/electron/electron/blob/main/CODE_OF_CONDUCT.md) -where appropriate. +## Filing pull requests -## Contribution Suggestions +- GitHub Actions checks every commit: lint, typecheck, unit and end-to-end tests, a package + smoke test on macOS, Windows and Linux, and that generated source is up to date. +- Please write tests for your changes unless it's impractical. +- Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages + and pull request titles. +- Please **don't** bump the version number. The maintainers do that. You're welcome to say + whether a change needs a major, minor or patch bump under [semantic versioning](https://semver.org/). -We use the label [`good first issue`](https://github.com/electron/fiddle/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) in the issue tracker to denote fairly-well-scoped-out bugs or feature requests that the community can pick up and work on. If any of those labeled issues do not have enough information, please feel free to ask constructive questions. (This applies to any open issue.) +## Running Fiddle from source -## Filing Pull Requests +You need the Node.js version in `.nvmrc`. Yarn is vendored in `.yarn/releases`, so any `yarn` +(for example via `corepack enable`) runs the right version. -Here are some things to keep in mind as you file pull requests to fix bugs, add new features, etc.: - -- If you're unfamiliar with forking, branching, and pull requests, please see [GitHub's extensive - documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests). -- GitHub Actions is used to make sure that any new or changed code meets the project's - style guidelines, and that the project's test suite passes for each new commit. -- Unless it's impractical, please write tests for your changes. This will help us so that we can - spot regressions much easier. -- When creating commit messages and pull request titles, please adhere to the [conventional - commits](https://www.conventionalcommits.org/en/v1.0.0/) standard. -- Please **do not** bump the version number in your pull requests, the maintainers will do that. - Feel free to indicate whether the changes require a major, minor, or patch version bump, as - prescribed by the [semantic versioning specification](https://semver.org/). - -### Running Fiddle From Source +```sh +git clone https://github.com/electron/fiddle.git +cd fiddle +yarn +yarn start +``` -1. Clone the Electron Fiddle repository locally. +Useful commands, all run from the repository root: - ```sh - git clone https://github.com/electron/fiddle.git - ``` +| Command | What it does | +| ----------------------------- | ------------------------------------------------------------------- | +| `yarn start` | Development run with hot reload. | +| `yarn test` | Unit and component tests (Vitest). | +| `yarn test:e2e` | End-to-end tests against a real build. | +| `yarn lint`, `yarn typecheck` | ESLint and `tsc -b`. | +| `yarn generate` | Regenerates the EIPC bindings and i18n catalogs. Commit the output. | +| `yarn package`, `yarn make` | Package the app or build installers into `packages/app/out/`. | -2. Change directory to where Fiddle has been cloned. +### Install scripts - ```sh - cd fiddle - ``` +`.yarnrc.yml` sets `enableScripts: false`, so dependencies can't run install scripts. Workspace +scripts still run: the app's `postinstall` runs Electron's `install-electron` to download its +binary. If a new dependency genuinely needs its install script, allow it with `dependenciesMeta` +in the root `package.json` and explain why in the pull request. -3. Install dependencies. +## Release process - ```sh - yarn - ``` +> **Note:** Releasing is only available to contributors who have write access to the +> `electron/fiddle` repository. -4. Start Fiddle and explore! +1. On a new branch, bump the version with `yarn workspace electron-fiddle version `, + then open a pull request and merge it. +2. Tag the merge commit with the new version, prefixed with `v` (for example `v1.2.3`), and push + the tag with `git push origin `. +3. The `Release` workflow tests, builds, signs and notarizes every platform, attests the build + provenance, and drafts a GitHub release with generated notes. +4. Check that all the expected files are present and that the installers work, then publish the + release. - ```sh - yarn start - ``` +### Signed local builds -### Running Tests +`yarn make` signs the macOS app when `APPLE_SIGNING_IDENTITY` names a Developer ID Application +certificate in your keychain, and notarizes it when one of three sets of credentials is also set. +Without the identity the build is unsigned, as in CI. ```sh -yarn test -``` - -Note that console calls, e.g. console.log(), are mocked out during tests -to avoid noise in the test reports. If you want this verbosity, e.g. if -you're debugging or writing new tests, it can be enabled by setting the -environment variable `FIDDLE_VERBOSE_TESTS` when running. +export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)" -## Release Process +# One of: an Apple ID with an app-specific password, +export APPLE_ID="you@example.com" APPLE_ID_PASSWORD="xxxx-xxxx-xxxx-xxxx" +# an App Store Connect API key (APPLE_API_ISSUER only for a team key), +export APPLE_API_KEY="$HOME/AuthKey_ABC123DEF4.p8" APPLE_API_KEY_ID="ABC123DEF4" APPLE_API_ISSUER="" +# or a profile saved with `xcrun notarytool store-credentials fiddle`. +export APPLE_KEYCHAIN_PROFILE="fiddle" -> **Note:** Releasing is only available to contributors who have write -> access to the `electron/fiddle` repository. - -First, create a new branch for your release. - -Then, run the [`yarn version`](https://yarnpkg.com/cli/version) -command with the corresponding `major`, `minor`, or `patch` positional argument. - -After running the command, you should have a commit updating the `version` field in -the `package.json` file. Create a new tag corresponding to this new value with the `git tag` command. - -Then, you'll want to commit the version bump change onto the main branch. Push -your commit upstream and open a new pull request. +yarn make # or `yarn package` for just the .app +spctl --assess -vv "packages/app/out/Electron Fiddle-darwin-arm64/Electron Fiddle.app" +``` -Finally, push your tag to the GitHub repository with `git push origin `. -CI will automatically draft a new release. Check that all the expected files are present, -that installers work, and then publish the release. +On Windows, `yarn make` signs the installers through Azure Trusted Signing when the +`AZURE_CODE_SIGNING_*` and `WINDOWS_SIGNTOOL_PATH` variables read in `forge.config.ts` are set. diff --git a/README.md b/README.md index 8f5ef9b606..925f958e62 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,47 @@ -# Electron Fiddle icon Electron Fiddle +# Electron Fiddle -[![Test](https://github.com/electron/fiddle/actions/workflows/test.yml/badge.svg)](https://github.com/electron/fiddle/actions/workflows/test.yml) -[![Coverage Status](https://coveralls.io/repos/github/electron/fiddle/badge.svg?branch=main)](https://coveralls.io/github/electron/fiddle?branch=main) +Electron Fiddle lets you create and play with small Electron experiments. It greets you with a +quick-start template: change a few things, choose the Electron version to run it with, and play +around. Then save your fiddle as a GitHub Gist or to a local folder, so anyone can try it out. -Electron Fiddle lets you create and play with small Electron experiments. It -greets you with a quick-start template after opening – change a few things, -choose the version of Electron you want to run it with, and play around. Then, -save your Fiddle either as a GitHub Gist or to a local folder. Once published -on GitHub, anyone can quickly try your Fiddle out by just entering it in the -address bar. +**[Download Electron Fiddle](https://www.electronjs.org/fiddle)** -**[Download Fiddle now!](https://www.electronjs.org/fiddle)** +## Documentation -Electron Fiddle screenshot +- [`docs/design/`](docs/design/) holds the "Lucent" design system reference: the handover page and the tokens. -## Features +## Packages -### Explore Electron +This is a Yarn workspaces monorepo. -Screenshot: Electron App running +| Package | Path | What it is | +| ----------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `electron-fiddle` | [`packages/app`](packages/app) | The Electron app: main process, preload, renderer, packaging (`forge.config.ts`). | +| `@electron/fiddle-core` | [`packages/core`](packages/core) | Downloads Electron versions and spawns fiddles. A trimmed port of [fiddle-core](https://github.com/electron/fiddle-core). | -Try Electron without installing any dependencies: Fiddle includes everything -you'll need to explore the platform. It also includes examples for every API -available in Electron, so if you want to quickly see what a -[BrowserView][BrowserView] is or how the [desktopCapturer][desktopCapturer] -works, Fiddle has got you covered. +## Development -### Code with Types +You need the Node.js version in `.nvmrc`. Yarn is vendored in `.yarn/releases`. -![Screenshot: Fiddle's Types](https://user-images.githubusercontent.com/1426799/43874324-10e46eae-9b40-11e8-962b-8c793d73c259.png) +```sh +yarn # install dependencies +yarn start # run the app, with hot reload +yarn test # unit and component tests +yarn test:e2e # end-to-end tests against a real build +``` -Fiddle includes Microsoft's excellent Monaco Editor, the same editor powering -Visual Studio Code. It also installs the type definitions for the currently -selected version of Electron automatically, ensuring that you always have -all Electron APIs only a few keystrokes away. - -### Compile and Package - -Screenshot: Fiddle's Tasks Menu - -Fiddle can automatically turn your experiment into binaries you can share with -your friends, coworkers, or grandparents. It does so thanks to -[electron-forge][electron-forge], allowing you to package your fiddle as an -app for Windows, macOS, or Linux. - -### Start with Fiddle, Continue Wherever - -Screenshot: Visual Studio Code with Fiddle Export - -Fiddle is not an IDE – it is however an excellent starting point. Once your -fiddle has grown up, export it as a project with or without -[electron-forge][electron-forge]. Then, use your favorite editor and take on -the world! +Also useful: `yarn lint`, `yarn typecheck`, `yarn generate` (regenerates committed bindings and +catalogs), and `yarn package` or `yarn make` (output in `packages/app/out/`). ## Contributing -Electron Fiddle is a community-driven project that welcomes all sorts of contributions. Please check out our [Contributing Guide](https://github.com/electron/fiddle/blob/main/CONTRIBUTING.md) for more details. +Electron Fiddle is a community-driven project that welcomes all sorts of contributions. See the +[Contributing Guide](CONTRIBUTING.md) for details, and [SECURITY.md](SECURITY.md) to report a +security issue. ## License -[MIT, please see the LICENSE file for full details](https://github.com/electron/fiddle/blob/main/LICENSE.md). - -When using the Electron or other GitHub logos, be sure to follow the [GitHub -logo guidelines](https://github.com/logos). +[MIT](LICENSE.md). -[BrowserView]: https://electronjs.org/docs/latest/api/browser-view -[desktopCapturer]: https://electronjs.org/docs/latest/api/desktop-capturer -[electron-forge]: https://electronforge.io/ +When using the Electron or other GitHub logos, be sure to follow the [GitHub logo +guidelines](https://github.com/logos). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4d37d7d468..9f8c7a3101 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,19 +1,134 @@ -# Third-Party Notices +# Third-party notices -Electron Fiddle incorporates third-party software components under -various licenses. The following notices are provided in compliance -with those licenses. +This file holds the license notices for the third-party software that Electron +Fiddle ships or downloads and that needs an explicit notice: Socket Firewall +(`sfw`) with the code bundled into it, the Socket Firewall Free binary that it +downloads, and the Inter and Commit Mono fonts. The other libraries bundled into +the app are permissively licensed and keep their own license headers. Electron +and Chromium ship their own notices with the app (`LICENSE` and +`LICENSES.chromium.html`). --- ## Socket Firewall (sfw) -- **npm package (`sfw`)**: -- **License**: MIT +- **npm package**: +- **Version**: 2.0.6 +- **License**: MIT (the `license` field of the package; the package ships no license file) - **Copyright**: Socket Inc. -The `sfw` npm package is an MIT-licensed wrapper that downloads and -manages the Socket Firewall Free binary. +Electron Fiddle ships `sfw.mjs` from this package. It runs npm and yarn installs +of modules through Socket Firewall. The file bundles the following packages, whose +licenses follow. + +### proper-lockfile (MIT) + + The MIT License (MIT) + + Copyright (c) 2018 Made With MOXY Lda + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + +### graceful-fs (ISC) + + The ISC License + + Copyright (c) 2011-2022 Isaac Z. Schlueter, Ben Noordhuis, and Contributors + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR + IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +### retry (MIT) + + Copyright (c) 2011: + Tim Koschützki (tim@debuggable.com) + Felix Geisendörfer (felix@debuggable.com) + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + +### signal-exit (ISC) + + The ISC License + + Copyright (c) 2015, Contributors + + Permission to use, copy, modify, and/or distribute this software + for any purpose with or without fee is hereby granted, provided + that the above copyright notice and this permission notice + appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES + OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE + LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES + OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, + WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +### yocto-spinner (MIT) + + MIT License + + Copyright (c) Sindre Sorhus (https://sindresorhus.com) + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +### yoctocolors (MIT) + + MIT License + + Copyright (c) Sindre Sorhus (https://sindresorhus.com) + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- @@ -23,133 +138,233 @@ manages the Socket Firewall Free binary. - **License**: PolyForm Shield License 1.0.0 - **Copyright**: Socket Inc. -The `sfw-free` binary, downloaded at runtime by the `sfw` npm -package, is licensed under the PolyForm Shield License 1.0.0. -The full license text is available at: - - -### PolyForm Shield License 1.0.0 - -> ## Acceptance -> -> In order to get any license under these terms, you must agree -> to them as both strict obligations and conditions to all -> your licenses. -> -> ## Copyright License -> -> The licensor grants you a copyright license for the -> software to do everything you might do with the software -> that would otherwise infringe the licensor's copyright -> in it for any permitted purpose. However, you may -> only distribute the software according to Distribution -> License and make changes or new works based on the software -> according to Changes and New Works License. -> -> ## Distribution License -> -> The licensor grants you an additional copyright license -> to distribute copies of the software. Your license -> to distribute covers distributing the software with -> changes and new works permitted by Changes and New Works -> License. -> -> ## Notices -> -> You must ensure that anyone who gets a copy of any part of -> the software from you also gets a copy of these terms or the -> URL for them above, as well as copies of any plain-text lines -> beginning with `Required Notice:` that the licensor provided -> with the software. -> -> ## Changes and New Works License -> -> The licensor grants you an additional copyright license to -> make changes and new works based on the software for any -> permitted purpose. -> -> ## Patent License -> -> The licensor grants you a patent license for the software that -> covers patent claims the licensor can license, or becomes able -> to license, that you would infringe by using the software. -> -> ## Noncompete -> -> Any purpose is a permitted purpose, except for providing any -> product that competes with the software or any product the -> licensor or any of its affiliates provides using the software. -> -> ## Competition -> -> Goods and services compete even when they provide functionality -> through different kinds of interfaces or for different technical -> platforms. Applications can compete with services, libraries -> with plugins, frameworks with development tools, and so on, -> even if they're written in different programming languages -> or for different computer architectures. Goods and services -> compete even when provided free of charge. If you market a -> product as a practical substitute for the software or another -> product, it definitely competes. -> -> ## New Products -> -> If you are using the software to provide a product that does -> not compete, but the licensor or any of its affiliates brings -> your product into competition by providing a new version of -> the software or another product using the software, you may -> continue using versions of the software available under these -> terms beforehand to provide your competing product, but not -> any later versions. -> -> ## Discontinued Products -> -> You may begin using the software to compete with a product -> or service that the licensor or any of its affiliates has -> stopped providing, unless the licensor includes a plain-text -> line beginning with `Licensor Line of Business:` with the -> software that mentions that line of business. -> -> ## Sales of Business -> -> If the licensor or any of its affiliates sells a line of -> business developing the software or using the software -> to provide a product, the buyer can also enforce -> Noncompete for that product. -> -> ## Fair Use -> -> You may have "fair use" rights for the software under the -> law. These terms do not limit them. -> -> ## No Other Rights -> -> These terms do not allow you to sublicense or transfer any of -> your licenses to anyone else, or prevent the licensor from -> granting licenses to anyone else. These terms do not imply -> any other licenses. -> -> ## Patent Defense -> -> If you make any written claim that the software infringes or -> contributes to infringement of any patent, your patent license -> for the software granted under these terms ends immediately. If -> your company makes such a claim, your patent license ends -> immediately for work on behalf of your company. -> -> ## Violations -> -> The first time you are notified in writing that you have -> violated any of these terms, or done anything with the software -> not covered by your licenses, your licenses can nonetheless -> continue if you come into full compliance with these terms, -> and take practical steps to correct past violations, within -> 32 days of receiving notice. Otherwise, all your licenses -> end immediately. -> -> ## No Liability -> -> ***As far as the law allows, the software comes as is, without -> any warranty or condition, and the licensor will not be liable -> to you for any damages arising out of these terms or the use -> or nature of the software, under any kind of legal claim.*** +Electron Fiddle does not include the `sfw-free` binary. The `sfw` package +downloads it when it first runs, and it is licensed under the PolyForm Shield +License 1.0.0. The full license text is available at + and follows. + + ## Acceptance + + In order to get any license under these terms, you must agree + to them as both strict obligations and conditions to all + your licenses. + + ## Copyright License + + The licensor grants you a copyright license for the + software to do everything you might do with the software + that would otherwise infringe the licensor's copyright + in it for any permitted purpose. However, you may + only distribute the software according to Distribution + License and make changes or new works based on the software + according to Changes and New Works License. + + ## Distribution License + + The licensor grants you an additional copyright license + to distribute copies of the software. Your license + to distribute covers distributing the software with + changes and new works permitted by Changes and New Works + License. + + ## Notices + + You must ensure that anyone who gets a copy of any part of + the software from you also gets a copy of these terms or the + URL for them above, as well as copies of any plain-text lines + beginning with `Required Notice:` that the licensor provided + with the software. + + ## Changes and New Works License + + The licensor grants you an additional copyright license to + make changes and new works based on the software for any + permitted purpose. + + ## Patent License + + The licensor grants you a patent license for the software that + covers patent claims the licensor can license, or becomes able + to license, that you would infringe by using the software. + + ## Noncompete + + Any purpose is a permitted purpose, except for providing any + product that competes with the software or any product the + licensor or any of its affiliates provides using the software. + + ## Competition + + Goods and services compete even when they provide functionality + through different kinds of interfaces or for different technical + platforms. Applications can compete with services, libraries + with plugins, frameworks with development tools, and so on, + even if they're written in different programming languages + or for different computer architectures. Goods and services + compete even when provided free of charge. If you market a + product as a practical substitute for the software or another + product, it definitely competes. + + ## New Products + + If you are using the software to provide a product that does + not compete, but the licensor or any of its affiliates brings + your product into competition by providing a new version of + the software or another product using the software, you may + continue using versions of the software available under these + terms beforehand to provide your competing product, but not + any later versions. + + ## Discontinued Products + + You may begin using the software to compete with a product + or service that the licensor or any of its affiliates has + stopped providing, unless the licensor includes a plain-text + line beginning with `Licensor Line of Business:` with the + software that mentions that line of business. + + ## Sales of Business + + If the licensor or any of its affiliates sells a line of + business developing the software or using the software + to provide a product, the buyer can also enforce + Noncompete for that product. + + ## Fair Use + + You may have "fair use" rights for the software under the + law. These terms do not limit them. + + ## No Other Rights + + These terms do not allow you to sublicense or transfer any of + your licenses to anyone else, or prevent the licensor from + granting licenses to anyone else. These terms do not imply + any other licenses. + + ## Patent Defense + + If you make any written claim that the software infringes or + contributes to infringement of any patent, your patent license + for the software granted under these terms ends immediately. If + your company makes such a claim, your patent license ends + immediately for work on behalf of your company. + + ## Violations + + The first time you are notified in writing that you have + violated any of these terms, or done anything with the software + not covered by your licenses, your licenses can nonetheless + continue if you come into full compliance with these terms, + and take practical steps to correct past violations, within + 32 days of receiving notice. Otherwise, all your licenses + end immediately. + + ## No Liability + + ***As far as the law allows, the software comes as is, without + any warranty or condition, and the licensor will not be liable + to you for any damages arising out of these terms or the use + or nature of the software, under any kind of legal claim.*** + +--- + +## Fonts: Inter and Commit Mono + +Electron Fiddle ships these fonts in its interface (`packages/app/src/ui/fonts/`). +Both are licensed under the SIL Open Font License, Version 1.1, which follows the +copyright notices. + +- **Inter**: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) +- **Commit Mono**: Copyright 2023 Commit Mono authors (https://github.com/eigilnikolajsen/commit-mono) + + ----------------------------------------------------------- + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + ----------------------------------------------------------- + + PREAMBLE + + The goals of the Open Font License (OFL) are to stimulate worldwide + development of collaborative font projects, to support the font + creation efforts of academic and linguistic communities, and to provide + a free and open framework in which fonts may be shared and improved in + partnership with others. + + The OFL allows the licensed fonts to be used, studied, modified and + redistributed freely as long as they are not sold by themselves. The + fonts, including any derivative works, can be bundled, embedded, + redistributed and/or sold with any software provided that any reserved + names are not used by derivative works. The fonts and derivatives, + however, cannot be released under any other type of license. The + requirement for fonts to remain under this license does not apply to + any document created using the fonts or their derivatives. + + DEFINITIONS + + "Font Software" refers to the set of files released by the Copyright + Holder(s) under this license and clearly marked as such. This may + include source files, build scripts and documentation. + + "Reserved Font Name" refers to any names specified as such after the + copyright statement(s). + + "Original Version" refers to the collection of Font Software components + as distributed by the Copyright Holder(s). + + "Modified Version" refers to any derivative made by adding to, + deleting, or substituting -- in part or in whole -- any of the + components of the Original Version, by changing formats or by porting + the Font Software to a new environment. + + "Author" refers to any designer, engineer, programmer, technical writer + or other person who contributed to the Font Software. + + PERMISSION & CONDITIONS + + Permission is hereby granted, free of charge, to any person obtaining a + copy of the Font Software, to use, study, copy, merge, embed, modify, + redistribute, and sell modified and unmodified copies of the Font + Software, subject to the following conditions: + + 1) Neither the Font Software nor any of its individual components, in + Original or Modified Versions, may be sold by itself. + + 2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + + 3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the + corresponding Copyright Holder. This restriction only applies to the + primary font name as presented to the users. + + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + + 5) The Font Software, modified or unmodified, in part or in whole, must + be distributed entirely under this license, and must not be distributed + under any other license. The requirement for fonts to remain under this + license does not apply to any document created using the Font Software. + + TERMINATION + + This license becomes null and void if any of the above conditions are + not met. + + DISCLAIMER + + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/icons/fiddle.icns b/assets/icons/fiddle.icns deleted file mode 100644 index 8a290042f1..0000000000 Binary files a/assets/icons/fiddle.icns and /dev/null differ diff --git a/assets/icons/fiddle.ico b/assets/icons/fiddle.ico deleted file mode 100644 index f59bee309a..0000000000 Binary files a/assets/icons/fiddle.ico and /dev/null differ diff --git a/assets/icons/fiddle.png b/assets/icons/fiddle.png deleted file mode 100644 index 16f6cc9d86..0000000000 Binary files a/assets/icons/fiddle.png and /dev/null differ diff --git a/assets/icons/fiddle.svg b/assets/icons/fiddle.svg deleted file mode 100644 index f444730ed8..0000000000 --- a/assets/icons/fiddle.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/assets/screenshots/fiddle-screenshot.png b/assets/screenshots/fiddle-screenshot.png deleted file mode 100644 index 7d14faafbf..0000000000 Binary files a/assets/screenshots/fiddle-screenshot.png and /dev/null differ diff --git a/docs/design/lucent-handover.html b/docs/design/lucent-handover.html new file mode 100644 index 0000000000..d8ece877b1 --- /dev/null +++ b/docs/design/lucent-handover.html @@ -0,0 +1,5080 @@ + + + + + +Lucent Handover + + + + + + + +
+
LucentDesign handover for Electron Fiddle · v1.0 · 12 September 2026
+
+ + +
+
+ +
+ + +
+
+
+

Design system reference

+

Lucent

+

Fiddle with a glass finish, in Electron's colours. The chrome is navy glass, the code sits on a solid sheet of Electron navy, and cyan means action.

+
+
+
+
For
Electron Fiddle's renderer, built in React
+
Covers
Tokens, native materials, the main window, 60 components, flows, accessibility
+
Appearance
Dark and light, following the OS. Dark is the hero.
+
Fonts
Inter (variable, with optical sizes) and Commit Mono. Both SIL Open Font License.
+
Precedence
Where a table and a specimen disagree, the table wins.
+
+
+
+ +
+
+

Live specimen. Press Run: renderer.js gets an error badge, the error shows under its line, and the console logs it. The split button at the right of the tab row opens a second pane. The coloured backdrop stands in for the user's desktop, and the small glass window stands in for the fiddle's own window, which in the app is a real Electron window.

+
+ +
+

Principles and rules

Four ideas carry the whole system. When a case isn't covered here, decide it by these.

+
    +
  • Glass for chrome, paper for workTitle bar, sidebar, status bar, menus and floating panes are translucent. Code, the console and anything you read at length sit on the solid sheet.
  • +
  • Cyan is the actionRun is a cyan capsule with a navy label, and cyan marks focus and selection. In light it flips to navy with a cyan label. Red means running or broken, nothing else.
  • +
  • Layers, not linesSurfaces separate by depth, blur and a lit edge. Hairlines are 0.5px and appear only where rows need them.
  • +
  • Chrome floats in capsulesToolbar controls gather into glass capsules. Everything inside the sheet uses continuous-corner rectangles.
  • +
+
+

Do

    +
  • Take every value from a token. If a value is missing, add a token rather than a literal.
  • +
  • Keep one editor by default. The split is opt-in, from the tab row or ⌘\.
  • +
  • Use sentence case everywhere: buttons, menus, section heads, the status bar.
  • +
  • Keep corners concentric: an inner radius is the outer radius minus the inset.
  • +
  • Let the OS draw window corners, shadows and the window controls.
  • +
+

Don't

    +
  • Put glass behind code, the console, dialogs' body copy or long text.
  • +
  • Use cyan for decoration, or red for anything that isn't running or broken.
  • +
  • Add gradients. The only ones are the primary sheen and the assist ring.
  • +
  • Use uppercase labels, borders between panes, or 1px dividers.
  • +
  • Stack a blurred overlay on another blurred surface without checking legibility.
  • +
+
+
+ +
+

Glass on real windows

The backdrop of a real window is the user's own desktop, which the page can't see. So the frosting comes from the OS, and Lucent paints a thin tint on top.

+
+ + + + + +
PlatformMaterial under the chromeTitle barNotes
macOSvibrancy: 'under-window'titleBarStyle: 'hiddenInset'Centre the traffic lights in the 56px bar (trafficLightPosition) and keep 80px clear on the left. Use visualEffectState: 'followWindow' so the glass dims when the window is inactive.
Windows 11backgroundMaterial: 'acrylic'titleBarStyle: 'hidden' + titleBarOverlayThe overlay draws the caption buttons on the right; keep 140px clear and move Publish and Settings left of it. Set the overlay's symbolColor to ink and update it when the appearance changes.
Linux, or no materialnonenative frame or hidden + overlayAdd lu-no-material to <html>. The tokens swap every material for its opaque fallback.
+

The page background must be transparent for the material to show: html, body { background: transparent }, with the chrome painted in --lu-material-chrome and the sheet in --lu-surface. Mark the title bar as a drag region (-webkit-app-region: drag) and every control in it as no-drag.

+
// Main process: the window Lucent expects. Verify each option on each OS.
+const win = new BrowserWindow({
+  width: 1280, height: 820, minWidth: 880, minHeight: 560,
+  backgroundColor: '#00000000',
+  // macOS
+  titleBarStyle: 'hiddenInset',
+  trafficLightPosition: { x: 20, y: 22 },
+  vibrancy: 'under-window',
+  visualEffectState: 'followWindow',
+  // Windows 11: titleBarStyle 'hidden', backgroundMaterial 'acrylic',
+  // titleBarOverlay { color: '#00000000', symbolColor: ink, height: 56 }
+})
+
Fallbacks are part of the design, not a degraded mode. With Reduce transparency on (prefers-reduced-transparency: reduce), on Linux, or when a material fails, the chrome becomes --lu-window and overlays become --lu-surface-raised. It should still look finished: same layout, same capsules, just opaque.
+
Test chrome text on hostile desktops. Try a black wallpaper and a saturated photo in both appearances. If ink-muted on the title bar or sidebar drops below 4.5:1, raise the chrome tint's alpha from 0.62 toward 0.78. Don't darken the text.
+
+ +
+

Colour

Electron navy #1b1c26 is the editor sheet in dark. Cyan #9feaf9 is the action and the focus colour; in light it deepens to teal #006f85 so it passes on white, and Run turns navy. Values below are canonical. Swatches show dark then light.

+
TokenDarkLightUse
Ground and surfaces
--lu-window#10121b#eef3f8Opaque window ground. Shows only where the OS gives no material: Linux, Reduce transparency, or a failed material.
--lu-surface#1b1c26#ffffffThe sheet: editor, console, settings pages. Dark is Electron navy #1b1c26 exactly.
--lu-surface-raised#262837#ffffffOpaque overlays and the fallback for every material.
--lu-surface-sunken#15161f#f3f6faConsole log, table heads, wells.
--lu-hoverrgba(159,234,249,0.08)rgba(27,28,38,0.06)Hovered rows and the selected tab, on the sheet.
--lu-fillrgba(255,255,255,0.08)rgba(118,118,128,0.12)Neutral fill: ghost hover, segmented track, callouts, inline code.
--lu-fill-strongrgba(255,255,255,0.13)rgba(118,118,128,0.20)Pressed states, menu item hover, progress track.
--lu-linergba(159,234,249,0.12)rgba(27,28,38,0.12)Hairlines (0.5px) between rows and above the console. Nowhere else.
--lu-line-strong#6d7594#7b8196Field and control borders. 3:1 on every surface.
Materials
--lu-material-chromergba(22,24,36,0.62)rgba(250,250,253,0.62)Tint painted over the native window material: title bar, sidebar, status bar.
--lu-material-thinrgba(27,28,38,0.42)rgba(255,255,255,0.42)App-drawn floating panes (pop-out previews). Blurred with blur-thin.
--lu-material-overlayrgba(38,40,56,0.90)rgba(252,252,254,0.86)Menus, popovers, toasts, dialogs. Blurred with blur-overlay.
--lu-capsulergba(255,255,255,0.08)rgba(255,255,255,0.60)Toolbar capsules and toolbar buttons on the chrome.
--lu-capsule-hoverrgba(255,255,255,0.15)rgba(255,255,255,0.86)Toolbar button hover.
--lu-chiprgba(255,255,255,0.13)rgba(255,255,255,0.88)Selected sidebar row, lifted with shadow-lift.
--lu-rimrgba(255,255,255,0.30)rgba(255,255,255,0.95)The lit top edge of glass (inset 0.5px).
--lu-rim-softrgba(255,255,255,0.07)rgba(255,255,255,0.34)The faint all-round edge of glass (inset 0.5px).
--lu-scrimrgba(6,8,18,0.50)rgba(226,228,238,0.40)Behind dialogs, with blur-scrim.
Text
--lu-ink#eef1f8#1b1c26Primary text on every surface and material.
--lu-ink-muted#aeb4c9#4c5266Secondary text, inactive tabs, sidebar heads, labels.
--lu-ink-faint#949ab2#5f6578Line numbers and timestamps. Never for sentences.
Accent and signals
--lu-accent#9feaf9#006f85Electron cyan (teal in light): focus, selection, links, the active icon.
--lu-accent-soft#1c3945#dcf3f8Selected list rows, primary callouts, search matches.
--lu-action#9feaf9#1b1c26Primary fill: Run, checked boxes, switches on. Cyan in dark, navy in light.
--lu-on-action#0d2a31#9feaf9Label and icon on action.
--lu-focus-ringvar(--lu-accent)var(--lu-accent)Keyboard focus. Drawn at 70% opacity, 3px.
--lu-spark#ff6e6e#d4122bElectron red: running, errors, destructive actions. Small doses.
--lu-spark-soft#3a1d26#fdeceeError line, error lens, Stop button, error badges.
--lu-success#6ee3a4#0b6b3dInstalled, passed, saved.
--lu-warning#f3c46b#9e5100Deprecated APIs and pre-release versions.
Controls
--lu-field#171923#ffffffText fields, selects, checkboxes and radios on the sheet.
--lu-field-on-glassrgba(255,255,255,0.07)rgba(255,255,255,0.72)Fields that sit on the chrome (sidebar search).
--lu-field-edge-on-glassrgba(245,245,247,0.50)rgba(29,29,31,0.56)Border of fields on the chrome.
--lu-buttonrgba(255,255,255,0.11)#ffffffSecondary button fill.
--lu-button-hoverrgba(255,255,255,0.16)#f6f6f8Secondary button hover.
--lu-button-edgergba(0,0,0,0.45)rgba(20,22,40,0.16)The 0.5px ring on secondary buttons, cards and tables.
--lu-thumb#3c4058#ffffffSelected segment in a segmented control.
--lu-kbdrgba(255,255,255,0.08)#ffffffKey caps.
--lu-tooltiprgba(38,40,56,0.92)rgba(27,28,38,0.90)Tooltip fill. Navy in both appearances.
--lu-tooltip-ink#eef1f8#f3f5faTooltip text.
Editor
--lu-syntax-keyword#9feaf9#006f85const, function, require, async, await.
--lu-syntax-string#ffb38a#a5470fStrings and template literals.
--lu-syntax-function#86b4ff#1f5fd1Functions and methods, in Electron bright blue.
--lu-syntax-number#ff9a9a#c01127Numbers, booleans, null.
--lu-syntax-comment#8a90aa#646a7cComments.
--lu-cursor-line
color-mix(in srgb, var(--lu-accent) 7%, var(--lu-surface))
color-mix(in srgb, var(--lu-accent) 7%, var(--lu-surface))
The line the caret is on.
--lu-assist-ring
linear-gradient(100deg, #9feaf9, #47a8ff 38%, #2f6bff 70%, #7fdcff)
linear-gradient(100deg, #1f7cff, #00a3bf 38%, #6fd6ea 70%, #1b1c26)
The assist field border only. The one multicolour element in the app.
+
+ +
+

Type

One sans and one mono. Inter's optical-size axis switches to the Display cut on its own at large sizes, so headings tighten without a second family. Turn on font-feature-settings: "cv05", "ss03" and font-optical-sizing: auto.

+
TokenSize / lineWeightTrackingSampleUse
--lu-type-display40px / 44px650-0.028em
Build it in a fiddle
Welcome and empty states. Inter switches to its Display cut on its own at this size.
--lu-type-title22px / 28px650-0.018em
Electron versions
Settings pages and large dialog titles.
--lu-type-headline15px / 20px600-0.01em
Installed versions
Dialog titles, section heads, toast titles.
--lu-type-body13px / 18px4000
Choose which Electron version runs your fiddle.
Default UI text, desktop dense.
--lu-type-label13px / 16px5000
Run fiddle
Buttons, tabs, menu items.
--lu-type-caption11px / 14px6000.005em
Renderer process
Sidebar heads, field labels, badges. Sentence case, never shouted.
--lu-type-code13px / 20px4000
win.loadFile('index.html')
Editor and console, in Commit Mono.
+

Sentence case everywhere, including captions and sidebar heads. The only mono outside the editor and console is key caps in menus, and those use the sans.

+
+ +
+

Shape and space

Corners are continuous. Where CSS.supports('corner-shape', 'squircle') is true, set corner-shape: squircle and use the larger squircle radius, which reads the same as the round value would. Capsules stay round.

+
check · 5px / 7px
sm · 6px / 9px
item · 8px / 12px
control · 10px / 15px
menu · 13px / 17px
panel · 16px / 24px
dialog · 20px / 28px
sheet · 16px / 26px
float · 26px / 40px
capsule · 999px
+
TokenRoundSquircleUse
--lu-radius-code5px5pxInline code, search-match highlight (3px).
--lu-radius-check5px7pxCheckboxes.
--lu-radius-sm6px9pxKey caps, small chips.
--lu-radius-item8px12pxMenu items, tree rows, tabs, tooltips (8px, round), console rows.
--lu-radius-control10px15pxButtons, fields, selects, segmented controls, callouts, list rows, error lens.
--lu-radius-menu13px17pxMenus: radius-item plus the 5px menu padding, so corners stay concentric.
--lu-radius-panel16px24pxPopovers, toasts, cards, coachmarks.
--lu-radius-dialog20px28pxDialogs.
--lu-radius-sheet16px26pxThe sheet inside the window.
--lu-radius-float26px40pxApp-drawn floating panes. Real windows use the OS corner.
--lu-radius-capsule999px999pxToolbar capsules, toolbar buttons, badges, tags, the assist field, filter chips.
+

Sizes

+
TokenValueUse
--lu-size-titlebar56pxTitle bar height, including the toolbar.
--lu-size-toolbar36pxToolbar capsule and toolbar button outer height.
--lu-size-toolbar-inner30pxControls inside a capsule (3px inset).
--lu-size-control30pxDefault button, field and select.
--lu-size-control-sm24pxSmall buttons and fields; icon buttons in the tab row.
--lu-size-row28pxMenu items, tree rows, tabs.
--lu-size-tabrow46pxTab row, plus a 0.5px hairline.
--lu-size-sidebar228pxSidebar width (resizable 200 to 320).
--lu-size-sheet-inset8pxGap between the sheet and the window edge (right side; the sidebar sits left).
--lu-size-editor-line20pxEditor line height.
--lu-size-gutter66pxLine-number column: 48px numbers plus 18px space. The error lens starts here.
--lu-size-assist40pxAssist row: 24px field plus 8px above and below.
--lu-size-console160pxConsole default height (resizable).
--lu-size-console-bar46pxConsole header.
--lu-size-statusbar32pxStatus bar.
--lu-size-hairline0.5pxEvery divider. 1px only for field borders.
--lu-size-icon16pxIcons, drawn at 1.4px stroke.
+

Spacing

+
TokenValueUse
--lu-space-14pxIcon to label, capsule inset (3px is the one exception).
--lu-space-28pxRelated controls, sheet inset, assist row padding.
--lu-space-312pxSidebar and title bar padding.
--lu-space-416pxEditor padding, card padding.
--lu-space-624pxDialog gutters, section gaps.
--lu-space-832pxEmpty states, settings page margins.
+
+ +
+

Depth and materials

Depth comes from three things together: a tint, a blur, and a lit edge (a 0.5px inset rim that's brighter on top). Overlays add a soft shadow. Nothing uses a solid border to separate itself.

+
island
overlay
capsule
lift
float
+
TokenUseDarkLight
--lu-shadow-popoverMenus, popovers, toasts, dialogs. Add the rims (see materials).
0 0 0 0.5px rgba(4,6,16,0.6), 0 16px 40px -8px rgba(4,6,16,0.65), 0 2px 8px rgba(4,6,16,0.35)
0 0 0 0.5px rgba(20,22,40,0.14), 0 12px 32px -8px rgba(20,24,48,0.28), 0 2px 6px rgba(20,24,48,0.08)
--lu-shadow-islandThe sheet, cards, swatches.
0 0 0 0.5px rgba(4,6,16,0.55), 0 1px 2px rgba(4,6,16,0.3), 0 10px 28px -12px rgba(4,6,16,0.6)
0 0 0 0.5px rgba(20,22,40,0.10), 0 1px 2px rgba(20,24,48,0.06), 0 8px 24px -12px rgba(20,24,48,0.22)
--lu-shadow-liftSelected sidebar row, selected segment.
inset 0 0.5px 0 rgba(255,255,255,0.14), 0 0 0 0.5px rgba(0,0,0,0.5), 0 1px 3px rgba(0,0,0,0.35)
0 0 0 0.5px rgba(20,22,40,0.08), 0 1px 2px rgba(20,22,40,0.12), 0 2px 6px -2px rgba(20,22,40,0.1)
--lu-shadow-capsuleToolbar capsules and toolbar buttons.
inset 0 0.5px 0 rgba(255,255,255,0.2), inset 0 0 0 0.5px rgba(255,255,255,0.06), 0 0 0 0.5px rgba(0,0,0,0.45), 0 3px 10px -3px rgba(0,0,0,0.45)
inset 0 0.5px 0 #fff, inset 0 0 0 0.5px rgba(255,255,255,0.55), 0 0 0 0.5px rgba(20,22,40,0.08), 0 3px 10px -3px rgba(30,40,90,0.18)
--lu-shadow-buttonSecondary buttons.
0 0 0 0.5px rgba(0,0,0,0.45), 0 1px 2px rgba(0,0,0,0.3), inset 0 0.5px 0 rgba(255,255,255,0.12)
0 0 0 0.5px rgba(20,22,40,0.16), 0 1px 2px rgba(0,0,0,0.07), inset 0 0.5px 0 rgba(255,255,255,0.7)
--lu-shadow-primaryPrimary buttons (Run). Pair with the sheen gradient.
inset 0 0.5px 0 rgba(255,255,255,0.5), inset 0 -0.5px 0 rgba(0,0,0,0.12), 0 1px 2px color-mix(in srgb, var(--lu-action) 35%, transparent), 0 4px 12px -4px color-mix(in srgb, var(--lu-action) 60%, transparent)
inset 0 0.5px 0 rgba(255,255,255,0.5), inset 0 -0.5px 0 rgba(0,0,0,0.12), 0 1px 2px color-mix(in srgb, var(--lu-action) 35%, transparent), 0 4px 12px -4px color-mix(in srgb, var(--lu-action) 60%, transparent)
--lu-shadow-floatApp-drawn floating panes only. Real windows get the OS shadow.
0 0 0 0.5px rgba(0,0,0,0.85), 0 44px 90px -24px rgba(0,0,0,0.75), 0 14px 30px -12px rgba(0,0,0,0.5)
0 0 0 0.5px rgba(20,22,40,0.18), 0 44px 90px -24px rgba(18,40,90,0.42), 0 14px 30px -12px rgba(18,40,90,0.2)
+

Blur

+
TokenDarkLightUse
--lu-blur-chromeblur(34px) saturate(170%)blur(34px) saturate(180%)Only when the app draws its own glass over its own content. Real window chrome uses the native material instead.
--lu-blur-overlayblur(24px) saturate(170%)blur(24px) saturate(180%)Menus, popovers, toasts, dialogs.
--lu-blur-thinblur(22px) saturate(190%)blur(22px) saturate(190%)App-drawn floating panes.
--lu-blur-tooltipblur(16px) saturate(160%)blur(16px) saturate(160%)Tooltips.
--lu-blur-scrimblur(8px) saturate(140%)blur(8px) saturate(140%)Dialog scrim.
+
Glass on glass. Chromium doesn't blur an element through another element that already has a backdrop filter. Menus and popovers that open over the chrome therefore see only the tint, which is why material-overlay is 86 to 90% opaque: it stays legible whether or not the blur has anything to sample.
+
+ +
+

Motion

One curve for everything: a quick start and a long, soft landing. Things settle rather than bounce. Colour changes are fast; anything that moves takes a little longer.

+
+ cubic-bezier(.32, .72, 0, 1) +

A press takes 220ms, a colour change 150ms. Nothing in the app loops except spinners.

+
+
TokenValueUse
--lu-easecubic-bezier(0.32, 0.72, 0, 1)Everything that moves. A fast start and a long, soft landing.
--lu-dur-instant80msMenu item hover.
--lu-dur-fast150msColour, fill and border changes (hover, selection).
--lu-dur-base220msPress scale, segmented thumb, field focus ring.
--lu-dur-slow280msSwitch knob travel.
--lu-dur-enter500msFloating panes appearing: from opacity 0, 14px lower, scale 0.94, blur 6px.
--lu-press-scale0.965Buttons shrink to this while pressed. Chips 0.96, large tiles 0.98.
+
+ +
+

Window anatomy

Measured at 1208 × 664. Heights are fixed; widths flex except the sidebar. The sheet is one rounded, opaque panel inset 8px from the right edge; the sidebar and bars sit on the glass around it.

+
window-vibrancyElectron 43.0.0RunPublishMain processmain.jsPreloadpreload.jsRendererindex.htmlrenderer.jsstyles.csssidebaron the materialmain.jspreload.jsrenderer.jsindex.htmlstyles.css1const { app, BrowserWindow } = require('electron')editor · one pane by defaultDescribe a change to this fiddleConsole19:42:08 Renderer Uncaught TypeErrorReady Electron 43.0.0 arm6456 title bar46 tab rowflex editor40 assist160 console32 status228 sidebar296 × 36 capsule, centred8
+
RegionSize (px)Spec
Title bar56 tallPadding 0 12 0 20. On the material, no divider. Title "window-vibrancy" in label weight 650, then "Edited" in ink-muted 12px.
Window controls80 clear (macOS, left) · 140 clear (Windows, right)Drawn by the OS. The sidebar button sits 12px after them.
Toolbar capsule296 × 36Centred in the window. capsule fill, shadow-capsule, 3px inset, 2px gap. Holds the version select (180 × 30) and Run (min 108 × 30).
Toolbar buttons36 tall, capsulePublish: padding 0 16 0 14, label 13/16 500. Settings: 36 × 36 icon button. 8px apart.
Sidebar228 widePadding 0 10 12 12, rows 2px apart. Section heads in caption style, ink-muted, padding 14 8 5 (4 on the first). Rows 28 tall, radius-item, padding 0 8, icon 16 with 6px gap.
Sidebar search24 tallfield-on-glass with a 1px field-edge-on-glass border, radius-control.
Sheetfills the rest, 8 from the rightsurface, radius-sheet, shadow-island (plus a 0.5px top rim at 8% white in dark). Clips its content.
Tab row46 + 0.5 hairlinePadding 0 8 0 9, 10px gap. Tabs 28 tall, padding 0 11, 13/16 at 12.5px, weight 500, 2px apart. Split button 24 × 24, radius 8.
EditorflexPadding 12 0. Commit Mono 13/20. Gutter 66 (48 + 18). Cursor line: accent 7% into surface.
Error lensautoMargin 6 16 8 66, padding 9 12, radius-control. spark 6% into surface with a 0.5px ring of spark at 30%. Text 12.5/18.
Assist row40 + 0.5 hairlinePadding 8 10 8 12, 8px gap. Field 24 tall, capsule, 1px assist-ring border. Button 24 tall, capsule, padding 0 12.
Console160 defaultsurface-sunken, 0.5px hairline on top. Bar 46 tall, padding 0 10 0 16, title 12.5/16 600. Rows 22 tall (2px padding), margin 0 8, radius 8, columns 60 / 70 / rest, Commit Mono 12/18; the process column is sans 11/18 500.
Status bar32 tallOn the material. Padding 0 20 0 22, 18px gap, 11.5/14 500 in ink-muted. "Running" is a capsule pill: spark on spark-soft, 600, with a dot.
+

Behaviour

+
+ + + + + +
One editor by defaultTabs for main.js, preload.js, renderer.js, index.html and styles.css.
SplitThe columns button at the right end of the tab row, or ⌘\. Opens a second pane beside the current one showing renderer.js, or main.js when renderer.js is current. The tab row keeps driving the left pane. Press again to close. Tooltip: "Split editor ⌘\" or "Close split".
Pop-outEach pane header in split view carries a pop-out action. A popped-out file opens in its own window with the same chrome and sheet, and its tab shows a small window glyph until it's docked back.
SidebarLists the files in tab order, then Packages with an "Add a package" field. Clicking a file opens its tab. The sidebar button in the title bar hides it; the sheet then runs to 8px from the left edge too.
ResizingDrag the sidebar edge (200 to 320px) and the top of the console (96px to half the sheet). Dividers are invisible until hover, then show a 2px cyan line.
+
+ +
+

Flows and states

The moments that define Fiddle. Each specimen below is the real component in that state.

+

Run

+
+
+ + + + +
StateButtonElsewhere
ReadyPrimary: action fill with the sheen, play icon, "Run", ⌘R at 80% opacity.Status bar says "Ready". Console shows one system line.
DownloadingProgress ring in place of the icon, "Downloading 42%", no shortcut. Width never jumps: minimum 108px.Only when the chosen version isn't installed yet.
StartingSpinner, "Starting", disabled. About 0.7s in practice.Nothing else changes yet.
RunningSecondary with a spark-soft fill and a faint red ring: stop icon, "Stop", ⌘R.Status bar shows a "Running" pill in spark on spark-soft. The fiddle opens its own Electron window.
+

When the fiddle throws

+
+
Tab

The file's tab gets a count badge: 10px semibold, spark on spark-soft, 16px tall capsule, before the unsaved dot.

+
Sidebar

The file's row gets a "1 error" pill in the same colours, right-aligned.

+
Editor

The line turns spark-soft and its number spark. The error lens sits under it, starting at the code column (66px): title in spark, message in ink, a hint in ink-muted.

+
Console

The row turns spark-soft with spark text, and the location ("renderer.js:4:36") is a link that jumps to the line.

+
+

Version picker

+

Lives in the toolbar capsule, left of Run. Opens a menu 10px below the capsule with "Stable" and "Pre-release" groups; hints ("latest", "beta") sit right-aligned in ink-muted. The menu sits above the editor and console (its own stacking layer), and grows to fit the longest label, between 200 and 360px.

+

Assist row

+

Optional row under the editor for describing a change in words. A capsule field with the assist-ring gradient as its border, a sparkle icon, placeholder "Describe a change to this fiddle", and a secondary "Suggest" button with ⌘↵. On focus the ring gains a soft glow in the ring's own colours. This is the only multicolour element in the app; don't reuse the gradient elsewhere.

+
+ +
+

Components

Resolved values for the core controls in both appearances. Then all 60 components, live, by group.

+
ComponentHeight, padding, radiusTypeFill and textNotes
Button primary30pad 0 15 0 13 · r 15 sqInter 13/16 · 500
Darksheen + #9feaf9 on #0d2a31
Lightsheen + #1b1c26 on #9feaf9
Action fill with a white sheen (20% to 0 over the top 55%), shadow-primary. Hover lifts the sheen to 28%.
Button secondary30pad 0 13 0 13 · r 15 sqInter 13/16 · 500
Darkrgba(255,255,255,0.11) on #eef1f8
Light#ffffff on #1b1c26
button fill with shadow-button. Hover: button-hover. Pressed: fill-strong.
Button ghost30pad 0 13 0 13 · r 15 sqInter 13/16 · 500
Darktransparent on #aeb4c9
Lighttransparent on #4c5266
No fill, ink-muted text. Hover: fill with ink text.
Button danger30pad 0 13 0 13 · r 15 sqInter 13/16 · 500
Darkrgba(255,110,110,0.09) on #ff6e6e
Lightrgba(212,18,43,0.09) on #d4122b
spark at 9% (15% on hover), spark text.
Button small24pad 0 11 0 11 · r 15 sqInter 13/16 · 500
Darksheen + #9feaf9 on #0d2a31
Lightsheen + #1b1c26 on #9feaf9
Same as its variant at 24 tall; 4px icon gap.
Icon button30 × 30pad 0 0 0 0 · r 15 sqInter 13/16 · 500
Darktransparent on #aeb4c9
Lighttransparent on #4c5266
Square, 16px icon at 1.4 stroke. Pressed state uses fill-strong.
Text field30 (24 small)pad 0 11 0 11 · r 15 sqInter 13/18 · 400
Dark#171923 on #eef1f8
Light#ffffff on #1b1c26
1px line-strong border. Focus: accent border plus a 3.5px ring of accent at 26%.
Field label14pad 0 0 0 0 · r 0Inter 11/14 · 600
Darktransparent on #aeb4c9
Lighttransparent on #4c5266
Caption style above the field, 6px gap.
Select trigger30 (24 small)pad 0 11 0 11 · r 15 sqInter 13/18 · 400
Dark#171923 on #eef1f8
Light#ffffff on #1b1c26
Field styling with a chevron in ink-muted. Open: same ring as focus. Menu 4px below, 200 to 360px wide.
Menuautopad 5 5 5 5 · r 17 sqInter 13/18 · 400
Darkrgba(38,40,56,0.9) on #eef1f8
Lightrgba(252,252,254,0.86) on #1b1c26
material-overlay, blur-overlay, shadow-popover and the two rims. Padding 5.
Menu item28pad 0 10 0 6 · r 12 sqInter 13/18 · 400
Darktransparent on #eef1f8
Lighttransparent on #1b1c26
Hover and keyboard-active: fill-strong. Selected: weight 500, no fill. Hints and key caps 11 to 12px in ink-muted.
Checkbox box16 × 16pad 0 0 0 0 · r 7 sq
Darksheen + #9feaf9
Lightsheen + #1b1c26
Unchecked: field fill, 1px line-strong. Checked: action with sheen, on-action tick.
Radio16 × 16pad 0 0 0 0 · r 50%
Darksheen + #9feaf9
Lightsheen + #1b1c26
As Checkbox, round.
Switch track34 × 20pad 0 0 0 0 · r 10
Dark#9feaf9
Light#1b1c26
Off: line-strong at 80%. On: action. Knob white, grows 4px while pressed, 280ms travel.
Segmented control30pad 2 2 2 2 · r 15 sqInter 13/18 · 400
Darkrgba(255,255,255,0.08) on #eef1f8
Lightrgba(118,118,128,0.12) on #1b1c26
Track: fill, 2px inset. Selected segment: thumb with shadow-lift.
Tab, selected28pad 0 11 0 11 · r 12 sqInter 12.5/16 · 500
Darkrgba(159,234,249,0.08) on #eef1f8
Lightrgba(27,28,38,0.06) on #1b1c26
Selected: hover fill, ink. Others ink-muted; hover at 55% of hover. Error count and unsaved dot follow the label.
Tree row28pad 0 8 0 8 · r 12 sqInter 13/18 · 400
Darktransparent on #eef1f8
Lighttransparent on #1b1c26
Hover: hover at 60%. Selected: weight 500 (plus chip and lift in the sidebar).
Badge20pad 0 8 0 8 · r 999Inter 11/14 · 600
Darkrgba(110,227,164,0.14) on #6ee3a4
Lightrgba(11,107,61,0.14) on #0b6b3d
Capsule, caption type, tone at 14% with tone text. Optional dot with a 2.5px halo.
Tag20pad 0 8 0 8 · r 999Inter 11/14 · 600
Darkrgba(174,180,201,0.14) on #aeb4c9
Lightrgba(76,82,102,0.14) on #4c5266
Like Badge, neutral tone by default.
Key cap19, min 22 widepad 4 6 4 6 · r 9 sqInter 11/11 · 500
Darkrgba(255,255,255,0.08) on #aeb4c9
Light#ffffff on #4c5266
Caption-sized sans, not mono. kbd fill, 0.5px line-strong ring with a 1px bottom lip.
Tooltip26pad 5 9 5 9 · r 8 sqInter 12/16 · 500
Darkrgba(38,40,56,0.92) on #eef1f8
Lightrgba(27,28,38,0.9) on #f5f5f7
tooltip fill, blur-tooltip, 0.5px white rim at 14%. Appears after 500ms, below the target by default.
Progress track6pad 0 0 0 0 · r 3
Darkrgba(255,255,255,0.13)
Lightrgba(118,118,128,0.2)
Track fill-strong. Bar: accent with a lighter start (accent 75% into white).
Toastautopad 12 12 12 14 · r 24 sqInter 13/18 · 400
Darkrgba(38,40,56,0.9) on #eef1f8
Lightrgba(252,252,254,0.86) on #1b1c26
Overlay material. Title 600, text ink-muted. Stack bottom-right, 8px apart.
Dialog420 widepad 0 0 0 0 · r 28 sqInter 13/18 · 400
Darkrgba(38,40,56,0.9) on #eef1f8
Lightrgba(252,252,254,0.86) on #1b1c26
Overlay material over a blurred scrim. Padding 18 20, title 15/20 650, footer without a divider.
Popoverautopad 14 16 14 16 · r 24 sqInter 13/18 · 400
Darkrgba(38,40,56,0.9) on #eef1f8
Lightrgba(252,252,254,0.86) on #1b1c26
Overlay material with an arrow cut from the same material.
Cardautopad 12 12 12 12 · r 24 sqInter 13/18 · 400
Dark#1b1c26 on #eef1f8
Light#ffffff on #1b1c26
surface, 0.5px button-edge ring. Hover adds a soft 10/24 shadow.
Calloutautopad 10 12 10 12 · r 15 sqInter 13/18 · 400
Darkrgba(255,255,255,0.08) on #eef1f8
Lightrgba(118,118,128,0.12) on #1b1c26
fill by default; accent-soft for primary, spark-soft for danger. No border.
List rowauto, min 48pad 8 10 8 10 · r 15 sqInter 13/18 · 400
Dark#1c3945 on #eef1f8
Light#dcf3f8 on #1b1c26
Selected: accent-soft. Hover: hover.
Table head33pad 0 12 0 12 · r 0Inter 11/14 · 600
Dark#15161f on #aeb4c9
Light#f3f6fa on #4c5266
surface-sunken, caption type. Rows 32 tall with hairlines.
Inline codeautopad 2 5 2 5 · r 5Commit Mono 92% of body
Darkrgba(255,255,255,0.08) on #eef1f8
Lightrgba(118,118,128,0.12) on #1b1c26
Commit Mono at 92%, fill, radius-code.
+

States

+
+ + + + + + +
HoverGhost and icon buttons take fill. Secondary buttons take button-hover. Rows on the sheet take hover at 55 to 60%. Toolbar buttons take capsule-hover.
PressedScale to 0.965 over 220ms; fills go to fill-strong. Primary darkens: action mixed 14% toward black, sheen removed.
SelectedTabs: hover fill, ink text. Sidebar rows: chip with shadow-lift, icon in accent. Menu items: weight 500, no fill. Segments: thumb with shadow-lift.
FocusButtons, tabs, switches, checkboxes: 3px outline in accent at 70%, 1px offset (inset 3px inside lists and tab rows). Fields and open selects: border turns accent plus a 3.5px ring of accent at 26%.
Disabled40% opacity. No hover, no press. Keep the tooltip so people can learn why.
InvalidField border spark, focus ring spark at 22%, message below in spark.
+ +
+ +
+

Accessibility

Every text token clears 4.5:1 on the surfaces it's used on, and control borders clear 3:1, in both appearances. Checked with the WCAG 2 formula.

+
WherePairDarkLightNeeds
Body text on the sheetink on surface14.9716.934.5
Secondary textink-muted on surface8.207.764.5
Line numbers, timestampsink-faint on surface6.065.804.5
Console timestampsink-faint on surface-sunken6.455.354.5
Links, active iconsaccent on surface12.585.814.5
Run labelon-action on action11.2112.584.5
Error textspark on surface6.215.364.5
Error badge, Stop, error rowsspark on spark-soft5.574.704.5
Commentssyntax-comment on surface5.365.394.5
Chrome text, opaque fallbackink-muted on window9.056.954.5
Field bordersline-strong on surface3.733.873.0
Field borders on fieldsline-strong on field3.853.873.0
+

Beyond contrast

+
+ + + + + + + +
KeyboardEverything is reachable by Tab. Tabs, trees, menus and segmented controls use arrow keys inside, one Tab stop each. ⌘R runs and stops, ⌘\ splits, ⌘↵ sends the assist prompt.
Targets24px minimum (tab-row icon buttons). Toolbar controls are 30 to 36px.
Reduce motionDrop scale, travel and blur-in. Keep colour changes and spinners.
Reduce transparencyMaterials go opaque (see Glass on real windows). Nothing else changes.
Increase contrastUnder prefers-contrast: more, draw hairlines at 1px in line-strong and raise the chrome tint to 0.9.
Forced colorsUnder forced-colors: active, the glass drops to system colours entirely.
AnnouncementsThe Run button announces "Ready to run", "Starting", "Running Electron 43.0.0". New console errors announce once, politely.
+
+ +
+

Acceptance checklist

Check each item in dark and light, on macOS and Windows.

+
    +
  • Tokens. No colour, radius, shadow or duration literal in component styles. Grep for # and rgba( outside the token file.
  • +
  • Material. The desktop shows through the title bar, sidebar and status bar; the sheet is fully opaque; the window corners and shadow are the OS's.
  • +
  • Fallback. With Reduce transparency on, and on Linux, the chrome is opaque and the app still looks finished.
  • +
  • Title bar. 56px, drag region works, traffic lights centred (macOS) or caption buttons clear (Windows), capsule centred in the window.
  • +
  • Default layout. One editor with five tabs, console 160px, status bar 32px.
  • +
  • Split. The button and ⌘\ open and close a second pane; the tooltip text matches the state.
  • +
  • Run. All four states, fixed width, correct announcements, the Running pill in the status bar.
  • +
  • Errors. Tab badge, sidebar pill, error line, lens aligned to the code column, console row with a working link.
  • +
  • Menus. The version menu opens above the editor, sizes to its labels, and is readable over the chrome.
  • +
  • Corners. Squircles where supported, round elsewhere, concentric everywhere (menu 17 = item 12 + padding 5).
  • +
  • Hairlines. 0.5px, only between rows, above the console and under the tab row.
  • +
  • Type. Inter with optical sizes and the two features; Commit Mono in the editor and console; sentence case.
  • +
  • Focus. Visible on every control, never clipped by a parent's overflow.
  • +
  • Contrast. The table above holds; chrome text still passes over a black and a saturated wallpaper.
  • +
+
+ +
+

Token files

The same values as the tables above, ready to paste. lucent-tokens.css handles both appearances, squircles, the no-material fallback, Reduce transparency and Reduce motion. lucent-tokens.json is the same set for tooling. This page also embeds the JSON in <script id="lucent-tokens" type="application/json">.

+

lucent-tokens.css

+
/* Lucent tokens v1.0. Canonical values for Electron Fiddle.
+   Light and dark follow the OS (Electron nativeTheme drives prefers-color-scheme).
+   Force a mode with data-theme="light" | "dark" on <html>. */
+
+:root {
+  --lu-font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
+  --lu-font-mono: "Commit Mono", ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace;
+  --lu-font-features: "cv05", "ss03";
+  /* type scale: size / line height / weight / tracking */
+  --lu-type-display: 650 40px/44px var(--lu-font-sans); --lu-type-display-tracking: -0.028em;
+  --lu-type-title: 650 22px/28px var(--lu-font-sans); --lu-type-title-tracking: -0.018em;
+  --lu-type-headline: 600 15px/20px var(--lu-font-sans); --lu-type-headline-tracking: -0.01em;
+  --lu-type-body: 400 13px/18px var(--lu-font-sans); --lu-type-body-tracking: 0;
+  --lu-type-label: 500 13px/16px var(--lu-font-sans); --lu-type-label-tracking: 0;
+  --lu-type-caption: 600 11px/14px var(--lu-font-sans); --lu-type-caption-tracking: 0.005em;
+  --lu-type-code: 400 13px/20px var(--lu-font-mono); --lu-type-code-tracking: 0;
+  /* radius (round). Where CSS.supports("corner-shape", "squircle"), use the squircle set below. */
+  --lu-radius-code: 5px;
+  --lu-radius-check: 5px;
+  --lu-radius-sm: 6px;
+  --lu-radius-item: 8px;
+  --lu-radius-control: 10px;
+  --lu-radius-menu: 13px;
+  --lu-radius-panel: 16px;
+  --lu-radius-dialog: 20px;
+  --lu-radius-sheet: 16px;
+  --lu-radius-float: 26px;
+  --lu-radius-capsule: 999px;
+  /* size */
+  --lu-size-titlebar: 56px;
+  --lu-size-toolbar: 36px;
+  --lu-size-toolbar-inner: 30px;
+  --lu-size-control: 30px;
+  --lu-size-control-sm: 24px;
+  --lu-size-row: 28px;
+  --lu-size-tabrow: 46px;
+  --lu-size-sidebar: 228px;
+  --lu-size-sheet-inset: 8px;
+  --lu-size-editor-line: 20px;
+  --lu-size-gutter: 66px;
+  --lu-size-assist: 40px;
+  --lu-size-console: 160px;
+  --lu-size-console-bar: 46px;
+  --lu-size-statusbar: 32px;
+  --lu-size-hairline: 0.5px;
+  --lu-size-icon: 16px;
+  --lu-space-1: 4px;
+  --lu-space-2: 8px;
+  --lu-space-3: 12px;
+  --lu-space-4: 16px;
+  --lu-space-6: 24px;
+  --lu-space-8: 32px;
+  /* motion */
+  --lu-ease: cubic-bezier(0.32, 0.72, 0, 1);
+  --lu-dur-instant: 80ms;
+  --lu-dur-fast: 150ms;
+  --lu-dur-base: 220ms;
+  --lu-dur-slow: 280ms;
+  --lu-dur-enter: 500ms;
+  --lu-press-scale: 0.965;
+}
+@supports (corner-shape: squircle) {
+  :root {
+    --lu-radius-check: 7px;
+    --lu-radius-sm: 9px;
+    --lu-radius-item: 12px;
+    --lu-radius-control: 15px;
+    --lu-radius-menu: 17px;
+    --lu-radius-panel: 24px;
+    --lu-radius-dialog: 28px;
+    --lu-radius-sheet: 26px;
+    --lu-radius-float: 40px;
+  }
+  .lu-squircle { corner-shape: squircle; }
+}
+
+:root {
+  color-scheme: light;
+  /* ground and surfaces */
+  --lu-window: #eef3f8;
+  --lu-surface: #ffffff;
+  --lu-surface-raised: #ffffff;
+  --lu-surface-sunken: #f3f6fa;
+  --lu-hover: rgba(27,28,38,0.06);
+  --lu-fill: rgba(118,118,128,0.12);
+  --lu-fill-strong: rgba(118,118,128,0.20);
+  --lu-line: rgba(27,28,38,0.12);
+  --lu-line-strong: #7b8196;
+  /* materials */
+  --lu-material-chrome: rgba(250,250,253,0.62);
+  --lu-material-thin: rgba(255,255,255,0.42);
+  --lu-material-overlay: rgba(252,252,254,0.86);
+  --lu-capsule: rgba(255,255,255,0.60);
+  --lu-capsule-hover: rgba(255,255,255,0.86);
+  --lu-chip: rgba(255,255,255,0.88);
+  --lu-rim: rgba(255,255,255,0.95);
+  --lu-rim-soft: rgba(255,255,255,0.34);
+  --lu-scrim: rgba(226,228,238,0.40);
+  /* text */
+  --lu-ink: #1b1c26;
+  --lu-ink-muted: #4c5266;
+  --lu-ink-faint: #5f6578;
+  /* accent and signals */
+  --lu-accent: #006f85;
+  --lu-accent-soft: #dcf3f8;
+  --lu-action: #1b1c26;
+  --lu-on-action: #9feaf9;
+  --lu-focus-ring: var(--lu-accent);
+  --lu-spark: #d4122b;
+  --lu-spark-soft: #fdecee;
+  --lu-success: #0b6b3d;
+  --lu-warning: #9e5100;
+  /* controls */
+  --lu-field: #ffffff;
+  --lu-field-on-glass: rgba(255,255,255,0.72);
+  --lu-field-edge-on-glass: rgba(29,29,31,0.56);
+  --lu-button: #ffffff;
+  --lu-button-hover: #f6f6f8;
+  --lu-button-edge: rgba(20,22,40,0.16);
+  --lu-thumb: #ffffff;
+  --lu-kbd: #ffffff;
+  --lu-tooltip: rgba(27,28,38,0.90);
+  --lu-tooltip-ink: #f3f5fa;
+  /* editor */
+  --lu-syntax-keyword: #006f85;
+  --lu-syntax-string: #a5470f;
+  --lu-syntax-function: #1f5fd1;
+  --lu-syntax-number: #c01127;
+  --lu-syntax-comment: #646a7c;
+  --lu-cursor-line: color-mix(in srgb, var(--lu-accent) 7%, var(--lu-surface));
+  --lu-assist-ring: linear-gradient(100deg, #1f7cff, #00a3bf 38%, #6fd6ea 70%, #1b1c26);
+  /* elevation */
+  --lu-shadow-popover: 0 0 0 0.5px rgba(20,22,40,0.14), 0 12px 32px -8px rgba(20,24,48,0.28), 0 2px 6px rgba(20,24,48,0.08);
+  --lu-shadow-island: 0 0 0 0.5px rgba(20,22,40,0.10), 0 1px 2px rgba(20,24,48,0.06), 0 8px 24px -12px rgba(20,24,48,0.22);
+  --lu-shadow-lift: 0 0 0 0.5px rgba(20,22,40,0.08), 0 1px 2px rgba(20,22,40,0.12), 0 2px 6px -2px rgba(20,22,40,0.1);
+  --lu-shadow-capsule: inset 0 0.5px 0 #fff, inset 0 0 0 0.5px rgba(255,255,255,0.55), 0 0 0 0.5px rgba(20,22,40,0.08), 0 3px 10px -3px rgba(30,40,90,0.18);
+  --lu-shadow-button: 0 0 0 0.5px rgba(20,22,40,0.16), 0 1px 2px rgba(0,0,0,0.07), inset 0 0.5px 0 rgba(255,255,255,0.7);
+  --lu-shadow-primary: inset 0 0.5px 0 rgba(255,255,255,0.5), inset 0 -0.5px 0 rgba(0,0,0,0.12), 0 1px 2px color-mix(in srgb, var(--lu-action) 35%, transparent), 0 4px 12px -4px color-mix(in srgb, var(--lu-action) 60%, transparent);
+  --lu-shadow-float: 0 0 0 0.5px rgba(20,22,40,0.18), 0 44px 90px -24px rgba(18,40,90,0.42), 0 14px 30px -12px rgba(18,40,90,0.2);
+  /* blur */
+  --lu-blur-chrome: blur(34px) saturate(180%);
+  --lu-blur-overlay: blur(24px) saturate(180%);
+  --lu-blur-thin: blur(22px) saturate(190%);
+  --lu-blur-tooltip: blur(16px) saturate(160%);
+  --lu-blur-scrim: blur(8px) saturate(140%);
+}
+
+@media (prefers-color-scheme: dark) {
+  :root:not([data-theme="light"]) {
+    color-scheme: dark;
+    /* ground and surfaces */
+    --lu-window: #10121b;
+    --lu-surface: #1b1c26;
+    --lu-surface-raised: #262837;
+    --lu-surface-sunken: #15161f;
+    --lu-hover: rgba(159,234,249,0.08);
+    --lu-fill: rgba(255,255,255,0.08);
+    --lu-fill-strong: rgba(255,255,255,0.13);
+    --lu-line: rgba(159,234,249,0.12);
+    --lu-line-strong: #6d7594;
+    /* materials */
+    --lu-material-chrome: rgba(22,24,36,0.62);
+    --lu-material-thin: rgba(27,28,38,0.42);
+    --lu-material-overlay: rgba(38,40,56,0.90);
+    --lu-capsule: rgba(255,255,255,0.08);
+    --lu-capsule-hover: rgba(255,255,255,0.15);
+    --lu-chip: rgba(255,255,255,0.13);
+    --lu-rim: rgba(255,255,255,0.30);
+    --lu-rim-soft: rgba(255,255,255,0.07);
+    --lu-scrim: rgba(6,8,18,0.50);
+    /* text */
+    --lu-ink: #eef1f8;
+    --lu-ink-muted: #aeb4c9;
+    --lu-ink-faint: #949ab2;
+    /* accent and signals */
+    --lu-accent: #9feaf9;
+    --lu-accent-soft: #1c3945;
+    --lu-action: #9feaf9;
+    --lu-on-action: #0d2a31;
+    --lu-focus-ring: var(--lu-accent);
+    --lu-spark: #ff6e6e;
+    --lu-spark-soft: #3a1d26;
+    --lu-success: #6ee3a4;
+    --lu-warning: #f3c46b;
+    /* controls */
+    --lu-field: #171923;
+    --lu-field-on-glass: rgba(255,255,255,0.07);
+    --lu-field-edge-on-glass: rgba(245,245,247,0.50);
+    --lu-button: rgba(255,255,255,0.11);
+    --lu-button-hover: rgba(255,255,255,0.16);
+    --lu-button-edge: rgba(0,0,0,0.45);
+    --lu-thumb: #3c4058;
+    --lu-kbd: rgba(255,255,255,0.08);
+    --lu-tooltip: rgba(38,40,56,0.92);
+    --lu-tooltip-ink: #eef1f8;
+    /* editor */
+    --lu-syntax-keyword: #9feaf9;
+    --lu-syntax-string: #ffb38a;
+    --lu-syntax-function: #86b4ff;
+    --lu-syntax-number: #ff9a9a;
+    --lu-syntax-comment: #8a90aa;
+    --lu-cursor-line: color-mix(in srgb, var(--lu-accent) 7%, var(--lu-surface));
+    --lu-assist-ring: linear-gradient(100deg, #9feaf9, #47a8ff 38%, #2f6bff 70%, #7fdcff);
+    /* elevation */
+    --lu-shadow-popover: 0 0 0 0.5px rgba(4,6,16,0.6), 0 16px 40px -8px rgba(4,6,16,0.65), 0 2px 8px rgba(4,6,16,0.35);
+    --lu-shadow-island: 0 0 0 0.5px rgba(4,6,16,0.55), 0 1px 2px rgba(4,6,16,0.3), 0 10px 28px -12px rgba(4,6,16,0.6);
+    --lu-shadow-lift: inset 0 0.5px 0 rgba(255,255,255,0.14), 0 0 0 0.5px rgba(0,0,0,0.5), 0 1px 3px rgba(0,0,0,0.35);
+    --lu-shadow-capsule: inset 0 0.5px 0 rgba(255,255,255,0.2), inset 0 0 0 0.5px rgba(255,255,255,0.06), 0 0 0 0.5px rgba(0,0,0,0.45), 0 3px 10px -3px rgba(0,0,0,0.45);
+    --lu-shadow-button: 0 0 0 0.5px rgba(0,0,0,0.45), 0 1px 2px rgba(0,0,0,0.3), inset 0 0.5px 0 rgba(255,255,255,0.12);
+    --lu-shadow-primary: inset 0 0.5px 0 rgba(255,255,255,0.5), inset 0 -0.5px 0 rgba(0,0,0,0.12), 0 1px 2px color-mix(in srgb, var(--lu-action) 35%, transparent), 0 4px 12px -4px color-mix(in srgb, var(--lu-action) 60%, transparent);
+    --lu-shadow-float: 0 0 0 0.5px rgba(0,0,0,0.85), 0 44px 90px -24px rgba(0,0,0,0.75), 0 14px 30px -12px rgba(0,0,0,0.5);
+    /* blur */
+    --lu-blur-chrome: blur(34px) saturate(170%);
+    --lu-blur-overlay: blur(24px) saturate(170%);
+    --lu-blur-thin: blur(22px) saturate(190%);
+    --lu-blur-tooltip: blur(16px) saturate(160%);
+    --lu-blur-scrim: blur(8px) saturate(140%);
+  }
+}
+
+:root[data-theme="dark"] {
+  color-scheme: dark;
+  /* ground and surfaces */
+  --lu-window: #10121b;
+  --lu-surface: #1b1c26;
+  --lu-surface-raised: #262837;
+  --lu-surface-sunken: #15161f;
+  --lu-hover: rgba(159,234,249,0.08);
+  --lu-fill: rgba(255,255,255,0.08);
+  --lu-fill-strong: rgba(255,255,255,0.13);
+  --lu-line: rgba(159,234,249,0.12);
+  --lu-line-strong: #6d7594;
+  /* materials */
+  --lu-material-chrome: rgba(22,24,36,0.62);
+  --lu-material-thin: rgba(27,28,38,0.42);
+  --lu-material-overlay: rgba(38,40,56,0.90);
+  --lu-capsule: rgba(255,255,255,0.08);
+  --lu-capsule-hover: rgba(255,255,255,0.15);
+  --lu-chip: rgba(255,255,255,0.13);
+  --lu-rim: rgba(255,255,255,0.30);
+  --lu-rim-soft: rgba(255,255,255,0.07);
+  --lu-scrim: rgba(6,8,18,0.50);
+  /* text */
+  --lu-ink: #eef1f8;
+  --lu-ink-muted: #aeb4c9;
+  --lu-ink-faint: #949ab2;
+  /* accent and signals */
+  --lu-accent: #9feaf9;
+  --lu-accent-soft: #1c3945;
+  --lu-action: #9feaf9;
+  --lu-on-action: #0d2a31;
+  --lu-focus-ring: var(--lu-accent);
+  --lu-spark: #ff6e6e;
+  --lu-spark-soft: #3a1d26;
+  --lu-success: #6ee3a4;
+  --lu-warning: #f3c46b;
+  /* controls */
+  --lu-field: #171923;
+  --lu-field-on-glass: rgba(255,255,255,0.07);
+  --lu-field-edge-on-glass: rgba(245,245,247,0.50);
+  --lu-button: rgba(255,255,255,0.11);
+  --lu-button-hover: rgba(255,255,255,0.16);
+  --lu-button-edge: rgba(0,0,0,0.45);
+  --lu-thumb: #3c4058;
+  --lu-kbd: rgba(255,255,255,0.08);
+  --lu-tooltip: rgba(38,40,56,0.92);
+  --lu-tooltip-ink: #eef1f8;
+  /* editor */
+  --lu-syntax-keyword: #9feaf9;
+  --lu-syntax-string: #ffb38a;
+  --lu-syntax-function: #86b4ff;
+  --lu-syntax-number: #ff9a9a;
+  --lu-syntax-comment: #8a90aa;
+  --lu-cursor-line: color-mix(in srgb, var(--lu-accent) 7%, var(--lu-surface));
+  --lu-assist-ring: linear-gradient(100deg, #9feaf9, #47a8ff 38%, #2f6bff 70%, #7fdcff);
+  /* elevation */
+  --lu-shadow-popover: 0 0 0 0.5px rgba(4,6,16,0.6), 0 16px 40px -8px rgba(4,6,16,0.65), 0 2px 8px rgba(4,6,16,0.35);
+  --lu-shadow-island: 0 0 0 0.5px rgba(4,6,16,0.55), 0 1px 2px rgba(4,6,16,0.3), 0 10px 28px -12px rgba(4,6,16,0.6);
+  --lu-shadow-lift: inset 0 0.5px 0 rgba(255,255,255,0.14), 0 0 0 0.5px rgba(0,0,0,0.5), 0 1px 3px rgba(0,0,0,0.35);
+  --lu-shadow-capsule: inset 0 0.5px 0 rgba(255,255,255,0.2), inset 0 0 0 0.5px rgba(255,255,255,0.06), 0 0 0 0.5px rgba(0,0,0,0.45), 0 3px 10px -3px rgba(0,0,0,0.45);
+  --lu-shadow-button: 0 0 0 0.5px rgba(0,0,0,0.45), 0 1px 2px rgba(0,0,0,0.3), inset 0 0.5px 0 rgba(255,255,255,0.12);
+  --lu-shadow-primary: inset 0 0.5px 0 rgba(255,255,255,0.5), inset 0 -0.5px 0 rgba(0,0,0,0.12), 0 1px 2px color-mix(in srgb, var(--lu-action) 35%, transparent), 0 4px 12px -4px color-mix(in srgb, var(--lu-action) 60%, transparent);
+  --lu-shadow-float: 0 0 0 0.5px rgba(0,0,0,0.85), 0 44px 90px -24px rgba(0,0,0,0.75), 0 14px 30px -12px rgba(0,0,0,0.5);
+  /* blur */
+  --lu-blur-chrome: blur(34px) saturate(170%);
+  --lu-blur-overlay: blur(24px) saturate(170%);
+  --lu-blur-thin: blur(22px) saturate(190%);
+  --lu-blur-tooltip: blur(16px) saturate(160%);
+  --lu-blur-scrim: blur(8px) saturate(140%);
+}
+
+/* No native material (Linux, a failed material) or Reduce transparency: go opaque. */
+:root.lu-no-material { --lu-material-chrome: var(--lu-window); --lu-material-thin: var(--lu-surface-raised); --lu-material-overlay: var(--lu-surface-raised); }
+@media (prefers-reduced-transparency: reduce) {
+  :root { --lu-material-chrome: var(--lu-window); --lu-material-thin: var(--lu-surface-raised); --lu-material-overlay: var(--lu-surface-raised); }
+}
+@media (prefers-reduced-motion: reduce) {
+  :root { --lu-dur-base: 0ms; --lu-dur-slow: 0ms; --lu-dur-enter: 0ms; --lu-press-scale: 1; }
+}
+
+

lucent-tokens.json

+
{
+ "name": "Lucent",
+ "version": "1.0",
+ "date": "2026-09-12",
+ "prefix": "--lu-",
+ "modes": [
+  "dark",
+  "light"
+ ],
+ "color": [
+  {
+   "group": "Ground and surfaces",
+   "token": "window",
+   "dark": "#10121b",
+   "light": "#eef3f8",
+   "use": "Opaque window ground. Shows only where the OS gives no material: Linux, Reduce transparency, or a failed material."
+  },
+  {
+   "group": "Ground and surfaces",
+   "token": "surface",
+   "dark": "#1b1c26",
+   "light": "#ffffff",
+   "use": "The sheet: editor, console, settings pages. Dark is Electron navy #1b1c26 exactly."
+  },
+  {
+   "group": "Ground and surfaces",
+   "token": "surface-raised",
+   "dark": "#262837",
+   "light": "#ffffff",
+   "use": "Opaque overlays and the fallback for every material."
+  },
+  {
+   "group": "Ground and surfaces",
+   "token": "surface-sunken",
+   "dark": "#15161f",
+   "light": "#f3f6fa",
+   "use": "Console log, table heads, wells."
+  },
+  {
+   "group": "Ground and surfaces",
+   "token": "hover",
+   "dark": "rgba(159,234,249,0.08)",
+   "light": "rgba(27,28,38,0.06)",
+   "use": "Hovered rows and the selected tab, on the sheet."
+  },
+  {
+   "group": "Ground and surfaces",
+   "token": "fill",
+   "dark": "rgba(255,255,255,0.08)",
+   "light": "rgba(118,118,128,0.12)",
+   "use": "Neutral fill: ghost hover, segmented track, callouts, inline code."
+  },
+  {
+   "group": "Ground and surfaces",
+   "token": "fill-strong",
+   "dark": "rgba(255,255,255,0.13)",
+   "light": "rgba(118,118,128,0.20)",
+   "use": "Pressed states, menu item hover, progress track."
+  },
+  {
+   "group": "Ground and surfaces",
+   "token": "line",
+   "dark": "rgba(159,234,249,0.12)",
+   "light": "rgba(27,28,38,0.12)",
+   "use": "Hairlines (0.5px) between rows and above the console. Nowhere else."
+  },
+  {
+   "group": "Ground and surfaces",
+   "token": "line-strong",
+   "dark": "#6d7594",
+   "light": "#7b8196",
+   "use": "Field and control borders. 3:1 on every surface."
+  },
+  {
+   "group": "Materials",
+   "token": "material-chrome",
+   "dark": "rgba(22,24,36,0.62)",
+   "light": "rgba(250,250,253,0.62)",
+   "use": "Tint painted over the native window material: title bar, sidebar, status bar."
+  },
+  {
+   "group": "Materials",
+   "token": "material-thin",
+   "dark": "rgba(27,28,38,0.42)",
+   "light": "rgba(255,255,255,0.42)",
+   "use": "App-drawn floating panes (pop-out previews). Blurred with blur-thin."
+  },
+  {
+   "group": "Materials",
+   "token": "material-overlay",
+   "dark": "rgba(38,40,56,0.90)",
+   "light": "rgba(252,252,254,0.86)",
+   "use": "Menus, popovers, toasts, dialogs. Blurred with blur-overlay."
+  },
+  {
+   "group": "Materials",
+   "token": "capsule",
+   "dark": "rgba(255,255,255,0.08)",
+   "light": "rgba(255,255,255,0.60)",
+   "use": "Toolbar capsules and toolbar buttons on the chrome."
+  },
+  {
+   "group": "Materials",
+   "token": "capsule-hover",
+   "dark": "rgba(255,255,255,0.15)",
+   "light": "rgba(255,255,255,0.86)",
+   "use": "Toolbar button hover."
+  },
+  {
+   "group": "Materials",
+   "token": "chip",
+   "dark": "rgba(255,255,255,0.13)",
+   "light": "rgba(255,255,255,0.88)",
+   "use": "Selected sidebar row, lifted with shadow-lift."
+  },
+  {
+   "group": "Materials",
+   "token": "rim",
+   "dark": "rgba(255,255,255,0.30)",
+   "light": "rgba(255,255,255,0.95)",
+   "use": "The lit top edge of glass (inset 0.5px)."
+  },
+  {
+   "group": "Materials",
+   "token": "rim-soft",
+   "dark": "rgba(255,255,255,0.07)",
+   "light": "rgba(255,255,255,0.34)",
+   "use": "The faint all-round edge of glass (inset 0.5px)."
+  },
+  {
+   "group": "Materials",
+   "token": "scrim",
+   "dark": "rgba(6,8,18,0.50)",
+   "light": "rgba(226,228,238,0.40)",
+   "use": "Behind dialogs, with blur-scrim."
+  },
+  {
+   "group": "Text",
+   "token": "ink",
+   "dark": "#eef1f8",
+   "light": "#1b1c26",
+   "use": "Primary text on every surface and material."
+  },
+  {
+   "group": "Text",
+   "token": "ink-muted",
+   "dark": "#aeb4c9",
+   "light": "#4c5266",
+   "use": "Secondary text, inactive tabs, sidebar heads, labels."
+  },
+  {
+   "group": "Text",
+   "token": "ink-faint",
+   "dark": "#949ab2",
+   "light": "#5f6578",
+   "use": "Line numbers and timestamps. Never for sentences."
+  },
+  {
+   "group": "Accent and signals",
+   "token": "accent",
+   "dark": "#9feaf9",
+   "light": "#006f85",
+   "use": "Electron cyan (teal in light): focus, selection, links, the active icon."
+  },
+  {
+   "group": "Accent and signals",
+   "token": "accent-soft",
+   "dark": "#1c3945",
+   "light": "#dcf3f8",
+   "use": "Selected list rows, primary callouts, search matches."
+  },
+  {
+   "group": "Accent and signals",
+   "token": "action",
+   "dark": "#9feaf9",
+   "light": "#1b1c26",
+   "use": "Primary fill: Run, checked boxes, switches on. Cyan in dark, navy in light."
+  },
+  {
+   "group": "Accent and signals",
+   "token": "on-action",
+   "dark": "#0d2a31",
+   "light": "#9feaf9",
+   "use": "Label and icon on action."
+  },
+  {
+   "group": "Accent and signals",
+   "token": "focus-ring",
+   "dark": "var(--lu-accent)",
+   "light": "var(--lu-accent)",
+   "use": "Keyboard focus. Drawn at 70% opacity, 3px."
+  },
+  {
+   "group": "Accent and signals",
+   "token": "spark",
+   "dark": "#ff6e6e",
+   "light": "#d4122b",
+   "use": "Electron red: running, errors, destructive actions. Small doses."
+  },
+  {
+   "group": "Accent and signals",
+   "token": "spark-soft",
+   "dark": "#3a1d26",
+   "light": "#fdecee",
+   "use": "Error line, error lens, Stop button, error badges."
+  },
+  {
+   "group": "Accent and signals",
+   "token": "success",
+   "dark": "#6ee3a4",
+   "light": "#0b6b3d",
+   "use": "Installed, passed, saved."
+  },
+  {
+   "group": "Accent and signals",
+   "token": "warning",
+   "dark": "#f3c46b",
+   "light": "#9e5100",
+   "use": "Deprecated APIs and pre-release versions."
+  },
+  {
+   "group": "Controls",
+   "token": "field",
+   "dark": "#171923",
+   "light": "#ffffff",
+   "use": "Text fields, selects, checkboxes and radios on the sheet."
+  },
+  {
+   "group": "Controls",
+   "token": "field-on-glass",
+   "dark": "rgba(255,255,255,0.07)",
+   "light": "rgba(255,255,255,0.72)",
+   "use": "Fields that sit on the chrome (sidebar search)."
+  },
+  {
+   "group": "Controls",
+   "token": "field-edge-on-glass",
+   "dark": "rgba(245,245,247,0.50)",
+   "light": "rgba(29,29,31,0.56)",
+   "use": "Border of fields on the chrome."
+  },
+  {
+   "group": "Controls",
+   "token": "button",
+   "dark": "rgba(255,255,255,0.11)",
+   "light": "#ffffff",
+   "use": "Secondary button fill."
+  },
+  {
+   "group": "Controls",
+   "token": "button-hover",
+   "dark": "rgba(255,255,255,0.16)",
+   "light": "#f6f6f8",
+   "use": "Secondary button hover."
+  },
+  {
+   "group": "Controls",
+   "token": "button-edge",
+   "dark": "rgba(0,0,0,0.45)",
+   "light": "rgba(20,22,40,0.16)",
+   "use": "The 0.5px ring on secondary buttons, cards and tables."
+  },
+  {
+   "group": "Controls",
+   "token": "thumb",
+   "dark": "#3c4058",
+   "light": "#ffffff",
+   "use": "Selected segment in a segmented control."
+  },
+  {
+   "group": "Controls",
+   "token": "kbd",
+   "dark": "rgba(255,255,255,0.08)",
+   "light": "#ffffff",
+   "use": "Key caps."
+  },
+  {
+   "group": "Controls",
+   "token": "tooltip",
+   "dark": "rgba(38,40,56,0.92)",
+   "light": "rgba(27,28,38,0.90)",
+   "use": "Tooltip fill. Navy in both appearances."
+  },
+  {
+   "group": "Controls",
+   "token": "tooltip-ink",
+   "dark": "#eef1f8",
+   "light": "#f3f5fa",
+   "use": "Tooltip text."
+  },
+  {
+   "group": "Editor",
+   "token": "syntax-keyword",
+   "dark": "#9feaf9",
+   "light": "#006f85",
+   "use": "const, function, require, async, await."
+  },
+  {
+   "group": "Editor",
+   "token": "syntax-string",
+   "dark": "#ffb38a",
+   "light": "#a5470f",
+   "use": "Strings and template literals."
+  },
+  {
+   "group": "Editor",
+   "token": "syntax-function",
+   "dark": "#86b4ff",
+   "light": "#1f5fd1",
+   "use": "Functions and methods, in Electron bright blue."
+  },
+  {
+   "group": "Editor",
+   "token": "syntax-number",
+   "dark": "#ff9a9a",
+   "light": "#c01127",
+   "use": "Numbers, booleans, null."
+  },
+  {
+   "group": "Editor",
+   "token": "syntax-comment",
+   "dark": "#8a90aa",
+   "light": "#646a7c",
+   "use": "Comments."
+  },
+  {
+   "group": "Editor",
+   "token": "cursor-line",
+   "dark": "color-mix(in srgb, var(--lu-accent) 7%, var(--lu-surface))",
+   "light": "color-mix(in srgb, var(--lu-accent) 7%, var(--lu-surface))",
+   "use": "The line the caret is on."
+  },
+  {
+   "group": "Accent and signals",
+   "token": "assist-ring",
+   "dark": "linear-gradient(100deg, #9feaf9, #47a8ff 38%, #2f6bff 70%, #7fdcff)",
+   "light": "linear-gradient(100deg, #1f7cff, #00a3bf 38%, #6fd6ea 70%, #1b1c26)",
+   "use": "The assist field border only. The one multicolour element in the app."
+  }
+ ],
+ "shadow": [
+  {
+   "token": "shadow-popover",
+   "dark": "0 0 0 0.5px rgba(4,6,16,0.6), 0 16px 40px -8px rgba(4,6,16,0.65), 0 2px 8px rgba(4,6,16,0.35)",
+   "light": "0 0 0 0.5px rgba(20,22,40,0.14), 0 12px 32px -8px rgba(20,24,48,0.28), 0 2px 6px rgba(20,24,48,0.08)",
+   "use": "Menus, popovers, toasts, dialogs. Add the rims (see materials)."
+  },
+  {
+   "token": "shadow-island",
+   "dark": "0 0 0 0.5px rgba(4,6,16,0.55), 0 1px 2px rgba(4,6,16,0.3), 0 10px 28px -12px rgba(4,6,16,0.6)",
+   "light": "0 0 0 0.5px rgba(20,22,40,0.10), 0 1px 2px rgba(20,24,48,0.06), 0 8px 24px -12px rgba(20,24,48,0.22)",
+   "use": "The sheet, cards, swatches."
+  },
+  {
+   "token": "shadow-lift",
+   "dark": "inset 0 0.5px 0 rgba(255,255,255,0.14), 0 0 0 0.5px rgba(0,0,0,0.5), 0 1px 3px rgba(0,0,0,0.35)",
+   "light": "0 0 0 0.5px rgba(20,22,40,0.08), 0 1px 2px rgba(20,22,40,0.12), 0 2px 6px -2px rgba(20,22,40,0.1)",
+   "use": "Selected sidebar row, selected segment."
+  },
+  {
+   "token": "shadow-capsule",
+   "dark": "inset 0 0.5px 0 rgba(255,255,255,0.2), inset 0 0 0 0.5px rgba(255,255,255,0.06), 0 0 0 0.5px rgba(0,0,0,0.45), 0 3px 10px -3px rgba(0,0,0,0.45)",
+   "light": "inset 0 0.5px 0 #fff, inset 0 0 0 0.5px rgba(255,255,255,0.55), 0 0 0 0.5px rgba(20,22,40,0.08), 0 3px 10px -3px rgba(30,40,90,0.18)",
+   "use": "Toolbar capsules and toolbar buttons."
+  },
+  {
+   "token": "shadow-button",
+   "dark": "0 0 0 0.5px rgba(0,0,0,0.45), 0 1px 2px rgba(0,0,0,0.3), inset 0 0.5px 0 rgba(255,255,255,0.12)",
+   "light": "0 0 0 0.5px rgba(20,22,40,0.16), 0 1px 2px rgba(0,0,0,0.07), inset 0 0.5px 0 rgba(255,255,255,0.7)",
+   "use": "Secondary buttons."
+  },
+  {
+   "token": "shadow-primary",
+   "dark": "inset 0 0.5px 0 rgba(255,255,255,0.5), inset 0 -0.5px 0 rgba(0,0,0,0.12), 0 1px 2px color-mix(in srgb, var(--lu-action) 35%, transparent), 0 4px 12px -4px color-mix(in srgb, var(--lu-action) 60%, transparent)",
+   "light": "inset 0 0.5px 0 rgba(255,255,255,0.5), inset 0 -0.5px 0 rgba(0,0,0,0.12), 0 1px 2px color-mix(in srgb, var(--lu-action) 35%, transparent), 0 4px 12px -4px color-mix(in srgb, var(--lu-action) 60%, transparent)",
+   "use": "Primary buttons (Run). Pair with the sheen gradient."
+  },
+  {
+   "token": "shadow-float",
+   "dark": "0 0 0 0.5px rgba(0,0,0,0.85), 0 44px 90px -24px rgba(0,0,0,0.75), 0 14px 30px -12px rgba(0,0,0,0.5)",
+   "light": "0 0 0 0.5px rgba(20,22,40,0.18), 0 44px 90px -24px rgba(18,40,90,0.42), 0 14px 30px -12px rgba(18,40,90,0.2)",
+   "use": "App-drawn floating panes only. Real windows get the OS shadow."
+  }
+ ],
+ "blur": [
+  {
+   "token": "blur-chrome",
+   "dark": "blur(34px) saturate(170%)",
+   "light": "blur(34px) saturate(180%)",
+   "use": "Only when the app draws its own glass over its own content. Real window chrome uses the native material instead."
+  },
+  {
+   "token": "blur-overlay",
+   "dark": "blur(24px) saturate(170%)",
+   "light": "blur(24px) saturate(180%)",
+   "use": "Menus, popovers, toasts, dialogs."
+  },
+  {
+   "token": "blur-thin",
+   "dark": "blur(22px) saturate(190%)",
+   "light": "blur(22px) saturate(190%)",
+   "use": "App-drawn floating panes."
+  },
+  {
+   "token": "blur-tooltip",
+   "dark": "blur(16px) saturate(160%)",
+   "light": "blur(16px) saturate(160%)",
+   "use": "Tooltips."
+  },
+  {
+   "token": "blur-scrim",
+   "dark": "blur(8px) saturate(140%)",
+   "light": "blur(8px) saturate(140%)",
+   "use": "Dialog scrim."
+  }
+ ],
+ "radius": [
+  {
+   "token": "radius-code",
+   "round": "5px",
+   "squircle": "5px",
+   "use": "Inline code, search-match highlight (3px)."
+  },
+  {
+   "token": "radius-check",
+   "round": "5px",
+   "squircle": "7px",
+   "use": "Checkboxes."
+  },
+  {
+   "token": "radius-sm",
+   "round": "6px",
+   "squircle": "9px",
+   "use": "Key caps, small chips."
+  },
+  {
+   "token": "radius-item",
+   "round": "8px",
+   "squircle": "12px",
+   "use": "Menu items, tree rows, tabs, tooltips (8px, round), console rows."
+  },
+  {
+   "token": "radius-control",
+   "round": "10px",
+   "squircle": "15px",
+   "use": "Buttons, fields, selects, segmented controls, callouts, list rows, error lens."
+  },
+  {
+   "token": "radius-menu",
+   "round": "13px",
+   "squircle": "17px",
+   "use": "Menus: radius-item plus the 5px menu padding, so corners stay concentric."
+  },
+  {
+   "token": "radius-panel",
+   "round": "16px",
+   "squircle": "24px",
+   "use": "Popovers, toasts, cards, coachmarks."
+  },
+  {
+   "token": "radius-dialog",
+   "round": "20px",
+   "squircle": "28px",
+   "use": "Dialogs."
+  },
+  {
+   "token": "radius-sheet",
+   "round": "16px",
+   "squircle": "26px",
+   "use": "The sheet inside the window."
+  },
+  {
+   "token": "radius-float",
+   "round": "26px",
+   "squircle": "40px",
+   "use": "App-drawn floating panes. Real windows use the OS corner."
+  },
+  {
+   "token": "radius-capsule",
+   "round": "999px",
+   "squircle": "999px",
+   "use": "Toolbar capsules, toolbar buttons, badges, tags, the assist field, filter chips."
+  }
+ ],
+ "size": [
+  {
+   "token": "size-titlebar",
+   "value": "56px",
+   "use": "Title bar height, including the toolbar."
+  },
+  {
+   "token": "size-toolbar",
+   "value": "36px",
+   "use": "Toolbar capsule and toolbar button outer height."
+  },
+  {
+   "token": "size-toolbar-inner",
+   "value": "30px",
+   "use": "Controls inside a capsule (3px inset)."
+  },
+  {
+   "token": "size-control",
+   "value": "30px",
+   "use": "Default button, field and select."
+  },
+  {
+   "token": "size-control-sm",
+   "value": "24px",
+   "use": "Small buttons and fields; icon buttons in the tab row."
+  },
+  {
+   "token": "size-row",
+   "value": "28px",
+   "use": "Menu items, tree rows, tabs."
+  },
+  {
+   "token": "size-tabrow",
+   "value": "46px",
+   "use": "Tab row, plus a 0.5px hairline."
+  },
+  {
+   "token": "size-sidebar",
+   "value": "228px",
+   "use": "Sidebar width (resizable 200 to 320)."
+  },
+  {
+   "token": "size-sheet-inset",
+   "value": "8px",
+   "use": "Gap between the sheet and the window edge (right side; the sidebar sits left)."
+  },
+  {
+   "token": "size-editor-line",
+   "value": "20px",
+   "use": "Editor line height."
+  },
+  {
+   "token": "size-gutter",
+   "value": "66px",
+   "use": "Line-number column: 48px numbers plus 18px space. The error lens starts here."
+  },
+  {
+   "token": "size-assist",
+   "value": "40px",
+   "use": "Assist row: 24px field plus 8px above and below."
+  },
+  {
+   "token": "size-console",
+   "value": "160px",
+   "use": "Console default height (resizable)."
+  },
+  {
+   "token": "size-console-bar",
+   "value": "46px",
+   "use": "Console header."
+  },
+  {
+   "token": "size-statusbar",
+   "value": "32px",
+   "use": "Status bar."
+  },
+  {
+   "token": "size-hairline",
+   "value": "0.5px",
+   "use": "Every divider. 1px only for field borders."
+  },
+  {
+   "token": "size-icon",
+   "value": "16px",
+   "use": "Icons, drawn at 1.4px stroke."
+  }
+ ],
+ "space": [
+  {
+   "token": "space-1",
+   "value": "4px",
+   "use": "Icon to label, capsule inset (3px is the one exception)."
+  },
+  {
+   "token": "space-2",
+   "value": "8px",
+   "use": "Related controls, sheet inset, assist row padding."
+  },
+  {
+   "token": "space-3",
+   "value": "12px",
+   "use": "Sidebar and title bar padding."
+  },
+  {
+   "token": "space-4",
+   "value": "16px",
+   "use": "Editor padding, card padding."
+  },
+  {
+   "token": "space-6",
+   "value": "24px",
+   "use": "Dialog gutters, section gaps."
+  },
+  {
+   "token": "space-8",
+   "value": "32px",
+   "use": "Empty states, settings page margins."
+  }
+ ],
+ "type": {
+  "sans": "\"Inter\", system-ui, -apple-system, \"Segoe UI\", sans-serif",
+  "mono": "\"Commit Mono\", ui-monospace, \"SF Mono\", \"Cascadia Mono\", Menlo, monospace",
+  "features": "\"cv05\", \"ss03\"",
+  "styles": [
+   {
+    "token": "type-display",
+    "size": "40px",
+    "lineHeight": "44px",
+    "weight": 650,
+    "tracking": "-0.028em",
+    "family": "sans",
+    "use": "Welcome and empty states. Inter switches to its Display cut on its own at this size."
+   },
+   {
+    "token": "type-title",
+    "size": "22px",
+    "lineHeight": "28px",
+    "weight": 650,
+    "tracking": "-0.018em",
+    "family": "sans",
+    "use": "Settings pages and large dialog titles."
+   },
+   {
+    "token": "type-headline",
+    "size": "15px",
+    "lineHeight": "20px",
+    "weight": 600,
+    "tracking": "-0.01em",
+    "family": "sans",
+    "use": "Dialog titles, section heads, toast titles."
+   },
+   {
+    "token": "type-body",
+    "size": "13px",
+    "lineHeight": "18px",
+    "weight": 400,
+    "tracking": "0",
+    "family": "sans",
+    "use": "Default UI text, desktop dense."
+   },
+   {
+    "token": "type-label",
+    "size": "13px",
+    "lineHeight": "16px",
+    "weight": 500,
+    "tracking": "0",
+    "family": "sans",
+    "use": "Buttons, tabs, menu items."
+   },
+   {
+    "token": "type-caption",
+    "size": "11px",
+    "lineHeight": "14px",
+    "weight": 600,
+    "tracking": "0.005em",
+    "family": "sans",
+    "use": "Sidebar heads, field labels, badges. Sentence case, never shouted."
+   },
+   {
+    "token": "type-code",
+    "size": "13px",
+    "lineHeight": "20px",
+    "weight": 400,
+    "tracking": "0",
+    "family": "mono",
+    "use": "Editor and console, in Commit Mono."
+   }
+  ]
+ },
+ "motion": [
+  {
+   "token": "ease",
+   "value": "cubic-bezier(0.32, 0.72, 0, 1)",
+   "use": "Everything that moves. A fast start and a long, soft landing."
+  },
+  {
+   "token": "dur-instant",
+   "value": "80ms",
+   "use": "Menu item hover."
+  },
+  {
+   "token": "dur-fast",
+   "value": "150ms",
+   "use": "Colour, fill and border changes (hover, selection)."
+  },
+  {
+   "token": "dur-base",
+   "value": "220ms",
+   "use": "Press scale, segmented thumb, field focus ring."
+  },
+  {
+   "token": "dur-slow",
+   "value": "280ms",
+   "use": "Switch knob travel."
+  },
+  {
+   "token": "dur-enter",
+   "value": "500ms",
+   "use": "Floating panes appearing: from opacity 0, 14px lower, scale 0.94, blur 6px."
+  },
+  {
+   "token": "press-scale",
+   "value": "0.965",
+   "use": "Buttons shrink to this while pressed. Chips 0.96, large tiles 0.98."
+  }
+ ]
+}
+

Lucent v1.0 · 12 September 2026 · Fonts: Inter by Rasmus Andersson and Commit Mono by Eigil Nikolajsen, both under the SIL Open Font License.

+
+
+
+ + + + + + + + + diff --git a/docs/design/lucent-tokens.css b/docs/design/lucent-tokens.css new file mode 100644 index 0000000000..6b9d695918 --- /dev/null +++ b/docs/design/lucent-tokens.css @@ -0,0 +1,299 @@ +/* Lucent tokens v1.0. Canonical values for Electron Fiddle. + Light and dark follow the OS (Electron nativeTheme drives prefers-color-scheme). + Force a mode with data-theme="light" | "dark" on . */ + +:root { + --lu-font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; + --lu-font-mono: "Commit Mono", ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace; + --lu-font-features: "cv05", "ss03"; + /* type scale: size / line height / weight / tracking */ + --lu-type-display: 650 40px/44px var(--lu-font-sans); --lu-type-display-tracking: -0.028em; + --lu-type-title: 650 22px/28px var(--lu-font-sans); --lu-type-title-tracking: -0.018em; + --lu-type-headline: 600 15px/20px var(--lu-font-sans); --lu-type-headline-tracking: -0.01em; + --lu-type-body: 400 13px/18px var(--lu-font-sans); --lu-type-body-tracking: 0; + --lu-type-label: 500 13px/16px var(--lu-font-sans); --lu-type-label-tracking: 0; + --lu-type-caption: 600 11px/14px var(--lu-font-sans); --lu-type-caption-tracking: 0.005em; + --lu-type-code: 400 13px/20px var(--lu-font-mono); --lu-type-code-tracking: 0; + /* radius (round). Where CSS.supports("corner-shape", "squircle"), use the squircle set below. */ + --lu-radius-code: 5px; + --lu-radius-check: 5px; + --lu-radius-sm: 6px; + --lu-radius-item: 8px; + --lu-radius-control: 10px; + --lu-radius-menu: 13px; + --lu-radius-panel: 16px; + --lu-radius-dialog: 20px; + --lu-radius-sheet: 16px; + --lu-radius-float: 26px; + --lu-radius-capsule: 999px; + /* size */ + --lu-size-titlebar: 56px; + --lu-size-toolbar: 36px; + --lu-size-toolbar-inner: 30px; + --lu-size-control: 30px; + --lu-size-control-sm: 24px; + --lu-size-row: 28px; + --lu-size-tabrow: 46px; + --lu-size-sidebar: 228px; + --lu-size-sheet-inset: 8px; + --lu-size-editor-line: 20px; + --lu-size-gutter: 66px; + --lu-size-assist: 40px; + --lu-size-console: 160px; + --lu-size-console-bar: 46px; + --lu-size-statusbar: 32px; + --lu-size-hairline: 0.5px; + --lu-size-icon: 16px; + --lu-space-1: 4px; + --lu-space-2: 8px; + --lu-space-3: 12px; + --lu-space-4: 16px; + --lu-space-6: 24px; + --lu-space-8: 32px; + /* motion */ + --lu-ease: cubic-bezier(0.32, 0.72, 0, 1); + --lu-dur-instant: 80ms; + --lu-dur-fast: 150ms; + --lu-dur-base: 220ms; + --lu-dur-slow: 280ms; + --lu-dur-enter: 500ms; + --lu-press-scale: 0.965; +} +@supports (corner-shape: squircle) { + :root { + --lu-radius-check: 7px; + --lu-radius-sm: 9px; + --lu-radius-item: 12px; + --lu-radius-control: 15px; + --lu-radius-menu: 17px; + --lu-radius-panel: 24px; + --lu-radius-dialog: 28px; + --lu-radius-sheet: 26px; + --lu-radius-float: 40px; + } + .lu-squircle { corner-shape: squircle; } +} + +:root { + color-scheme: light; + /* ground and surfaces */ + --lu-window: #eef3f8; + --lu-surface: #ffffff; + --lu-surface-raised: #ffffff; + --lu-surface-sunken: #f3f6fa; + --lu-hover: rgba(27,28,38,0.06); + --lu-fill: rgba(118,118,128,0.12); + --lu-fill-strong: rgba(118,118,128,0.20); + --lu-line: rgba(27,28,38,0.12); + --lu-line-strong: #7b8196; + /* materials */ + --lu-material-chrome: rgba(250,250,253,0.62); + --lu-material-thin: rgba(255,255,255,0.42); + --lu-material-overlay: rgba(252,252,254,0.86); + --lu-capsule: rgba(255,255,255,0.60); + --lu-capsule-hover: rgba(255,255,255,0.86); + --lu-chip: rgba(255,255,255,0.88); + --lu-rim: rgba(255,255,255,0.95); + --lu-rim-soft: rgba(255,255,255,0.34); + --lu-scrim: rgba(226,228,238,0.40); + /* text */ + --lu-ink: #1b1c26; + --lu-ink-muted: #4c5266; + --lu-ink-faint: #5f6578; + /* accent and signals */ + --lu-accent: #006f85; + --lu-accent-soft: #dcf3f8; + --lu-action: #1b1c26; + --lu-on-action: #9feaf9; + --lu-focus-ring: var(--lu-accent); + --lu-spark: #d4122b; + --lu-spark-soft: #fdecee; + --lu-success: #0b6b3d; + --lu-warning: #9e5100; + /* controls */ + --lu-field: #ffffff; + --lu-field-on-glass: rgba(255,255,255,0.72); + --lu-field-edge-on-glass: rgba(29,29,31,0.56); + --lu-button: #ffffff; + --lu-button-hover: #f6f6f8; + --lu-button-edge: rgba(20,22,40,0.16); + --lu-thumb: #ffffff; + --lu-kbd: #ffffff; + --lu-tooltip: rgba(27,28,38,0.90); + --lu-tooltip-ink: #f3f5fa; + /* editor */ + --lu-syntax-keyword: #006f85; + --lu-syntax-string: #a5470f; + --lu-syntax-function: #1f5fd1; + --lu-syntax-number: #c01127; + --lu-syntax-comment: #646a7c; + --lu-cursor-line: color-mix(in srgb, var(--lu-accent) 7%, var(--lu-surface)); + --lu-assist-ring: linear-gradient(100deg, #1f7cff, #00a3bf 38%, #6fd6ea 70%, #1b1c26); + /* elevation */ + --lu-shadow-popover: 0 0 0 0.5px rgba(20,22,40,0.14), 0 12px 32px -8px rgba(20,24,48,0.28), 0 2px 6px rgba(20,24,48,0.08); + --lu-shadow-island: 0 0 0 0.5px rgba(20,22,40,0.10), 0 1px 2px rgba(20,24,48,0.06), 0 8px 24px -12px rgba(20,24,48,0.22); + --lu-shadow-lift: 0 0 0 0.5px rgba(20,22,40,0.08), 0 1px 2px rgba(20,22,40,0.12), 0 2px 6px -2px rgba(20,22,40,0.1); + --lu-shadow-capsule: inset 0 0.5px 0 #fff, inset 0 0 0 0.5px rgba(255,255,255,0.55), 0 0 0 0.5px rgba(20,22,40,0.08), 0 3px 10px -3px rgba(30,40,90,0.18); + --lu-shadow-button: 0 0 0 0.5px rgba(20,22,40,0.16), 0 1px 2px rgba(0,0,0,0.07), inset 0 0.5px 0 rgba(255,255,255,0.7); + --lu-shadow-primary: inset 0 0.5px 0 rgba(255,255,255,0.5), inset 0 -0.5px 0 rgba(0,0,0,0.12), 0 1px 2px color-mix(in srgb, var(--lu-action) 35%, transparent), 0 4px 12px -4px color-mix(in srgb, var(--lu-action) 60%, transparent); + --lu-shadow-float: 0 0 0 0.5px rgba(20,22,40,0.18), 0 44px 90px -24px rgba(18,40,90,0.42), 0 14px 30px -12px rgba(18,40,90,0.2); + /* blur */ + --lu-blur-chrome: blur(34px) saturate(180%); + --lu-blur-overlay: blur(24px) saturate(180%); + --lu-blur-thin: blur(22px) saturate(190%); + --lu-blur-tooltip: blur(16px) saturate(160%); + --lu-blur-scrim: blur(8px) saturate(140%); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + /* ground and surfaces */ + --lu-window: #10121b; + --lu-surface: #1b1c26; + --lu-surface-raised: #262837; + --lu-surface-sunken: #15161f; + --lu-hover: rgba(159,234,249,0.08); + --lu-fill: rgba(255,255,255,0.08); + --lu-fill-strong: rgba(255,255,255,0.13); + --lu-line: rgba(159,234,249,0.12); + --lu-line-strong: #6d7594; + /* materials */ + --lu-material-chrome: rgba(22,24,36,0.62); + --lu-material-thin: rgba(27,28,38,0.42); + --lu-material-overlay: rgba(38,40,56,0.90); + --lu-capsule: rgba(255,255,255,0.08); + --lu-capsule-hover: rgba(255,255,255,0.15); + --lu-chip: rgba(255,255,255,0.13); + --lu-rim: rgba(255,255,255,0.30); + --lu-rim-soft: rgba(255,255,255,0.07); + --lu-scrim: rgba(6,8,18,0.50); + /* text */ + --lu-ink: #eef1f8; + --lu-ink-muted: #aeb4c9; + --lu-ink-faint: #949ab2; + /* accent and signals */ + --lu-accent: #9feaf9; + --lu-accent-soft: #1c3945; + --lu-action: #9feaf9; + --lu-on-action: #0d2a31; + --lu-focus-ring: var(--lu-accent); + --lu-spark: #ff6e6e; + --lu-spark-soft: #3a1d26; + --lu-success: #6ee3a4; + --lu-warning: #f3c46b; + /* controls */ + --lu-field: #171923; + --lu-field-on-glass: rgba(255,255,255,0.07); + --lu-field-edge-on-glass: rgba(245,245,247,0.50); + --lu-button: rgba(255,255,255,0.11); + --lu-button-hover: rgba(255,255,255,0.16); + --lu-button-edge: rgba(0,0,0,0.45); + --lu-thumb: #3c4058; + --lu-kbd: rgba(255,255,255,0.08); + --lu-tooltip: rgba(38,40,56,0.92); + --lu-tooltip-ink: #eef1f8; + /* editor */ + --lu-syntax-keyword: #9feaf9; + --lu-syntax-string: #ffb38a; + --lu-syntax-function: #86b4ff; + --lu-syntax-number: #ff9a9a; + --lu-syntax-comment: #8a90aa; + --lu-cursor-line: color-mix(in srgb, var(--lu-accent) 7%, var(--lu-surface)); + --lu-assist-ring: linear-gradient(100deg, #9feaf9, #47a8ff 38%, #2f6bff 70%, #7fdcff); + /* elevation */ + --lu-shadow-popover: 0 0 0 0.5px rgba(4,6,16,0.6), 0 16px 40px -8px rgba(4,6,16,0.65), 0 2px 8px rgba(4,6,16,0.35); + --lu-shadow-island: 0 0 0 0.5px rgba(4,6,16,0.55), 0 1px 2px rgba(4,6,16,0.3), 0 10px 28px -12px rgba(4,6,16,0.6); + --lu-shadow-lift: inset 0 0.5px 0 rgba(255,255,255,0.14), 0 0 0 0.5px rgba(0,0,0,0.5), 0 1px 3px rgba(0,0,0,0.35); + --lu-shadow-capsule: inset 0 0.5px 0 rgba(255,255,255,0.2), inset 0 0 0 0.5px rgba(255,255,255,0.06), 0 0 0 0.5px rgba(0,0,0,0.45), 0 3px 10px -3px rgba(0,0,0,0.45); + --lu-shadow-button: 0 0 0 0.5px rgba(0,0,0,0.45), 0 1px 2px rgba(0,0,0,0.3), inset 0 0.5px 0 rgba(255,255,255,0.12); + --lu-shadow-primary: inset 0 0.5px 0 rgba(255,255,255,0.5), inset 0 -0.5px 0 rgba(0,0,0,0.12), 0 1px 2px color-mix(in srgb, var(--lu-action) 35%, transparent), 0 4px 12px -4px color-mix(in srgb, var(--lu-action) 60%, transparent); + --lu-shadow-float: 0 0 0 0.5px rgba(0,0,0,0.85), 0 44px 90px -24px rgba(0,0,0,0.75), 0 14px 30px -12px rgba(0,0,0,0.5); + /* blur */ + --lu-blur-chrome: blur(34px) saturate(170%); + --lu-blur-overlay: blur(24px) saturate(170%); + --lu-blur-thin: blur(22px) saturate(190%); + --lu-blur-tooltip: blur(16px) saturate(160%); + --lu-blur-scrim: blur(8px) saturate(140%); + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + /* ground and surfaces */ + --lu-window: #10121b; + --lu-surface: #1b1c26; + --lu-surface-raised: #262837; + --lu-surface-sunken: #15161f; + --lu-hover: rgba(159,234,249,0.08); + --lu-fill: rgba(255,255,255,0.08); + --lu-fill-strong: rgba(255,255,255,0.13); + --lu-line: rgba(159,234,249,0.12); + --lu-line-strong: #6d7594; + /* materials */ + --lu-material-chrome: rgba(22,24,36,0.62); + --lu-material-thin: rgba(27,28,38,0.42); + --lu-material-overlay: rgba(38,40,56,0.90); + --lu-capsule: rgba(255,255,255,0.08); + --lu-capsule-hover: rgba(255,255,255,0.15); + --lu-chip: rgba(255,255,255,0.13); + --lu-rim: rgba(255,255,255,0.30); + --lu-rim-soft: rgba(255,255,255,0.07); + --lu-scrim: rgba(6,8,18,0.50); + /* text */ + --lu-ink: #eef1f8; + --lu-ink-muted: #aeb4c9; + --lu-ink-faint: #949ab2; + /* accent and signals */ + --lu-accent: #9feaf9; + --lu-accent-soft: #1c3945; + --lu-action: #9feaf9; + --lu-on-action: #0d2a31; + --lu-focus-ring: var(--lu-accent); + --lu-spark: #ff6e6e; + --lu-spark-soft: #3a1d26; + --lu-success: #6ee3a4; + --lu-warning: #f3c46b; + /* controls */ + --lu-field: #171923; + --lu-field-on-glass: rgba(255,255,255,0.07); + --lu-field-edge-on-glass: rgba(245,245,247,0.50); + --lu-button: rgba(255,255,255,0.11); + --lu-button-hover: rgba(255,255,255,0.16); + --lu-button-edge: rgba(0,0,0,0.45); + --lu-thumb: #3c4058; + --lu-kbd: rgba(255,255,255,0.08); + --lu-tooltip: rgba(38,40,56,0.92); + --lu-tooltip-ink: #eef1f8; + /* editor */ + --lu-syntax-keyword: #9feaf9; + --lu-syntax-string: #ffb38a; + --lu-syntax-function: #86b4ff; + --lu-syntax-number: #ff9a9a; + --lu-syntax-comment: #8a90aa; + --lu-cursor-line: color-mix(in srgb, var(--lu-accent) 7%, var(--lu-surface)); + --lu-assist-ring: linear-gradient(100deg, #9feaf9, #47a8ff 38%, #2f6bff 70%, #7fdcff); + /* elevation */ + --lu-shadow-popover: 0 0 0 0.5px rgba(4,6,16,0.6), 0 16px 40px -8px rgba(4,6,16,0.65), 0 2px 8px rgba(4,6,16,0.35); + --lu-shadow-island: 0 0 0 0.5px rgba(4,6,16,0.55), 0 1px 2px rgba(4,6,16,0.3), 0 10px 28px -12px rgba(4,6,16,0.6); + --lu-shadow-lift: inset 0 0.5px 0 rgba(255,255,255,0.14), 0 0 0 0.5px rgba(0,0,0,0.5), 0 1px 3px rgba(0,0,0,0.35); + --lu-shadow-capsule: inset 0 0.5px 0 rgba(255,255,255,0.2), inset 0 0 0 0.5px rgba(255,255,255,0.06), 0 0 0 0.5px rgba(0,0,0,0.45), 0 3px 10px -3px rgba(0,0,0,0.45); + --lu-shadow-button: 0 0 0 0.5px rgba(0,0,0,0.45), 0 1px 2px rgba(0,0,0,0.3), inset 0 0.5px 0 rgba(255,255,255,0.12); + --lu-shadow-primary: inset 0 0.5px 0 rgba(255,255,255,0.5), inset 0 -0.5px 0 rgba(0,0,0,0.12), 0 1px 2px color-mix(in srgb, var(--lu-action) 35%, transparent), 0 4px 12px -4px color-mix(in srgb, var(--lu-action) 60%, transparent); + --lu-shadow-float: 0 0 0 0.5px rgba(0,0,0,0.85), 0 44px 90px -24px rgba(0,0,0,0.75), 0 14px 30px -12px rgba(0,0,0,0.5); + /* blur */ + --lu-blur-chrome: blur(34px) saturate(170%); + --lu-blur-overlay: blur(24px) saturate(170%); + --lu-blur-thin: blur(22px) saturate(190%); + --lu-blur-tooltip: blur(16px) saturate(160%); + --lu-blur-scrim: blur(8px) saturate(140%); +} + +/* No native material (Linux, a failed material) or Reduce transparency: go opaque. */ +:root.lu-no-material { --lu-material-chrome: var(--lu-window); --lu-material-thin: var(--lu-surface-raised); --lu-material-overlay: var(--lu-surface-raised); } +@media (prefers-reduced-transparency: reduce) { + :root { --lu-material-chrome: var(--lu-window); --lu-material-thin: var(--lu-surface-raised); --lu-material-overlay: var(--lu-surface-raised); } +} +@media (prefers-reduced-motion: reduce) { + :root { --lu-dur-base: 0ms; --lu-dur-slow: 0ms; --lu-dur-enter: 0ms; --lu-press-scale: 1; } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000000..54a9e2ed67 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,136 @@ +// @ts-check +import js from '@eslint/js'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import i18next from 'eslint-plugin-i18next'; +import reactHooks from 'eslint-plugin-react-hooks'; +import tseslint from 'typescript-eslint'; + +const ipcRendererBan = { + selector: "Identifier[name='ipcRenderer']", + message: + 'ipcRenderer is never used or exposed. Use the EIPC bindings in src/ipc/renderer.ts.', +}; + +const MENU_DIALOG_TEXT = + '/^(label|sublabel|toolTip|title|message|detail|checkboxLabel|buttonLabel|nameFieldLabel|placeholder)$/'; +const catalogTextMessage = + 'Menu and dialog text comes from the i18n catalog: use t() or tm().'; +const menuDialogLiterals = [ + { + selector: `Property[key.name=${MENU_DIALOG_TEXT}] > Literal[value=/[A-Za-z]/]`, + message: catalogTextMessage, + }, + { + selector: `Property[key.name=${MENU_DIALOG_TEXT}] > TemplateLiteral > TemplateElement[value.raw=/[A-Za-z]/]`, + message: catalogTextMessage, + }, + { + selector: + "Property[key.name='buttons'] > ArrayExpression > Literal[value=/[A-Za-z]/]", + message: catalogTextMessage, + }, +]; + +export default defineConfig( + globalIgnores([ + '**/node_modules/', + '**/.vite/', + '**/out/', + '**/dist/', + '**/coverage/', + '.yarn/', + 'docs/', + '**/tests/fixtures/', + 'packages/app/static/', + 'packages/app/src/ipc/generated/', + 'packages/app/src/i18n/generated/', + ]), + js.configs.recommended, + tseslint.configs.recommended, + { + // Plain Node scripts and configs (TypeScript files get no-undef from tsc instead). + files: ['**/*.{js,mjs,cjs}'], + languageOptions: { + globals: { + console: 'readonly', + process: 'readonly', + URL: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + }, + }, + }, + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }, + ], + }, + }, + { + files: ['packages/app/src/**/*.{ts,tsx}'], + ...reactHooks.configs.flat.recommended, + }, + { + // Fiddle logic runs under plain Node in unit tests. + files: ['packages/app/src/fiddle/**'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [{ name: 'electron', message: 'src/fiddle must not import electron.' }], + patterns: [ + { group: ['electron/*'], message: 'src/fiddle must not import electron.' }, + ], + }, + ], + }, + }, + { + // Renderers are views: they reach main only through the EIPC wrappers. + files: ['packages/app/src/{renderer,ui}/**'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'electron', + message: 'Renderers talk to main through src/ipc/renderer.ts.', + }, + ], + patterns: [ + { + group: ['electron/*'], + message: 'Renderers talk to main through src/ipc/renderer.ts.', + }, + { + group: ['**/ipc/generated/**'], + message: + 'Import IPC from src/ipc/renderer.ts, which re-throws FiddleErrors.', + }, + ], + }, + ], + 'no-restricted-syntax': ['error', ipcRendererBan], + }, + }, + { + files: ['packages/app/src/preload/**'], + rules: { 'no-restricted-syntax': ['error', ipcRendererBan] }, + }, + // No string literals in JSX text. + { + files: ['packages/app/src/{renderer,ui}/**/*.tsx'], + ignores: ['**/*.test.tsx'], + plugins: { i18next }, + rules: { 'i18next/no-literal-string': ['error', { mode: 'jsx-text-only' }] }, + }, + // Menu and dialog text in main comes from the catalog. + { + files: ['packages/app/src/main/**/*.ts'], + ignores: ['**/*.test.ts', 'packages/app/src/main/test-driver/**'], + rules: { 'no-restricted-syntax': ['error', ...menuDialogLiterals] }, + }, +); diff --git a/forge.config.ts b/forge.config.ts deleted file mode 100644 index c6b7d186b0..0000000000 --- a/forge.config.ts +++ /dev/null @@ -1,320 +0,0 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; - -import { FuseV1Options, FuseVersion } from '@electron/fuses'; -import { MakerMSIX } from '@electron-forge/maker-msix'; -import { FusesPlugin } from '@electron-forge/plugin-fuses'; -import type { ForgeConfig } from '@electron-forge/shared-types'; - -import packageJson from './package.json'; -import { maybeFetchContributors } from './tools/contributors'; -import { populateReleases } from './tools/fetch-releases'; -import { mainConfig } from './tools/webpack/webpack.main.config'; -import { rendererConfig } from './tools/webpack/webpack.renderer.config'; - -const { version } = packageJson; -const iconDir = path.resolve(__dirname, 'assets', 'icons'); -const root = process.cwd(); - -const commonLinuxConfig = { - categories: ['Development', 'Utility'], - icon: { - '1024x1024': path.resolve(iconDir, 'fiddle.png'), - scalable: path.resolve(iconDir, 'fiddle.svg'), - }, - mimeType: ['x-scheme-handler/electron-fiddle'], -}; - -const requirements = path.resolve(__dirname, 'tools/certs/requirements.txt'); - -/** - * Windows code signing through Azure Trusted Signing. - * - * Authentication is not handled here. In CI, `azure/login` performs an OIDC - * login with the Azure CLI, and the Trusted Signing dlib then picks up that - * session through `AzureCliCredential`. This function only tells signtool - * where the dlib lives and which account and certificate profile to use. - * - * Returns `undefined` when none of the Azure variables are set so that local - * and CI builds produce unsigned artifacts, as before. - */ -// Derived from Forge rather than imported from `@electron/windows-sign`, so the -// type always matches the version Forge itself depends on. -type WindowsSignOptions = NonNullable< - MakerMSIX['config']['windowsSignOptions'] ->; - -function getWindowsSignOptions(): WindowsSignOptions | undefined { - const { - AZURE_CODE_SIGNING_DLIB: dlib, - AZURE_CODE_SIGNING_ENDPOINT: endpoint, - AZURE_CODE_SIGNING_ACCOUNT_NAME: accountName, - AZURE_CODE_SIGNING_CERTIFICATE_PROFILE_NAME: certificateProfileName, - WINDOWS_SIGNTOOL_PATH: signToolPath, - } = process.env; - - if (!dlib && !endpoint && !accountName && !certificateProfileName) { - return undefined; - } - - if (!dlib || !endpoint || !accountName || !certificateProfileName) { - throw new Error( - 'Azure Trusted Signing is only partially configured. Set all of ' + - 'AZURE_CODE_SIGNING_DLIB, AZURE_CODE_SIGNING_ENDPOINT, ' + - 'AZURE_CODE_SIGNING_ACCOUNT_NAME and ' + - 'AZURE_CODE_SIGNING_CERTIFICATE_PROFILE_NAME, or none of them.', - ); - } - - if (!signToolPath) { - // The signtool.exe vendored by @electron/windows-sign predates /dlib - // support. Trusted Signing needs one from Windows SDK 10.0.22621.755+. - throw new Error( - 'Azure Trusted Signing needs a recent signtool.exe. Set ' + - 'WINDOWS_SIGNTOOL_PATH to one from Windows SDK 10.0.22621.755 or later.', - ); - } - - const metadataPath = path.join( - os.tmpdir(), - 'electron-fiddle-trusted-signing-metadata.json', - ); - - fs.writeFileSync( - metadataPath, - JSON.stringify( - { - Endpoint: endpoint, - CodeSigningAccountName: accountName, - CertificateProfileName: certificateProfileName, - // `azure/login` leaves us with an Azure CLI session. Skip the other - // credential providers DefaultAzureCredential would otherwise probe, - // some of which (managed identity) time out slowly on GitHub runners. - ExcludeCredentials: [ - 'ManagedIdentityCredential', - 'WorkloadIdentityCredential', - 'SharedTokenCacheCredential', - 'VisualStudioCredential', - 'VisualStudioCodeCredential', - 'AzurePowerShellCredential', - 'AzureDeveloperCliCredential', - 'InteractiveBrowserCredential', - ], - }, - null, - 2, - ), - ); - - return { - signToolPath, - // Passed as an array so paths with spaces survive intact. - signWithParams: ['/dlib', dlib, '/dmdf', metadataPath], - timestampServer: 'http://timestamp.acs.microsoft.com', - // Trusted Signing certificates are SHA-256 only; no SHA-1 dual signing. - hashes: ['sha256'] as WindowsSignOptions['hashes'], - // Certificate selection is done by the dlib, not by signtool's `/a`. - automaticallySelectCertificate: false, - }; -} - -const windowsSignOptions = getWindowsSignOptions(); - -const config: ForgeConfig = { - hooks: { - generateAssets: async () => { - await Promise.all([populateReleases(), maybeFetchContributors(true)]); - }, - }, - plugins: [ - { - name: '@electron-forge/plugin-webpack', - config: { - devContentSecurityPolicy: - "default-src 'none'; img-src 'self' https: data:; media-src 'none'; child-src 'self' isolated-actions:; object-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https:; font-src 'self' https:;", - devServer: { - // Disallow browser from opening/reloading with HMR in development mode. - open: false, - liveReload: false, - hot: 'only', - }, - mainConfig: mainConfig, - renderer: { - config: rendererConfig, - entryPoints: [ - { - html: path.join(root, './static/index.html'), - js: path.join(root, './src/renderer/main.tsx'), - name: 'main_window', - preload: { - js: path.join(root, 'src/preload/preload.ts'), - }, - }, - { - html: path.join(root, './static/isolated-run-button.html'), - js: path.join(root, './src/isolated-run-button.ts'), - name: 'isolated_run_button', - }, - ], - }, - }, - }, - new FusesPlugin({ - version: FuseVersion.V1, - [FuseV1Options.RunAsNode]: false, - [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, - [FuseV1Options.EnableNodeCliInspectArguments]: false, - [FuseV1Options.OnlyLoadAppFromAsar]: true, - [FuseV1Options.EnableCookieEncryption]: true, - [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, - }), - ], - packagerConfig: { - name: 'Electron Fiddle', - executableName: 'electron-fiddle', - // Unpack the embedded sfw script so system Node can spawn it — it can't - // be executed from inside an asar archive. The `.webpack` segment must - // be explicit because minimatch globstar skips dot-prefixed directories. - asar: { unpack: '**/.webpack/sfw/**' }, - icon: path.resolve(__dirname, 'assets', 'icons', 'fiddle'), - appBundleId: 'com.electron.fiddle', - usageDescription: { - Camera: - 'Access is needed by certain built-in fiddles in addition to any custom fiddles that use the Camera', - Microphone: - 'Access is needed by certain built-in fiddles in addition to any custom fiddles that use the Microphone', - Calendars: - 'Access is needed by certain built-in fiddles in addition to any custom fiddles that may access Calendars', - Contacts: - 'Access is needed by certain built-in fiddles in addition to any custom fiddles that may access Contacts', - Reminders: - 'Access is needed by certain built-in fiddles in addition to any custom fiddles that may access Reminders', - AudioCapture: - 'Access is needed by certain built-in fiddles in addition to any custom fiddles that may capture Audio', - }, - appCategoryType: 'public.app-category.developer-tools', - protocols: [ - { - name: 'Electron Fiddle Launch Protocol', - schemes: ['electron-fiddle'], - }, - ], - win32metadata: { - CompanyName: 'Electron Community', - OriginalFilename: 'Electron Fiddle', - }, - osxSign: { - identity: - 'Developer ID Application: OpenJS Foundation, Inc. (UY52UFTVTM)', - optionsForFile: (filePath) => - ['(Plugin).app', '(GPU).app', '(Renderer).app'].some((helper) => - filePath.includes(helper), - ) - ? { requirements } - : { - entitlements: 'static/entitlements.plist', - requirements, - }, - }, - }, - makers: [ - { - name: '@electron-forge/maker-squirrel', - platforms: ['win32'], - config: (arch: string) => ({ - name: 'electron-fiddle', - authors: 'Electron Community', - exe: 'electron-fiddle.exe', - iconUrl: - 'https://raw.githubusercontent.com/electron/fiddle/0119f0ce697f5ff7dec4fe51f17620c78cfd488b/assets/icons/fiddle.ico', - loadingGif: './assets/loading.gif', - noMsi: true, - setupExe: `electron-fiddle-${version}-win32-${arch}-setup.exe`, - setupIcon: path.resolve(iconDir, 'fiddle.ico'), - windowsSign: windowsSignOptions, - }), - }, - new MakerMSIX({ - manifestVariables: { - // Must match the subject of the Azure Trusted Signing certificate - // exactly, or signtool refuses to sign the package. - publisher: - 'CN=OpenJS Foundation, O=OpenJS Foundation, L=San Francisco, S=California, C=US', - publisherDisplayName: 'OpenJS Foundation', - packageIdentity: 'ElectronCommunity.ElectronFiddle', - appExecutable: 'electron-fiddle.exe', - packageDisplayName: 'Electron Fiddle', - appDisplayName: 'Electron Fiddle', - packageDescription: packageJson.description, - }, - windowsSignOptions, - }), - { - name: '@electron-forge/maker-zip', - platforms: ['darwin'], - config: {}, - }, - { - name: '@electron-forge/maker-deb', - platforms: ['linux'], - config: commonLinuxConfig, - }, - { - name: '@electron-forge/maker-rpm', - platforms: ['linux'], - config: commonLinuxConfig, - }, - { - name: '@reforged/maker-appimage', - platforms: ['linux'], - config: { - options: commonLinuxConfig, - }, - }, - ], - publishers: [ - { - name: '@electron-forge/publisher-github', - config: { - repository: { - owner: 'electron', - name: 'fiddle', - }, - draft: true, - prerelease: false, - generateReleaseNotes: true, - }, - }, - ], -}; - -function notarizeMaybe() { - if (process.platform !== 'darwin') { - return; - } - - if (!process.env.CI && !process.env.FORCE_NOTARIZATION) { - // Not in CI, skipping notarization - console.log('Not in CI, skipping notarization'); - return; - } - - if (!process.env.APPLE_ID || !process.env.APPLE_ID_PASSWORD) { - console.warn( - 'Should be notarizing, but environment variables APPLE_ID or APPLE_ID_PASSWORD are missing!', - ); - return; - } - - config.packagerConfig!.osxNotarize = { - appleId: process.env.APPLE_ID, - appleIdPassword: process.env.APPLE_ID_PASSWORD, - teamId: 'UY52UFTVTM', - }; -} - -notarizeMaybe(); - -// Finally, export it -export default config; diff --git a/package.json b/package.json index a2e6064657..7794d150c0 100644 --- a/package.json +++ b/package.json @@ -1,156 +1,52 @@ { - "name": "electron-fiddle", + "name": "electron-fiddle-monorepo", "private": true, - "productName": "Electron Fiddle", - "version": "0.40.1", - "description": "The easiest way to get started with Electron", - "repository": "https://github.com/electron/fiddle", - "homepage": "https://electronjs.org/fiddle", - "main": "./.webpack/main", - "scripts": { - "contributors": "tsx ./tools/contributors.ts", - "format": "prettier src \"static/**/*.js\" --check --experimental-cli", - "format:write": "prettier src \"static/**/*.js\" --write --experimental-cli", - "lint:style": "stylelint \"./src/less/*.less\"", - "lint:js": "oxlint", - "lint:markdown": "markdownlint-cli2 \"*.md\"", - "lint:links": "lint-roller-markdown-links \"*.md\"", - "lint:fix": "npm-run-all \"lint:!(fix|links) -- --fix\"", - "lint": "npm-run-all \"lint:!(fix)\"", - "make": "electron-forge make", - "package": "electron-forge package", - "publish": "electron-forge publish", - "start": "tsx ./tools/clean-webpack.ts && electron-forge start --", - "test": "tsx ./tools/vitest.ts run", - "test:ci": "tsx ./tools/vitest.ts run --coverage --reporter=default --reporter=junit --outputFile=./reports/report.xml", - "test:report": "tsx ./tools/vitest.ts run --reporter=json --outputFile=report.json | true", - "tsc": "tsc --noEmit -p .", - "electron-releases": "tsx ./tools/fetch-releases.ts", - "postinstall": "husky && npm run electron-releases" - }, - "engines": { - "node": ">= 22.12.0" - }, - "keywords": [ - "Electron", - "Fiddle", - "Tutorial" + "type": "module", + "workspaces": [ + "packages/*" ], - "author": "Felix Rieseberg ", - "license": "MIT", - "config": { - "forge": "./forge.config.ts" - }, - "dependencies": { - "@blueprintjs/core": "^3.36.0", - "@blueprintjs/popover2": "^0.12.2", - "@blueprintjs/select": "^3.15.0", - "@electron-internal/extract-zip": "^1.0.5", - "@electron/fiddle-core": "^2.2.0", - "@octokit/rest": "^22.0.1", - "@sentry/electron": "^7.12.0", - "algoliasearch": "^4.12.0", - "classnames": "^2.2.6", - "electron-squirrel-startup": "^1.0.0", - "fs-extra": "^9.1.0", - "mobx": "^6.5.0", - "mobx-react": "^7.3.0", - "monaco-editor": "^0.22.0", - "namor": "^3.0.3", - "node-watch": "^0.7.3", - "p-debounce": "^2.0.0", - "parse-env-string": "^1.0.1", - "prettier": "^3.6.2", - "react": "^16.14.0", - "react-dom": "^16.14.0", - "react-mosaic-component": "^4.1.1", - "react-window": "^1.8.10", - "semver": "^7.3.4", - "sfw": "^2.0.4", - "shell-env": "^4.0.3", - "update-electron-app": "^3.2.0" - }, - "devDependencies": { - "@electron-forge/cli": "8.0.0-alpha.9", - "@electron-forge/maker-deb": "8.0.0-alpha.9", - "@electron-forge/maker-msix": "8.0.0-alpha.9", - "@electron-forge/maker-rpm": "8.0.0-alpha.9", - "@electron-forge/maker-squirrel": "8.0.0-alpha.9", - "@electron-forge/maker-zip": "8.0.0-alpha.9", - "@electron-forge/plugin-fuses": "8.0.0-alpha.9", - "@electron-forge/plugin-webpack": "8.0.0-alpha.9", - "@electron-forge/publisher-github": "8.0.0-alpha.9", - "@electron/devtron": "^2.1.1", - "@electron/fuses": "^2.1.1", - "@electron/lint-roller": "^4.0.0", - "@reforged/maker-appimage": "^5.1.0", - "@testing-library/dom": "^10.4.0", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^12.1.5", - "@testing-library/user-event": "^14.5.2", - "@tsconfig/node22": "^22.0.2", - "@types/classnames": "^2.2.11", - "@types/fs-extra": "^9.0.7", - "@types/node": "^22.19.1", - "@types/parse-env-string": "^1.0.2", - "@types/react": "^16.14.0", - "@types/react-dom": "^16.9.11", - "@types/react-window": "^1.8.8", - "@types/semver": "^7.3.4", - "@vercel/webpack-asset-relocator-loader": "^1.7.2", - "@vitest/coverage-v8": "5.0.1", - "copy-webpack-plugin": "^14.0.0", - "css-loader": "^6.7.1", - "electron": "^42.10.0", - "electron-devtools-installer": "^4.0.0", - "fork-ts-checker-webpack-plugin": "^8.0.0", - "husky": "^9.0.11", - "jsdom": "^26.1.0", - "less": "^4.1.1", - "less-loader": "^11.0.0", - "lint-staged": "^17.4.1", - "log-symbols": "^6.0.0", - "markdownlint-cli2": "^0.22.0", - "mini-css-extract-plugin": "^2.6.1", - "monaco-editor-webpack-plugin": "^3.0.0", - "npm-run-all2": "^7.0.1", - "oxlint": "^1.81.0", - "postcss": "^8.5.23", - "postcss-less": "^6.0.0", - "resolve-url-loader": "^5.0.0", - "style-loader": "^4.0.0", - "stylelint": "^15.10.1", - "stylelint-config-standard": "^34.0.0", - "terser-webpack-plugin": "^5.3.3", - "ts-loader": "^9.4.4", - "tsx": "^4.20.3", - "typescript": "^5.8.3", - "vite": "^8.2.2", - "vitest": "^5.0.1", - "webpack": "^5.104.1" + "packageManager": "yarn@4.10.3", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, - "lint-staged": { - "./**/*.{js,ts,tsx}": [ - "npm run lint:js -- --fix", - "prettier --write --experimental-cli" - ], - "./src/less/*.less": [ - "npm run lint:style -- --fix", - "prettier --write --experimental-cli" - ] + "scripts": { + "start": "yarn workspace electron-fiddle start", + "start:xvfb": "yarn workspace electron-fiddle start:xvfb", + "generate": "yarn workspace electron-fiddle generate", + "i18n:check": "yarn workspace electron-fiddle i18n:check", + "i18n:translate": "yarn workspace electron-fiddle i18n:translate", + "i18n:test": "yarn workspace electron-fiddle i18n:test", + "typecheck": "yarn generate && tsc -b", + "lint": "yarn generate && eslint --cache .", + "lint:fix": "yarn generate && eslint --cache --fix .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "yarn generate && vitest run", + "test:watch": "vitest", + "test:e2e": "yarn generate && vitest run -c packages/app/e2e/vitest.config.mts", + "driver": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON packages/app/tools/driver.ts", + "fiddle": "node packages/app/tools/electron-run.mjs headless", + "package": "yarn workspace electron-fiddle package", + "make": "yarn workspace electron-fiddle make" }, "resolutions": { - "@electron-forge/maker-base": "8.0.0-alpha.9", - "@electron-forge/shared-types": "8.0.0-alpha.9", + "@electron-forge/maker-base": "8.0.0-alpha.10", + "@electron-forge/shared-types": "8.0.0-alpha.10", "electron-windows-msix@npm:^2.0.4": "patch:electron-windows-msix@npm%3A2.0.4#~/.yarn/patches/electron-windows-msix-npm-2.0.4-954aec946e.patch" }, - "packageManager": "yarn@4.10.3+sha512.c38cafb5c7bb273f3926d04e55e1d8c9dfa7d9c3ea1f36a4868fa028b9e5f72298f0b7f401ad5eb921749eb012eb1c3bb74bf7503df3ee43fd600d14a018266f", - "dependenciesMeta": { - "electron": { - "built": true - }, - "electron-winstaller": { - "built": true - } + "devDependencies": { + "@eslint/js": "^10.0.0", + "@types/node": "^24.9.0", + "@vitest/coverage-v8": "^5.0.0", + "eslint": "^10.10.0", + "eslint-plugin-i18next": "^6.1.5", + "eslint-plugin-react-hooks": "^7.1.1", + "jiti": "^2.4.2", + "jsdom": "^30.0.1", + "prettier": "^3.9.6", + "typescript": "~6.0.3", + "typescript-eslint": "^8.69.0", + "vite": "8.2.2", + "vitest": "^5.0.0" } } diff --git a/packages/app/assets/icons/fiddle.icns b/packages/app/assets/icons/fiddle.icns new file mode 100644 index 0000000000..b4ba8918ea Binary files /dev/null and b/packages/app/assets/icons/fiddle.icns differ diff --git a/packages/app/assets/icons/fiddle.ico b/packages/app/assets/icons/fiddle.ico new file mode 100644 index 0000000000..9cd2be5d9a Binary files /dev/null and b/packages/app/assets/icons/fiddle.ico differ diff --git a/packages/app/assets/icons/fiddle.png b/packages/app/assets/icons/fiddle.png new file mode 100644 index 0000000000..4c209824f8 Binary files /dev/null and b/packages/app/assets/icons/fiddle.png differ diff --git a/packages/app/assets/icons/fiddle.svg b/packages/app/assets/icons/fiddle.svg new file mode 100644 index 0000000000..e680d7614b --- /dev/null +++ b/packages/app/assets/icons/fiddle.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/loading.gif b/packages/app/assets/loading.gif similarity index 100% rename from assets/loading.gif rename to packages/app/assets/loading.gif diff --git a/packages/app/build/add-macos-cert.sh b/packages/app/build/add-macos-cert.sh new file mode 100755 index 0000000000..fc1692af07 --- /dev/null +++ b/packages/app/build/add-macos-cert.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -eo pipefail + +KEY_CHAIN=build.keychain +MACOS_CERT_P12_FILE=certificate.p12 + +if [ -z "$MACOS_CERT_P12" ]; then + echo "MACOS_CERT_P12 is not set." >&2 + exit 1 +fi + +# Recreate the certificate from the secure environment variable +echo -n "$MACOS_CERT_P12" | base64 -d > "$MACOS_CERT_P12_FILE" + +# Create a keychain +security create-keychain -p actions $KEY_CHAIN + +# Make the keychain the default so identities are found +security default-keychain -s $KEY_CHAIN + +# Unlock the keychain +security unlock-keychain -p actions $KEY_CHAIN + +# CI lacks the latest Developer ID intermediate certificate, which the cert needs +curl https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer -o DeveloperIDG2CA.cer +sudo security add-trusted-cert -d -r unspecified -k $KEY_CHAIN DeveloperIDG2CA.cer +rm -f DeveloperIDG2CA.cer + +security import $MACOS_CERT_P12_FILE -k $KEY_CHAIN -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign; + +security set-key-partition-list -S apple-tool:,apple: -s -k actions $KEY_CHAIN + +security find-identity + +# remove certs +rm -fr *.p12 diff --git a/tools/certs/requirements.txt b/packages/app/build/certs/requirements.txt similarity index 100% rename from tools/certs/requirements.txt rename to packages/app/build/certs/requirements.txt diff --git a/packages/app/build/entitlements.plist b/packages/app/build/entitlements.plist new file mode 100644 index 0000000000..a4a6baf2b8 --- /dev/null +++ b/packages/app/build/entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.device.print + + com.apple.security.device.usb + + + diff --git a/packages/app/build/identity.json b/packages/app/build/identity.json new file mode 100644 index 0000000000..04594b52ec --- /dev/null +++ b/packages/app/build/identity.json @@ -0,0 +1,31 @@ +{ + "package": { + "name": "electron-fiddle", + "productName": "Electron Fiddle" + }, + "packager": { + "name": "Electron Fiddle", + "executableName": "electron-fiddle", + "appBundleId": "com.electron.fiddle", + "appCategoryType": "public.app-category.developer-tools", + "protocols": ["electron-fiddle"], + "companyName": "Electron Community" + }, + "squirrel": { + "name": "electron-fiddle", + "exe": "electron-fiddle.exe", + "setupExe": "electron-fiddle--win32-x64-setup.exe", + "noMsi": true + }, + "msix": { + "packageIdentity": "ElectronCommunity.ElectronFiddle", + "publisher": "CN=OpenJS Foundation, O=OpenJS Foundation, L=San Francisco, S=California, C=US" + }, + "linux": { + "deb": "electron-fiddle", + "rpm": "electron-fiddle", + "appImage": "Electron Fiddle", + "bin": "electron-fiddle", + "mimeType": ["x-scheme-handler/electron-fiddle"] + } +} diff --git a/packages/app/e2e/README.md b/packages/app/e2e/README.md new file mode 100644 index 0000000000..f805442046 --- /dev/null +++ b/packages/app/e2e/README.md @@ -0,0 +1,90 @@ +# E2E tests + +End-to-end specs drive a real test build of the app through the in-house driver +(`src/main/test-driver`). There's no Playwright or WebDriver. + +- **Run everything:** `yarn test:e2e` from the repo root. It builds `out/test-build` once, then runs + every `packages/app/e2e/*.e2e.ts` in parallel. In a sandboxed container, run it outside the + sandbox, because Xvfb needs its display socket (and on macOS, because Electron can't start in it). +- **Run one file:** `yarn test:e2e smoke`. Run whole files: the tests in a file depend on each + other, so `-t "name"` fails for any test that needs what an earlier one left behind. Set + `FIDDLE_E2E_SKIP_BUILD=1` to reuse the last build, and `FIDDLE_E2E_VERBOSE=1` to see the app's + output. +- **Explore by hand:** `yarn driver launch`, then `yarn driver snapshot`, + `yarn driver click button Run`, `yarn driver screenshot /tmp/x.png`, `yarn driver quit`. Every + command prints JSON. The full list is in the header of `tools/driver.ts`. + +## Platforms + +The driver needs no OS focus: clicks, keys and typing go through CDP (`Input.*`) straight to the +page, and every page emulates focus. That's what lets many apps run side by side. + +- **Linux:** each spec file gets its own Xvfb display, with openbox on it when installed. Nothing + shows on a real screen. `FIDDLE_E2E_DISPLAY=:` uses an existing display instead. +- **macOS:** no Xvfb needed. The app runs as a background app that never becomes the active app, + and its windows open below normal windows. `FIDDLE_E2E_FOREGROUND=1` opens them in front (still + without taking focus), to watch a `yarn driver` session. +- **Workers:** `FIDDLE_E2E_WORKERS=` sets how many spec files run at once. The default is one + per core but one on Linux, and at most 4 on macOS and Windows. + +## Writing a spec + +Create `packages/app/e2e/.e2e.ts`. Each file gets its own app, temp dir, X display (on +Linux) and fixture server, so files never share state. Tests inside a file share one app and run +in order, and a test may rely on what the ones before it left behind. + +```ts +import { describe, expect, it } from 'vitest'; + +import { role, text, useApp } from './harness.ts'; + +describe('run', () => { + const app = useApp(); + + it('runs the fiddle and shows its output', async () => { + // Find controls by accessible role and name, the way a user or screen reader would. + // A click waits until the element is enabled, on top and no longer moving. + await app().click(role('button', 'Run')); + + // Every query waits, with a 5 s default timeout. Never sleep. + await app().query(text('Hello from Electron')); + + // Stores, commands and dialogs. + const { window } = await app().stores(); + expect(window).toMatchObject({ run: { status: 'running' } }); + await app().runCommand('run.stop'); + await app().queueDialog('save', { filePath: '/tmp/fiddle' }); // before the dialog opens + }); +}); +``` + +The rest of the API (`snapshot`, `screenshot`, `logs`, `clipboard`, `sideEffects`, `dialogs`, +`mainFetch` and more) is on `FiddleApp` in `e2e/driver.ts`, and the spec +glue is in `e2e/harness.ts`. + +- **Queries.** `role('button', 'Run')`, `role('heading', /welcome/i)` and `text('Saved')` take + `{ nth, timeout, window }`. Without `window`, actions go to the focused window, else the first. +- **Keys.** `press` takes Electron accelerators or DOM key names (`CmdOrCtrl+Shift+P`, `Escape`). + `Shift+F10` opens the focused element's context menu on every platform. +- **Dialogs.** Native dialogs must be scripted with `queueDialog` before they open. An unscripted + dialog fails the file. +- **Side effects.** `shell.openExternal`, protocol registration, notifications and native context + menus are recorded, not performed. Read them with `sideEffects()`. +- **Clipboard.** Main's clipboard is in memory: `clipboard()` returns what the app last copied + (a gist link, a version number), and main never reads or writes the OS clipboard. A copy the page + makes itself, such as Ctrl+C in the editor, still goes to the OS clipboard, so specs don't + assert on it. + +A failed step throws a `DriverError` with the step and query, the accessibility snapshot, the path +of a failure screenshot and the tails of the main and renderer logs. When a test fails, `useApp()` +also prints diagnostics and keeps the temp dir, including `artifacts/` and `app-output.log`. +Isolation violations fail the file after its last test: a request to a non-loopback host, an +unscripted dialog, or a crashed renderer. + +## Fixtures + +`fixtures/server.ts` serves every URL in `src/shared/endpoints.ts`. Its data is in +`fixtures/data/`, and it records each request (`app.fixtures().requests`). Electron zips come from +the local Electron download cache (or the directory in `FIDDLE_E2E_ELECTRON_ZIPS`), or are zipped +from `node_modules/electron`. Add data files there; add a route in `route()` only for a new +endpoint. diff --git a/packages/app/e2e/cli.e2e.ts b/packages/app/e2e/cli.e2e.ts new file mode 100644 index 0000000000..eb23fd9214 --- /dev/null +++ b/packages/app/e2e/cli.e2e.ts @@ -0,0 +1,131 @@ +// The headless CLI of the test build, in test mode against the fixture server. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { electronArgs, removeTestDir, spawnTestBuild } from './driver.ts'; +import { startFixtureServer, type FixtureServer } from './fixtures/server.ts'; + +interface CliRun { + code: number | null; + stdout: string; + stderr: string; + /** The `--json` lines; the last one is the result. */ + lines: Record[]; +} + +describe('headless CLI', () => { + let fixtures: FixtureServer; + let dir: string; + + beforeAll(async () => { + fixtures = await startFixtureServer(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fiddle-e2e-cli-')); + }); + + afterAll(async () => { + await fixtures?.close(); + if (dir) removeTestDir(dir); + }); + + /** Runs the CLI, killing it after `timeout` ms so a hung run doesn't outlive the test. */ + const cli = (timeout: number, ...args: string[]) => + new Promise((resolve, reject) => { + const env = { FIDDLE_TEST_DIR: dir, FIDDLE_TEST_FIXTURE_URL: fixtures.url }; + const child = spawnTestBuild(['--log-level=3', '--headless', ...args], env, { + cwd: dir, + timeout, + killSignal: 'SIGKILL', + }); + let stdout = ''; + let stderr = ''; + child.stdout!.on('data', (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr!.on('data', (chunk: Buffer) => (stderr += chunk.toString())); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (signal === 'SIGKILL') { + reject(new Error(`the CLI didn't exit within ${timeout} ms\n${stderr}`)); + return; + } + resolve({ + code, + stdout, + stderr, + lines: stdout + .split('\n') + .filter((line) => line.startsWith('{')) + .map((line) => JSON.parse(line) as Record), + }); + }); + }); + + const fiddle = (name: string, main: string) => { + const folder = path.join(dir, name); + fs.mkdirSync(folder, { recursive: true }); + fs.writeFileSync(path.join(folder, 'main.js'), main); + return folder; + }; + // The fiddle's own Electron: no display (Chromium's headless Ozone backend), and no sandbox where the app's has none. + const fiddleFlags = () => [ + '--flag=--ozone-platform=headless', + ...electronArgs() + .filter((arg) => arg === '--no-sandbox') + .map((arg) => `--flag=${arg}`), + ]; + + it('lists versions as JSON, with the stable and beta channels by default', async () => { + const run = await cli(50_000, 'versions', 'list', '--json'); + expect(run.code, run.stderr).toBe(0); + const result = run.lines.at(-1) as { + ok: boolean; + data: { versions: { version: string; channel: string }[] }; + }; + expect(result.ok).toBe(true); + const listed = result.data.versions.map((v) => `${v.version} ${v.channel}`); + expect(listed).toEqual( + expect.arrayContaining(['44.3.0 stable', '43.7.0 stable', '45.0.0-alpha.6 beta']), + ); + }, 60_000); + + it("exits with the fiddle's exit code", async () => { + const run = await cli( + 170_000, + 'run', + fiddle('fails', 'process.exit(3);\n'), + '--json', + ...fiddleFlags(), + ); + expect(run.code, run.stderr).toBe(3); + expect(run.lines.at(-1)).toMatchObject({ + ok: true, + data: { result: 'failure', exitCode: 3 }, + }); + }, 180_000); + + it('succeeds with exit code 0 and never passes blocked variables', async () => { + const main = + "console.log('LD_PRELOAD=' + (process.env.LD_PRELOAD ?? 'unset'));\nprocess.exit(0);\n"; + const run = await cli( + 110_000, + 'run', + fiddle('passes', main), + '--json', + ...fiddleFlags(), + '--env=LD_PRELOAD=/tmp/evil.so', + ); + expect(run.code, run.stderr).toBe(0); + expect(run.stdout).toContain('LD_PRELOAD=unset'); + expect(run.lines.at(-1)).toMatchObject({ + ok: true, + data: { result: 'success', exitCode: 0 }, + }); + }, 120_000); + + it('exits with 64 for an unknown command', async () => { + const run = await cli(50_000, 'nope', '--json'); + expect(run.code).toBe(64); + expect(run.lines.at(-1)).toMatchObject({ ok: false }); + }, 60_000); +}); diff --git a/packages/app/e2e/deep-link.e2e.ts b/packages/app/e2e/deep-link.e2e.ts new file mode 100644 index 0000000000..3a975d1cf5 --- /dev/null +++ b/packages/app/e2e/deep-link.e2e.ts @@ -0,0 +1,56 @@ +// Deep links through the single-instance lock: a second launch of the test +// build with the same userData forwards its argv to the running app and quits. +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { spawnTestBuild } from './driver.ts'; +import { FIXTURE_GIST_ID, useApp, windowState } from './harness.ts'; + +describe('deep links', () => { + const app = useApp(); + + /** Launches a second instance; resolves with its exit code. */ + const secondInstance = (...args: string[]) => + new Promise((resolve, reject) => { + const testDir = app().testDir ?? ''; + const child = spawnTestBuild( + ['--ozone-platform=headless', ...args], + { + FIDDLE_TEST_DIR: testDir, + FIDDLE_TEST_FIXTURE_URL: app.fixtures().url, + ELECTRON_FIDDLE_DRIVER_SOCKET: path.join(testDir, 'second-instance.sock'), + }, + { stdio: 'ignore', timeout: 20_000, killSignal: 'SIGKILL' }, + ); + child.once('error', reject); + child.once('exit', (code, signal) => + signal === 'SIGKILL' + ? reject(new Error('the second instance did not exit within 20 s')) + : resolve(code), + ); + }); + + it('forwards a link from a second launch and asks before loading it', async () => { + await app().queueDialog('messageBox', { button: 'Load' }); + expect( + await secondInstance(`electron-fiddle://gist/fiddle-e2e/${FIXTURE_GIST_ID}`), + ).toBe(0); + await expect + .poll(async () => (await windowState(app())).fiddle.source.gistId, { + timeout: 15_000, + }) + .toBe(FIXTURE_GIST_ID); + + const prompt = (await app().dialogs()).find( + (dialog) => dialog.options.message === 'Load this gist?', + ); + expect(prompt).toMatchObject({ kind: 'messageBox', scripted: true }); + expect(String(prompt?.options.detail)).toContain('Owner: fiddle-e2e'); + }); + + it('keeps one window when a second launch has no link', async () => { + expect(await secondInstance()).toBe(0); + expect(await app().windows()).toHaveLength(1); + }); +}); diff --git a/packages/app/e2e/documents.e2e.ts b/packages/app/e2e/documents.e2e.ts new file mode 100644 index 0000000000..28e6226a6b --- /dev/null +++ b/packages/app/e2e/documents.e2e.ts @@ -0,0 +1,205 @@ +// Unsaved state, Save, Save As, Open and Save as Forge project, with the native +// dialogs answered from the queue. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + dialogMessages, + makeFolder, + readJson, + role, + useApp, + windowState, +} from './harness.ts'; + +interface PackageJson { + name?: string; + productName?: string; + version?: string; + main?: string; + author?: string; + license?: string; + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; + config?: { forge?: { makers?: { name: string }[] } }; +} + +describe('documents', () => { + const app = useApp(); + const fiddle = async () => (await windowState(app())).fiddle; + const title = async () => (await app().windows())[0]?.title; + const read = (dir: string, file: string) => + fs.readFileSync(path.join(dir, file), 'utf8'); + const edit = async (value: string) => { + await app().click(role('code')); + await app().type(value); + await expect.poll(async () => (await fiddle()).dirty).toBe(true); + }; + + it('shows unsaved changes in the window title', async () => { + const { name } = await fiddle(); + expect(await title()).toBe(name); + await edit('// edited'); + await expect.poll(title).toBe(`${name} (edited)`); + expect((await fiddle()).dirtyFiles).toEqual(['main.js']); + // The title bar's "Edited" marker is hidden where the drawn menu bar takes the room (Linux, Windows). + await app().query(role('tab', /^main\.js , Unsaved changes$/)); + }); + + it('saves to a new folder, asking for it only the first time', async () => { + const dir = path.join(makeFolder(app(), 'parent'), 'my-fiddle'); + await app().queueDialog('open', { filePaths: [dir] }); + await app().press('CmdOrCtrl+S'); + await expect.poll(async () => (await fiddle()).dirty).toBe(false); + + const saved = await fiddle(); + expect(saved.name).toBe('my-fiddle'); + expect(saved.source.localPath).toBe(dir); + await expect.poll(title).toBe('my-fiddle'); + const picker = (await app().dialogs()).at(-1); + expect(picker).toMatchObject({ kind: 'open', scripted: true }); + expect(picker?.options.properties).toContain('createDirectory'); + + expect(read(dir, 'main.js')).toContain('// edited'); + expect(read(dir, '.gitignore').split(/\r?\n/)).toEqual( + expect.arrayContaining(['node_modules', 'out']), + ); + const pkg = readJson(path.join(dir, 'package.json')); + const version = saved.versionRef.kind === 'release' ? saved.versionRef.version : ''; + expect(pkg.main).toMatch(/^(\.\/)?main\.js$/); + expect(pkg).toMatchObject({ + name: 'my-fiddle', + version: '1.0.0', + author: os.userInfo().username, + scripts: { start: 'electron .' }, + devDependencies: { electron: version }, + }); + expect(pkg.productName).toBeTruthy(); + // Saved folders become OS recent documents (the dock menu and the jump list). + const recent = (await app().sideEffects()).filter( + (effect) => effect.kind === 'app.addRecentDocument', + ); + expect(recent.map((effect) => effect.args[0])).toContain(dir); + + // The folder is known now: saving again doesn't ask. + const dialogs = (await app().dialogs()).length; + await edit('// again'); + await app().press('CmdOrCtrl+S'); + await expect.poll(async () => (await fiddle()).dirty).toBe(false); + expect((await app().dialogs()).length).toBe(dialogs); + expect(read(dir, 'main.js')).toContain('// again'); + }); + + it('always asks with Save As, and warns before replacing files', async () => { + const other = makeFolder(app(), 'other', { 'main.js': '// old\n' }); + await app().queueDialog('open', { filePaths: [other] }); + await app().queueDialog('messageBox', { button: 'Replace' }); + await app().press('CmdOrCtrl+Shift+S'); + await expect.poll(async () => (await fiddle()).source.localPath).toBe(other); + expect(await dialogMessages(app())).toContain('Replace the files in “other”?'); + expect(read(other, 'main.js')).toContain('// again'); + }); + + it('opens a folder, asking before it replaces unsaved changes', async () => { + const opened = makeFolder(app(), 'opened', { + 'main.js': "console.log('opened');\n", + 'index.html': '\n', + 'notes.md': 'not a fiddle file\n', + 'package.json': JSON.stringify({ + dependencies: { 'left-pad': '1.3.0' }, + devDependencies: { electron: '43.7.0' }, + }), + }); + await edit('// unsaved'); + await app().queueDialog('messageBox', { button: 'Discard changes' }); + await app().queueDialog('open', { filePaths: [opened] }); + await app().press('CmdOrCtrl+O'); + await expect.poll(async () => (await fiddle()).name).toBe('opened'); + + const loaded = await fiddle(); + expect(loaded).toMatchObject({ + dirty: false, + modules: { 'left-pad': '1.3.0' }, + versionRef: { kind: 'release', version: '43.7.0' }, + source: { localPath: opened }, + }); + expect(loaded.files.map((file) => file.name)).not.toContain('notes.md'); + expect(await dialogMessages(app())).toContain('Discard your unsaved changes?'); + }); + + it('warns about an invalid package.json and still loads the folder', async () => { + const bad = makeFolder(app(), 'bad-json', { + 'main.js': '// bad json\n', + 'package.json': '{ nope', + }); + await app().queueDialog('open', { filePaths: [bad] }); + await app().queueDialog('messageBox', { response: 0 }); + await app().runCommand('file.open'); + await expect.poll(async () => (await fiddle()).name).toBe('bad-json'); + await expect + .poll(() => dialogMessages(app())) + .toContain('The fiddle loaded with warnings'); + const warning = (await app().dialogs()).find( + (d) => d.options.message === 'The fiddle loaded with warnings', + ); + expect(String(warning?.options.detail)).toContain("package.json isn't valid JSON"); + }); + + it('saves a Forge project', async () => { + const dir = path.join(makeFolder(app(), 'forge-parent'), 'forge-fiddle'); + await app().queueDialog('open', { filePaths: [dir] }); + await app().runCommand('file.saveAsForge'); + await expect.poll(() => fs.existsSync(path.join(dir, 'package.json'))).toBe(true); + // An export: the window is not relinked to the Forge folder. + expect((await fiddle()).source.localPath).not.toBe(dir); + + const pkg = readJson(path.join(dir, 'package.json')); + expect(pkg.license).toBe('MIT'); + expect(pkg.devDependencies).toHaveProperty('@electron-forge/cli'); + expect(pkg.scripts).toMatchObject({ + start: 'electron-forge start', + package: 'electron-forge package', + make: 'electron-forge make', + publish: 'electron-forge publish', + }); + expect(pkg.config?.forge?.makers?.map((maker) => maker.name)).toEqual([ + '@electron-forge/maker-squirrel', + '@electron-forge/maker-zip', + '@electron-forge/maker-deb', + '@electron-forge/maker-rpm', + ]); + }); + + it('starts a new test from the test-template branch with CmdOrCtrl+T', async () => { + await app().press('CmdOrCtrl+T'); + await expect.poll(async () => (await fiddle()).source.templateName).toBe('test'); + expect(app.fixtures().requests.map((r) => r.path)).toContain( + '/minimal-repro/archive/test-template.zip', + ); + }); + + it('asks before closing a window with unsaved changes', async () => { + await edit('// unsaved'); + await app().queueDialog('messageBox', { button: 'Cancel' }); + await app().press('CmdOrCtrl+W'); + await expect + .poll(() => dialogMessages(app())) + .toContainEqual(expect.stringMatching(/^Save changes to “.+” before closing\?$/)); + expect(await app().windows()).toHaveLength(1); + }); + + it('asks before quitting with unsaved changes', async () => { + await app().queueDialog('messageBox', { button: 'Cancel' }); + await app().call('quit', {}); + await expect + .poll(() => dialogMessages(app())) + .toContain('Quit with unsaved changes?'); + expect(await app().windows()).toHaveLength(1); + // The harness quits after the last test. + await app().queueDialog('messageBox', { button: 'Quit' }); + }); +}); diff --git a/packages/app/e2e/driver.ts b/packages/app/e2e/driver.ts new file mode 100644 index 0000000000..9554beabb3 --- /dev/null +++ b/packages/app/e2e/driver.ts @@ -0,0 +1,616 @@ +// The e2e client: `launchApp()` starts the test build (out/test-build) with test mode on, a fresh +// temp dir, its own fixture server and, on Linux, its own Xvfb display. Plain Node with type +// stripping (`yarn driver` uses it too): `.ts` import extensions, and no Vitest imports. +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import type { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; + +import { needsNoSandbox } from '../tools/electron-run.mjs'; +import type { + DialogKind, + DialogResponse, + DriverMethod, + DriverMethods, + DriverResponse, + ElementInfo, + FailureReport, + Query, + TextMatcher, + WindowRef, +} from '../src/main/test-driver/protocol.ts'; +import { startFixtureServer, type FixtureServer } from './fixtures/server.ts'; + +export type { + ElementInfo, + FailureReport, + WindowInfo, +} from '../src/main/test-driver/protocol.ts'; +export { FIXTURE_GIST_ID, type FixtureServer } from './fixtures/server.ts'; + +export const APP_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const TEST_BUILD_DIR = path.join(APP_DIR, 'out', 'test-build'); + +/** A query as specs write it: names and text may be RegExps. */ +export interface AppQuery extends Omit { + name?: string | RegExp; + text?: string | RegExp; +} + +/** `role('button', 'Run')`, `role('heading', /welcome/i)`. */ +export function role( + roleName: string, + name?: string | RegExp, + extra: Omit = {}, +): AppQuery { + return { role: roleName, ...(name === undefined ? {} : { name }), ...extra }; +} + +/** Visible text containing `value` (or matching a RegExp). */ +export function text( + value: string | RegExp, + extra: Omit = {}, +): AppQuery { + return { text: value, ...extra }; +} + +const toMatcher = (value: string | RegExp): TextMatcher => + value instanceof RegExp ? { regex: value.source, flags: value.flags } : value; + +export function toQuery(query: AppQuery): Query { + const { name, text: textValue, ...rest } = query; + return { + ...rest, + ...(name === undefined ? {} : { name: toMatcher(name) }), + ...(textValue === undefined ? {} : { text: toMatcher(textValue) }), + }; +} + +const indent = (value: string) => + value + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + +export function formatFailure(report: FailureReport): string { + return [ + `${report.step} failed: ${report.message}`, + report.query === undefined ? '' : ` query: ${JSON.stringify(report.query)}`, + report.screenshot ? ` screenshot: ${report.screenshot}` : '', + report.a11ySnapshot + ? ` accessibility snapshot:\n${indent(report.a11ySnapshot)}` + : '', + ` main log (last ${report.mainLog.length}):\n${indent(report.mainLog.join('\n'))}`, + ` renderer log (last ${report.rendererLog.length}):\n${indent(report.rendererLog.join('\n'))}`, + ] + .filter(Boolean) + .join('\n'); +} + +/** A driver step failed. The message carries the whole report. */ +export class DriverError extends Error { + readonly report: FailureReport; + + constructor(report: FailureReport) { + super(formatFailure(report)); + this.name = 'DriverError'; + this.report = report; + } +} + +interface AppParts { + testDir?: string; + pid?: number; + exited?: Promise; + /** Stops the process, display and fixtures; removes the temp dir unless `keep`. */ + cleanup?: (keep: boolean) => Promise; + keepArtifacts?: boolean; +} + +const CLOSED = 'The driver connection closed (did the app quit or crash?)'; + +/** A running app, over its driver socket. Every method is one driver method. */ +export class FiddleApp { + readonly socketPath: string; + /** The temp dir: userData, cache, logs, `artifacts/` (screenshots) and `app-output.log`. */ + readonly testDir: string | undefined; + readonly pid: number | undefined; + /** Resolves with the exit code when a launched app exits. */ + readonly exited: Promise; + readonly #socket: net.Socket; + /** Settles a call by id: an error, or else the result. */ + readonly #pending = new Map void>(); + readonly #cleanup: AppParts['cleanup']; + readonly #keep: boolean; + #nextId = 1; + #failed = false; + #closed = false; + + private constructor(socket: net.Socket, socketPath: string, parts: AppParts) { + this.socketPath = socketPath; + this.testDir = parts.testDir; + this.pid = parts.pid; + this.exited = parts.exited ?? new Promise(() => undefined); + this.#cleanup = parts.cleanup; + this.#keep = parts.keepArtifacts ?? false; + this.#socket = socket; + let buffer = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + buffer += chunk; + for (let end = buffer.indexOf('\n'); end !== -1; end = buffer.indexOf('\n')) { + const line = buffer.slice(0, end); + buffer = buffer.slice(end + 1); + if (!line.trim()) continue; + const response = JSON.parse(line) as DriverResponse; + this.#pending.get(response.id)?.( + response.ok ? undefined : new DriverError(response.error), + response.ok ? response.result : undefined, + ); + } + }); + const fail = () => this.#pending.forEach((settle) => settle(new Error(CLOSED))); + socket.on('close', fail); + socket.on('error', fail); + } + + /** Attaches to a running app's driver socket (`yarn driver`); `launchApp()` passes the parts it owns. */ + static connect(socketPath: string, parts: AppParts = {}): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(socketPath); + socket.once('error', reject); + socket.once('connect', () => { + socket.off('error', reject); + resolve(new FiddleApp(socket, socketPath, parts)); + }); + }); + } + + /** Any driver method. A failed step throws a `DriverError` with the full report. */ + call( + method: M, + params: DriverMethods[M][0], + timeoutMs?: number, + ): Promise { + const id = this.#nextId++; + const limit = + timeoutMs ?? ((params as { timeout?: number }).timeout ?? 10_000) + 20_000; + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => settle(new Error(`driver ${method} got no answer within ${limit} ms`)), + limit, + ); + const settle = (error?: Error, result?: unknown) => { + this.#pending.delete(id); + clearTimeout(timer); + if (!error) return resolve(result as DriverMethods[M][1]); + // A failed step keeps the temp dir (screenshots, logs), unless it's close() finding the app gone. + if (!this.#closed) this.#failed = true; + reject(error); + }; + this.#pending.set(id, settle); + this.#socket.write(`${JSON.stringify({ id, method, params })}\n`); + }); + } + + windows() { + return this.call('windows', {}); + } + waitForWindow(window: WindowRef = 0, timeout?: number) { + return this.call('waitForWindow', { window, timeout }); + } + snapshot(window?: WindowRef) { + return this.call('snapshot', { window }); + } + /** Waits for at least one match; returns every match. */ + query(query: AppQuery): Promise { + return this.call('query', toQuery(query)); + } + /** Waits until nothing matches. */ + async waitForAbsent(query: AppQuery): Promise { + await this.call('query', { ...toQuery(query), state: 'absent' }); + } + /** Real mouse click at the center of exactly one match (or `nth`), once it's enabled and on top. */ + click(query: AppQuery) { + return this.call('click', toQuery(query)); + } + /** Real key events; clicks `query` first if given. */ + async type(value: string, query?: AppQuery): Promise { + await this.call('type', { text: value, ...(query ? { query: toQuery(query) } : {}) }); + } + /** `Enter`, `Escape`, `CmdOrCtrl+S`, `Shift+Tab`, ... Clicks `query` first if given. */ + async press(key: string, query?: AppQuery): Promise { + await this.call('press', { key, ...(query ? { query: toQuery(query) } : {}) }); + } + async runCommand(id: string, window?: WindowRef): Promise { + await this.call('runCommand', { id, window }); + } + stores(window?: WindowRef) { + return this.call('stores', { window }); + } + clipboard() { + return this.call('clipboard', {}); + } + /** Main's log and the renderers' console messages, newest last. */ + logs(tail?: number) { + return this.call('logs', { tail }); + } + screenshot(file?: string, window?: WindowRef) { + return this.call('screenshot', { path: file, window }); + } + waitForIdle(timeout?: number) { + return this.call('waitForIdle', { timeout }); + } + /** Evaluates JavaScript in the renderer's main world. Prefer queries in specs. */ + evaluate(expression: string, window?: WindowRef) { + return this.call('evaluate', { expression, window }); + } + /** Makes main request `url` (through Node's `fetch` or `net.fetch`): `{ status }`, or `{ error }` when the network guard blocks it. */ + mainFetch(url: string, via: 'node' | 'net' = 'node') { + return this.call('mainFetch', { url, via }); + } + /** Scripts the answer to the next native dialog of `kind`. */ + async queueDialog(kind: DialogKind, response: DialogResponse): Promise { + await this.call('queueDialog', { kind, response }); + } + dialogs() { + return this.call('dialogs', {}); + } + sideEffects() { + return this.call('sideEffects', {}); + } + violations() { + return this.call('violations', {}); + } + + /** Throws if the app made a non-loopback request, showed an unscripted dialog or lost a renderer. */ + async assertClean(): Promise { + const violations = await this.violations(); + if (violations.length > 0) { + this.#failed = true; + throw new Error(`Test isolation violations:\n- ${violations.join('\n- ')}`); + } + } + + /** Keeps the temp dir (screenshots, logs) after close. */ + markFailed(): void { + this.#failed = true; + } + + /** A screenshot, the accessibility snapshot and log tails for a failed test, as text. Never throws. */ + report(title: string): Promise { + return this.call('report', { title }).then(formatFailure, String); + } + + /** Closes the connection and leaves the app running (`yarn driver`). */ + disconnect(): void { + this.#socket.end(); + } + + /** Quits the app (killing it after 10 s) and cleans up. Keeps the temp dir if anything failed. */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + if (this.#cleanup) { + await this.call('quit', {}, 5000).catch(() => undefined); // Rejects when it's already gone. + let killTimer: NodeJS.Timeout | undefined; + await Promise.race([ + this.exited, + new Promise((resolve) => (killTimer = setTimeout(resolve, 10_000))), + ]); + clearTimeout(killTimer); + await this.#cleanup(this.#keep || this.#failed); + if (this.#failed && this.testDir) { + console.error( + `[e2e] kept ${this.testDir} (screenshots in artifacts/, app-output.log)`, + ); + } + } + this.disconnect(); + } +} + +export interface LaunchOptions { + /** Extra environment variables for the app. */ + env?: Record; + /** Default `en-US`. */ + locale?: string; + /** Share a fixture server; by default each app gets its own. */ + fixtures?: FixtureServer; + /** Keep the temp dir after close even if nothing failed. */ + keepArtifacts?: boolean; +} + +const requireFromApp = createRequire(path.join(APP_DIR, 'package.json')); + +function hasCommand(name: string): boolean { + return (process.env.PATH ?? '').split(path.delimiter).some((dir) => { + try { + fs.accessSync(path.join(dir, name), fs.constants.X_OK); + return true; + } catch { + return false; + } + }); +} + +/** Electron arguments every test launch needs. */ +export function electronArgs(): string[] { + if (process.platform !== 'linux') return []; + return needsNoSandbox() + ? ['--password-store=basic', '--no-sandbox'] + : ['--password-store=basic']; +} + +const TEST_ENV = { + FIDDLE_TEST_MODE: '1', + TZ: 'UTC', + LANG: 'en_US.UTF-8', + LANGUAGE: 'en_US', + LC_ALL: 'en_US.UTF-8', +}; + +/** Starts the test build in test mode with `args` after the app path (Chromium takes switches there too) and `env` added. */ +export function spawnTestBuild( + args: string[], + env: SpawnOptions['env'], + options: SpawnOptions = {}, +): ChildProcess { + const childEnv: NodeJS.ProcessEnv = { ...process.env, ...TEST_ENV, ...env }; + for (const key of ['ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS', 'ELECTRON_ENABLE_LOGGING']) + delete childEnv[key]; + const argv = [...electronArgs(), TEST_BUILD_DIR, ...args]; + return spawn(requireFromApp('electron') as string, argv, { ...options, env: childEnv }); +} + +/** + * macOS: loaded with `electron -r` into every fiddle a run starts, so its windows stay in the + * background like the app's own; otherwise each `new BrowserWindow` activates that Electron over + * the desktop. It reaches runs through FIDDLE_DEV_ELECTRON_FLAGS, which only dev and test builds read. + */ +const BACKGROUND_FIDDLE_PRELOAD = `// Written by packages/app/e2e/driver.ts for \`electron -r\`. +const Module = require('node:module'); +const { app, BrowserWindow } = require('electron'); +try { app.setActivationPolicy('accessory'); } catch {} +function BackgroundWindow(options) { + const wanted = Object.assign({}, options); + const show = wanted.show !== false; + wanted.show = false; + const win = new BrowserWindow(wanted); + try { win.setAlwaysOnTop(true, 'normal', -1); } catch {} + win.show = () => win.showInactive(); + win.focus = () => {}; + if (show) win.showInactive(); + return win; +} +Object.setPrototypeOf(BackgroundWindow, BrowserWindow); +BackgroundWindow.prototype = BrowserWindow.prototype; +const load = Module._load; +Module._load = function (request, ...rest) { + const exports = load.call(this, request, ...rest); + if (request !== 'electron' && request !== 'electron/main') return exports; + return new Proxy(exports, { get: (target, key) => (key === 'BrowserWindow' ? BackgroundWindow : Reflect.get(target, key)) }); +}; +`; + +/** + * FIDDLE_DEV_ELECTRON_FLAGS for the fiddles a run starts: no sandbox where the app itself has none + * (without it Chromium aborts with SIGTRAP on hosts that forbid its namespace sandbox, like the + * Ubuntu CI runners), and on macOS BACKGROUND_FIDDLE_PRELOAD, so they stay in the background. + */ +function fiddleRunFlags(testDir: string): Record { + const flags = [ + process.env.FIDDLE_DEV_ELECTRON_FLAGS, + needsNoSandbox() && '--no-sandbox', + ]; + // The app splits the variable on spaces, so a temp dir with one (never on macOS) opts out. + if ( + process.platform === 'darwin' && + process.env.FIDDLE_E2E_FOREGROUND !== '1' && + !testDir.includes(' ') + ) { + const preload = path.join(testDir, 'background-windows.cjs'); + fs.writeFileSync(preload, BACKGROUND_FIDDLE_PRELOAD); + flags.push('-r', preload); + } + const value = flags.filter(Boolean).join(' '); + return value ? { FIDDLE_DEV_ELECTRON_FLAGS: value } : {}; +} + +/** + * Removes a test dir. On Windows something from the app that just quit can still hold it after + * the retries; that is left to the temp dir rather than failing a spec that passed. + */ +export function removeTestDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10 }); + } catch (error) { + if (process.platform !== 'win32') throw error; + console.warn(`[e2e] left ${dir} behind: ${(error as Error).message}`); + } +} + +interface Display { + env: Record; + stop(): void; +} + +/** A private X display per app: `-displayfd` picks a free number atomically, which `xvfb-run -a` doesn't under parallel launches. openbox runs on it when installed, for real focus handling. */ +async function startDisplay(): Promise { + const given = process.env.FIDDLE_E2E_DISPLAY; + if (given) return { env: { DISPLAY: given }, stop: () => undefined }; + if (!hasCommand('Xvfb')) { + if (process.env.DISPLAY) return { env: {}, stop: () => undefined }; + throw new Error('Xvfb is not installed and DISPLAY is not set'); + } + const xvfb = spawn( + 'Xvfb', + ['-displayfd', '3', '-screen', '0', '1440x900x24', '-nolisten', 'tcp', '-noreset'], + { stdio: ['ignore', 'ignore', 'pipe', 'pipe'] }, + ); + const display = await new Promise((resolve, reject) => { + let out = ''; + let err = ''; + xvfb.stderr?.on('data', (chunk: Buffer) => { + if (err.length < 4000) err += chunk.toString(); + }); + (xvfb.stdio[3] as Readable).on('data', (chunk: Buffer) => { + out += chunk.toString(); + if (out.includes('\n')) resolve(`:${out.trim()}`); + }); + xvfb.once('error', reject); + xvfb.once('exit', (code) => + reject(new Error(`Xvfb exited (${code}): ${err.trim()}`)), + ); + }); + const wm = hasCommand('openbox') + ? spawn('openbox', ['--sm-disable'], { + env: { ...process.env, DISPLAY: display }, + stdio: 'ignore', + }) + : undefined; + return { + env: { DISPLAY: display }, + stop: () => { + wm?.kill(); + xvfb.kill(); + }, + }; +} + +async function connectWhenReady( + connect: () => Promise, + timeout: number, + exited: Promise, + crashed: () => boolean, +): Promise { + let exitCode: number | null | undefined; + void exited.then((code) => { + exitCode = code; + }); + const deadline = Date.now() + timeout; + for (;;) { + if (exitCode !== undefined) { + throw new Error(`the app exited (code ${exitCode}) before its driver started`); + } + if (crashed()) throw new Error('the main process threw during startup'); + try { + return await connect(); + } catch { + // Not listening yet. + } + if (Date.now() > deadline) + throw new Error(`the driver socket didn't open within ${timeout} ms`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +/** Launches the test build and waits for its first window to be shown. */ +export async function launchApp(options: LaunchOptions = {}): Promise { + const timeout = 30_000; + if (!fs.existsSync(path.join(TEST_BUILD_DIR, 'package.json'))) { + throw new Error( + `No test build in ${TEST_BUILD_DIR}. Build it with: yarn workspace electron-fiddle driver:build`, + ); + } + const fixtures = options.fixtures ?? (await startFixtureServer()); + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fiddle-e2e-')); + const socketPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\fiddle-driver-${path.basename(testDir)}` + : path.join(testDir, 'driver.sock'); + let display: Display | undefined; + if (process.platform === 'linux') { + try { + display = await startDisplay(); + } catch (error) { + if (!options.fixtures) await fixtures.close(); + fs.rmSync(testDir, { recursive: true, force: true }); + throw error; + } + } + + const env = { + ...display?.env, + FIDDLE_TEST_DIR: testDir, + FIDDLE_TEST_FIXTURE_URL: fixtures.url, + FIDDLE_TEST_LOCALE: options.locale ?? 'en-US', + ELECTRON_FIDDLE_DRIVER_SOCKET: socketPath, + ...(process.env.FIDDLE_E2E_FOREGROUND === '1' ? { FIDDLE_TEST_FOREGROUND: '1' } : {}), + ...fiddleRunFlags(testDir), + ...options.env, + }; + const output = fs.createWriteStream(path.join(testDir, 'app-output.log')); + const tail: string[] = []; + // FIDDLE_E2E_VERBOSE=1 echoes the app's stdout and stderr. + const verbose = process.env.FIDDLE_E2E_VERBOSE === '1'; + const child = spawnTestBuild([], env, { stdio: ['ignore', 'pipe', 'pipe'] }); + for (const stream of [child.stdout!, child.stderr!]) { + stream.on('data', (chunk: Buffer) => { + output.write(chunk); + if (verbose) process.stderr.write(chunk); + tail.push(...chunk.toString().split('\n').filter(Boolean)); + tail.splice(0, Math.max(0, tail.length - 60)); + }); + } + const exited = new Promise((resolve) => { + child.once('exit', (code) => resolve(code)); + child.once('error', () => resolve(null)); + }); + // An uncaught exception in main leaves Electron up behind an error dialog; fail fast. + let mainCrashed = false; + child.stderr!.on('data', (chunk: Buffer) => { + if (chunk.toString().includes('A JavaScript error occurred in the main process')) { + mainCrashed = true; + } + }); + + const cleanup = async (keep: boolean) => { + if (child.exitCode === null && child.signalCode === null) { + console.warn(`[e2e] the app in ${testDir} had not exited; killing it`); + child.kill('SIGKILL'); + } + display?.stop(); + await new Promise((resolve) => output.close(resolve)); + if (!options.fixtures) await fixtures.close(); + if (!keep) removeTestDir(testDir); + }; + + try { + const parts = { + testDir, + pid: child.pid, + exited, + cleanup, + keepArtifacts: options.keepArtifacts, + }; + const app = await connectWhenReady( + () => FiddleApp.connect(socketPath, parts), + timeout, + exited, + () => mainCrashed, + ); + try { + await app.waitForWindow(0, timeout); + // Shown is not painted: main shows a window whose page is slow to report ready anyway, and a + // cold Windows runner has taken 10 s to mount the editor. Specs expect it there. + await app.query({ role: 'code', timeout }); + } catch (error) { + app.disconnect(); + throw error; + } + return app; + } catch (error) { + await cleanup(true); + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Launching the app failed: ${message}\n` + + `app output (${path.join(testDir, 'app-output.log')}):\n${indent(tail.join('\n'))}`, + { cause: error }, + ); + } +} diff --git a/packages/app/e2e/features.e2e.ts b/packages/app/e2e/features.e2e.ts new file mode 100644 index 0000000000..5dfcadb0ce --- /dev/null +++ b/packages/app/e2e/features.e2e.ts @@ -0,0 +1,126 @@ +// Core flows: new fiddle, settings, the command palette, Show Me examples and the tour. +import { describe, expect, it } from 'vitest'; + +import { openedUrls, role, text, useApp, windowState } from './harness.ts'; + +describe('features', () => { + const app = useApp(); + + it('downloads the template for the current major at startup', async () => { + await expect + .poll(() => app.fixtures().requests.map((r) => `${r.status} ${r.path}`)) + .toContain('200 /minimal-repro/archive/44-x-y.zip'); + }); + + it('starts a new fiddle from the template with CmdOrCtrl+N', async () => { + const before = await windowState(app()); + await app().press('CmdOrCtrl+N'); + await expect + .poll(async () => (await windowState(app())).fiddle.fiddleRev) + .toBeGreaterThan(before.fiddle.fiddleRev); + const after = await windowState(app()); + expect(after.fiddle).toMatchObject({ dirty: false, modules: {} }); + expect(after.fiddle.name).not.toBe(before.fiddle.name); + await app().query(role('tab', 'main.js')); + }); + + it('opens settings with CmdOrCtrl+,', async () => { + await app().press('CmdOrCtrl+,'); + await app().query(role('navigation', 'Settings sections')); + expect((await windowState(app())).view).toBe('settings'); + await app().click(role('button', 'Close settings')); + await app().waitForAbsent(role('navigation', 'Settings sections')); + expect((await windowState(app())).view).toBe('editor'); + }); + + // Focus stays on the title bar button, outside the page. + it('opens settings from the title bar and closes them with Escape', async () => { + await app().click(role('button', 'Settings')); + await app().query(role('navigation', 'Settings sections')); + await app().press('Escape'); + await app().waitForAbsent(role('navigation', 'Settings sections')); + }); + + // Focus stays where it was before the shortcut, outside the page. + it('closes settings opened with CmdOrCtrl+, on Escape', async () => { + await app().press('CmdOrCtrl+,'); + await app().query(role('navigation', 'Settings sections')); + await app().press('Escape'); + await app().waitForAbsent(role('navigation', 'Settings sections')); + expect((await windowState(app())).view).toBe('editor'); + }); + + it('runs a command from the palette, which shows keybindings', async () => { + await app().press('CmdOrCtrl+Shift+P'); + await app().query(role('dialog', 'Command palette')); + await app().query(role('option', /^New window .*N$/)); + + await app().type('toggle sidebar', role('combobox', 'Command palette')); + await app().query(role('option', 'Toggle sidebar')); + await app().press('Enter'); + await app().waitForAbsent(role('dialog', 'Command palette')); + await expect.poll(async () => (await windowState(app())).layout.sidebar).toBe(false); + + await app().runCommand('view.toggleSidebar'); + await expect.poll(async () => (await windowState(app())).layout.sidebar).toBe(true); + }); + + it('offers the menu actions that have no shortcut', async () => { + await app().press('CmdOrCtrl+Shift+P'); + for (const name of [ + 'Publish to gist…', + 'Save as Forge project…', + 'Package', + 'Make installers', + 'Toggle soft wrap', + 'Toggle minimap', + 'Show welcome tour', + 'Electron Fiddle on GitHub', + 'Electron on GitHub', + 'Report an issue', + 'About Electron Fiddle', + ]) { + // An exact name: no keybinding follows the label. + await app().query(role('option', name)); + } + await app().press('Escape'); + await app().waitForAbsent(role('dialog', 'Command palette')); + + // Each link asks before opening in the browser. + for (const id of [ + 'help.fiddleRepository', + 'help.electronRepository', + 'help.reportIssue', + ]) { + await app().queueDialog('messageBox', { button: 'Open link' }); + await app().runCommand(id); + } + await expect + .poll(() => openedUrls(app())) + .toEqual( + expect.arrayContaining([ + expect.stringMatching(/^https:\/\/github\.com\/electron\/fiddle\/?$/), + expect.stringMatching(/^https:\/\/github\.com\/electron\/electron\/?$/), + expect.stringMatching(/^https:\/\/github\.com\/electron\/fiddle\/issues/), + ]), + ); + }); + + it('loads a Show Me example from the palette', async () => { + await app().press('CmdOrCtrl+Shift+P'); + await app().type('BrowserWindow', role('combobox', 'Command palette')); + await app().query(role('option', /^BrowserWindow\b/)); + await app().press('Enter'); + await expect + .poll(async () => (await windowState(app())).fiddle.source.templateName) + .toBe('BrowserWindow'); + await app().query(role('tab', 'main.js')); + }); + + it('replays the welcome tour', async () => { + await app().runCommand('help.showTour'); + await app().query(text(/^1 of \d+$/)); + await app().click(role('button', 'Skip tour')); + await app().waitForAbsent(text(/^1 of \d+$/)); + }); +}); diff --git a/packages/app/e2e/files.e2e.ts b/packages/app/e2e/files.e2e.ts new file mode 100644 index 0000000000..16db2403b7 --- /dev/null +++ b/packages/app/e2e/files.e2e.ts @@ -0,0 +1,122 @@ +// The file list: add files, the validation errors for names the fiddle can't +// take, and the row's context menu (rename, close tab, open and delete). +import { describe, expect, it } from 'vitest'; + +import { role, useApp, windowState } from './harness.ts'; + +const FAILED = "Couldn't change the file"; + +describe('files', () => { + const app = useApp(); + const files = async () => (await windowState(app())).fiddle.files; + const names = async () => (await files()).map((file) => file.name); + const failures = async () => (await app().snapshot(0)).split(FAILED).length - 1; + + const addFile = async (name: string) => { + await app().click(role('button', 'Add file')); + await app().query(role('dialog', 'New file')); + await app().type(name, role('textbox', 'File name')); + }; + + it('adds a file, confirming its name with Enter', async () => { + await addFile('extra.js'); + await app().press('Enter'); + await app().waitForAbsent(role('dialog', 'New file')); + // A new file is unsaved, and its tab says so. + await app().query(role('tab', /^extra\.js\b/)); + expect(await files()).toContainEqual({ name: 'extra.js', visible: true }); + expect((await windowState(app())).fiddle.activeFile).toBe('extra.js'); + }); + + it('cancels a new file with Escape', async () => { + await addFile('cancelled.js'); + await app().press('Escape'); + await app().waitForAbsent(role('dialog', 'New file')); + expect(await names()).not.toContain('cancelled.js'); + }); + + it.each([ + ['an unsupported extension', 'notes.md'], + ['a path separator', 'lib/util.js'], + ['a duplicate name', 'extra.js'], + ['a reserved name', 'package.json'], + ['a second main entry', 'main.mjs'], + ])('refuses %s', async (_what, name) => { + const before = await files(); + const shown = await failures(); + await addFile(name); + await app().press('Enter'); + await expect.poll(failures).toBeGreaterThan(shown); + expect(await files()).toEqual(before); + }); + + it('renames, closes, opens and deletes a file from its context menu', async () => { + /** From the keyboard: a click on the row would put the focus in the file's editor. */ + const openMenu = async (file: string) => { + await app().query(role('row', file)); + await app().evaluate( + `document.querySelector('[role="row"][data-key=${JSON.stringify(file)}]').focus()`, + ); + await app().press('Shift+F10'); + }; + const fromMenu = async (file: string, item: string) => { + await openMenu(file); + await app().click(role('menuitem', item)); + }; + + // Escape closes the menu and leaves the window intact. + await openMenu('extra.js'); + await app().query(role('menu', 'File actions')); + await app().press('Escape'); + await app().waitForAbsent(role('menu', 'File actions')); + + await fromMenu('extra.js', 'Rename…'); + await app().query(role('dialog', 'Rename extra.js')); + await app().press('CmdOrCtrl+A', role('textbox', 'File name')); + await app().type('renamed.js'); + await app().press('Enter'); + await expect.poll(names).toContain('renamed.js'); + expect(await names()).not.toContain('extra.js'); + + await fromMenu('renamed.js', 'Close tab'); + await expect.poll(files).toContainEqual({ name: 'renamed.js', visible: false }); + // A click on the row would reopen the file, so open the menu from the keyboard once focus is back on it. + await app().waitForAbsent(role('menu', 'File actions')); + await app().waitForIdle(); + await app().press('Shift+F10'); + await app().click(role('menuitem', 'Open')); + await expect.poll(files).toContainEqual({ name: 'renamed.js', visible: true }); + + await fromMenu('renamed.js', 'Delete…'); + await app().query(role('alertdialog', 'Delete renamed.js?')); + await app().click(role('button', 'Delete')); + await expect.poll(names).not.toContain('renamed.js'); + }); + + /** The given snapshot lines in the order the accessibility snapshot has them, leaving out the ones it lacks. */ + const snapshotOrder = async (lines: string[]) => { + const snapshot = await app().snapshot(); + return lines + .map((line) => [line, snapshot.indexOf(line)] as const) + .filter(([, at]) => at !== -1) + .sort(([, a], [, b]) => a - b) + .map(([line]) => line); + }; + + it('lists files in one flat list, in fiddle order', async () => { + await app().query(role('heading', 'Files')); + await addFile('data.json'); + await app().press('Enter'); + await app().query(role('row', 'data.json')); + const expected = [ + 'heading "Files"', + 'row "main.js"', + 'row "renderer.js"', + 'row "index.html"', + 'row "preload.js"', + 'row "data.json"', + 'button "Add file"', + ]; + expect(await snapshotOrder(expected)).toEqual(expected); + }); +}); diff --git a/packages/app/e2e/fixtures/data/electron.d.ts b/packages/app/e2e/fixtures/data/electron.d.ts new file mode 100644 index 0000000000..bda6ba85b7 --- /dev/null +++ b/packages/app/e2e/fixtures/data/electron.d.ts @@ -0,0 +1,7 @@ +// E2E fixture: a tiny stand-in for electron.d.ts. +declare namespace Electron { + const fixture: true; +} +declare module 'electron' { + export = Electron; +} diff --git a/packages/app/e2e/fixtures/data/gists/c0ffee00c0ffee00c0ffee00c0ffee00.json b/packages/app/e2e/fixtures/data/gists/c0ffee00c0ffee00c0ffee00c0ffee00.json new file mode 100644 index 0000000000..b34122ac50 --- /dev/null +++ b/packages/app/e2e/fixtures/data/gists/c0ffee00c0ffee00c0ffee00c0ffee00.json @@ -0,0 +1,32 @@ +{ + "description": "E2E fixture gist", + "public": true, + "owner": { "login": "fiddle-e2e" }, + "history": [ + { + "version": "3333333333333333333333333333333333333333", + "committed_at": "2026-01-03T00:00:00Z", + "change_status": { "total": 3, "additions": 2, "deletions": 1 } + }, + { + "version": "2222222222222222222222222222222222222222", + "committed_at": "2026-01-02T00:00:00Z", + "change_status": { "total": 0, "additions": 0, "deletions": 0 } + }, + { + "version": "1111111111111111111111111111111111111111", + "committed_at": "2026-01-01T00:00:00Z", + "change_status": { "total": 12, "additions": 12, "deletions": 0 } + } + ], + "files": { + "main.js": { + "content": "const { app, BrowserWindow } = require('electron');\n\napp.whenReady().then(() => {\n const win = new BrowserWindow({ width: 800, height: 600 });\n win.loadFile('index.html');\n});\n" + }, + "index.html": { + "content": "\n\n \n

Hello from the fixture gist

\n \n \n\n" + }, + "renderer.js": { "content": "console.log('fixture gist renderer');\n" }, + "preload.js": { "content": "// fixture gist preload\n" } + } +} diff --git a/packages/app/e2e/fixtures/data/minimal-repro/index.html b/packages/app/e2e/fixtures/data/minimal-repro/index.html new file mode 100644 index 0000000000..8c64061278 --- /dev/null +++ b/packages/app/e2e/fixtures/data/minimal-repro/index.html @@ -0,0 +1,7 @@ + + + +

minimal-repro fixture

+ + + diff --git a/packages/app/e2e/fixtures/data/minimal-repro/main.js b/packages/app/e2e/fixtures/data/minimal-repro/main.js new file mode 100644 index 0000000000..99a41593b1 --- /dev/null +++ b/packages/app/e2e/fixtures/data/minimal-repro/main.js @@ -0,0 +1,7 @@ +/* eslint-disable */ +const { app, BrowserWindow } = require('electron'); + +app.whenReady().then(() => { + const win = new BrowserWindow({ width: 800, height: 600 }); + win.loadFile('index.html'); +}); diff --git a/packages/app/e2e/fixtures/data/minimal-repro/package.json b/packages/app/e2e/fixtures/data/minimal-repro/package.json new file mode 100644 index 0000000000..0be02e6bf7 --- /dev/null +++ b/packages/app/e2e/fixtures/data/minimal-repro/package.json @@ -0,0 +1,5 @@ +{ + "name": "minimal-repro", + "version": "1.0.0", + "main": "main.js" +} diff --git a/packages/app/e2e/fixtures/data/minimal-repro/preload.js b/packages/app/e2e/fixtures/data/minimal-repro/preload.js new file mode 100644 index 0000000000..535e5a3aed --- /dev/null +++ b/packages/app/e2e/fixtures/data/minimal-repro/preload.js @@ -0,0 +1 @@ +// minimal-repro fixture preload diff --git a/packages/app/e2e/fixtures/data/minimal-repro/renderer.js b/packages/app/e2e/fixtures/data/minimal-repro/renderer.js new file mode 100644 index 0000000000..8290463520 --- /dev/null +++ b/packages/app/e2e/fixtures/data/minimal-repro/renderer.js @@ -0,0 +1 @@ +// minimal-repro fixture renderer diff --git a/packages/app/e2e/fixtures/data/node.d.ts b/packages/app/e2e/fixtures/data/node.d.ts new file mode 100644 index 0000000000..c64134eaaf --- /dev/null +++ b/packages/app/e2e/fixtures/data/node.d.ts @@ -0,0 +1,4 @@ +// E2E fixture: a tiny stand-in for @types/node. +declare module 'node:fixture' { + export const fixture: true; +} diff --git a/packages/app/e2e/fixtures/data/npm-search.json b/packages/app/e2e/fixtures/data/npm-search.json new file mode 100644 index 0000000000..860fa5a830 --- /dev/null +++ b/packages/app/e2e/fixtures/data/npm-search.json @@ -0,0 +1,25 @@ +{ + "hits": [ + { + "objectID": "left-pad", + "name": "left-pad", + "version": "1.1.0", + "description": "String left pad", + "owner": { "name": "fixture" }, + "humanDownloadsLast30Days": "1m" + }, + { + "objectID": "lodash", + "name": "lodash", + "version": "1.1.0", + "description": "Lodash modular utilities.", + "owner": { "name": "fixture" }, + "humanDownloadsLast30Days": "200m" + } + ], + "nbHits": 2, + "page": 0, + "nbPages": 1, + "hitsPerPage": 20, + "query": "fixture" +} diff --git a/packages/app/e2e/fixtures/data/releases.json b/packages/app/e2e/fixtures/data/releases.json new file mode 100644 index 0000000000..803995d268 --- /dev/null +++ b/packages/app/e2e/fixtures/data/releases.json @@ -0,0 +1,98 @@ +[ + { + "version": "44.3.0", + "date": "2026-09-08", + "fullDate": "2026-09-08T16:57:51.000Z", + "node": "24.20.0", + "v8": "15.2.124.19", + "uv": "1.52.1", + "zlib": "1.3.2.1-motley", + "openssl": "0.0.0", + "modules": "149", + "chrome": "152.0.7977.78", + "files": [ + "darwin-x64", + "darwin-x64-symbols", + "linux-ia32", + "linux-ia32-symbols", + "linux-x64", + "linux-x64-symbols", + "win32-ia32", + "win32-ia32-symbols", + "win32-x64", + "win32-x64-symbols" + ] + }, + { + "version": "45.0.0-alpha.6", + "date": "2026-09-10", + "fullDate": "2026-09-10T20:00:16.000Z", + "node": "24.21.0", + "v8": "15.4.80", + "uv": "1.52.1", + "zlib": "1.3.2.1-motley", + "openssl": "0.0.0", + "modules": "150", + "chrome": "155.0.8038.2", + "files": [ + "darwin-x64", + "darwin-x64-symbols", + "linux-ia32", + "linux-ia32-symbols", + "linux-x64", + "linux-x64-symbols", + "win32-ia32", + "win32-ia32-symbols", + "win32-x64", + "win32-x64-symbols" + ] + }, + { + "version": "43.7.0", + "date": "2026-09-10", + "fullDate": "2026-09-10T06:34:54.000Z", + "node": "24.21.0", + "v8": "15.0.245.31", + "uv": "1.52.1", + "zlib": "1.3.2.1-motley", + "openssl": "0.0.0", + "modules": "148", + "chrome": "150.0.7871.250", + "files": [ + "darwin-x64", + "darwin-x64-symbols", + "linux-ia32", + "linux-ia32-symbols", + "linux-x64", + "linux-x64-symbols", + "win32-ia32", + "win32-ia32-symbols", + "win32-x64", + "win32-x64-symbols" + ] + }, + { + "version": "42.11.3", + "date": "2026-09-08", + "fullDate": "2026-09-08T16:00:26.000Z", + "node": "24.19.0", + "v8": "14.8.178.38", + "uv": "1.52.1", + "zlib": "1.3.2.1-motley", + "openssl": "0.0.0", + "modules": "146", + "chrome": "148.0.7778.280", + "files": [ + "darwin-x64", + "darwin-x64-symbols", + "linux-ia32", + "linux-ia32-symbols", + "linux-x64", + "linux-x64-symbols", + "win32-ia32", + "win32-ia32-symbols", + "win32-x64", + "win32-x64-symbols" + ] + } +] diff --git a/packages/app/e2e/fixtures/server.ts b/packages/app/e2e/fixtures/server.ts new file mode 100644 index 0000000000..fdfbdccd73 --- /dev/null +++ b/packages/app/e2e/fixtures/server.ts @@ -0,0 +1,419 @@ +/** + * The local fixture server behind every endpoint in test mode (`fixtureEndpoints` in + * src/shared/endpoints.ts). It records every request so specs can assert on traffic. + * + * Electron zips come from FIDDLE_E2E_ELECTRON_ZIPS (a directory), ELECTRON_CACHE or @electron/get's + * default cache (where CI's cached download lands). If the installed Electron's zip isn't there + * and `zip` is available, one is made from node_modules/electron/dist. + */ +import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { makeZip } from './zip.ts'; + +const FIXTURES = path.dirname(fileURLToPath(import.meta.url)); +const DATA = path.join(FIXTURES, 'data'); +const APP_DIR = path.resolve(FIXTURES, '..', '..'); + +export interface FixtureRequest { + method: string; + path: string; + status: number; + body?: string; +} + +export interface FixtureServer { + /** e.g. `http://127.0.0.1:43210`, no trailing slash. */ + url: string; + requests: FixtureRequest[]; + close(): Promise; +} + +/** The fixture gist every spec can load. */ +export const FIXTURE_GIST_ID = 'c0ffee00c0ffee00c0ffee00c0ffee00'; + +type Reply = + | { + status: number; + type: string; + body: Buffer | string; + headers?: Record; + } + | { file: string }; + +const json = (value: unknown, status = 200): Reply => ({ + status, + type: 'application/json', + body: JSON.stringify(value), +}); +const text = (body: string, status = 200, type = 'text/plain'): Reply => ({ + status, + type, + body, +}); +const notFound = (what: string): Reply => + text(`fixture server: no fixture for ${what}\n`, 404); + +function readData(file: string): string { + return fs.readFileSync(path.join(DATA, file), 'utf8'); +} + +function electronCacheDirs(): string[] { + const home = os.homedir(); + const dirs = [ + process.env.FIDDLE_E2E_ELECTRON_ZIPS, + process.env.ELECTRON_CACHE, + process.platform === 'darwin' + ? path.join(home, 'Library', 'Caches', 'electron') + : process.platform === 'win32' + ? path.join( + process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local'), + 'electron', + 'Cache', + ) + : path.join(process.env.XDG_CACHE_HOME ?? path.join(home, '.cache'), 'electron'), + path.join(APP_DIR, 'node_modules', '.cache', 'fiddle-e2e'), + ]; + return dirs.filter((dir): dir is string => !!dir && fs.existsSync(dir)); +} + +/** Finds `file` in a cache dir, directly or one level down (@electron/get's layout). */ +function findInCache(file: string): string | undefined { + for (const dir of electronCacheDirs()) { + const direct = path.join(dir, file); + if (fs.existsSync(direct)) return direct; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const nested = path.join(dir, entry.name, file); + if (entry.isDirectory() && fs.existsSync(nested)) return nested; + } + } + return undefined; +} + +function installedElectron(): { version: string; dist: string } | undefined { + try { + const require = createRequire(path.join(APP_DIR, 'package.json')); + const pkgFile = require.resolve('electron/package.json'); + const { version } = JSON.parse(fs.readFileSync(pkgFile, 'utf8')) as { + version: string; + }; + return { version, dist: path.join(path.dirname(pkgFile), 'dist') }; + } catch { + return undefined; + } +} + +function findElectronZip(file: string): string | undefined { + const cached = findInCache(file); + if (cached) return cached; + const installed = installedElectron(); + const wanted = + installed && `electron-v${installed.version}-${process.platform}-${process.arch}.zip`; + if (!installed || file !== wanted || !fs.existsSync(installed.dist)) return undefined; + const out = path.join(APP_DIR, 'node_modules', '.cache', 'fiddle-e2e', file); + try { + fs.mkdirSync(path.dirname(out), { recursive: true }); + execFileSync('zip', ['-qry', `${out}.tmp`, '.'], { cwd: installed.dist }); + fs.renameSync(`${out}.tmp`, out); + return out; + } catch { + return undefined; + } +} + +const hashes = new Map(); + +function sha256(file: string): string { + let hash = hashes.get(file); + if (!hash) { + hash = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + hashes.set(file, hash); + } + return hash; +} + +/** + * The zip for `file`, or else the installed Electron's zip under that name, + * so specs can switch between the fixture's versions offline. + */ +function zipFor(file: string): string | undefined { + const zip = findElectronZip(file); + if (zip) return zip; + const installed = installedElectron(); + const suffix = `-${process.platform}-${process.arch}.zip`; + if (!installed || !file.startsWith('electron-v') || !file.endsWith(suffix)) + return undefined; + return findElectronZip(`electron-v${installed.version}${suffix}`); +} + +function electronMirror(dir: string, file: string): Reply { + if (file === 'SHASUMS256.txt') { + const lines: string[] = []; + for (const name of [`electron-${dir}-${process.platform}-${process.arch}.zip`]) { + const zip = zipFor(name); + if (zip) lines.push(`${sha256(zip)} *${name}`); + } + return lines.length > 0 + ? text(`${lines.join('\n')}\n`) + : notFound(`SHASUMS256.txt for ${dir}`); + } + const zip = file.endsWith('.zip') ? zipFor(file) : undefined; + return zip ? { file: zip } : notFound(`${dir}/${file} (no cached Electron zip)`); +} + +interface GistFile { + filename?: string; + content: string; +} + +function loadGist(id: string, base: string): Record | undefined { + const file = path.join(DATA, 'gists', `${id}.json`); + if (!fs.existsSync(file)) return undefined; + const gist = JSON.parse(fs.readFileSync(file, 'utf8')) as { + owner?: { login: string }; + files: Record; + }; + const owner = gist.owner?.login ?? 'fiddle-e2e'; + const files = Object.fromEntries( + Object.entries(gist.files).map(([name, entry]) => [ + name, + { + filename: name, + type: 'text/plain', + size: entry.content.length, + truncated: false, + content: entry.content, + raw_url: `${base}/gist-raw/${owner}/${id}/raw/0/${name}`, + }, + ]), + ); + return { + ...gist, + id, + url: `${base}/github-api/gists/${id}`, + html_url: `https://gist.github.com/${owner}/${id}`, + files, + }; +} + +/** Files as GitHub returns them: `null` in a PATCH deletes, anything else adds or replaces. */ +function applyFiles( + current: Record, + changes: Record = {}, +): Record { + const files = { ...current }; + for (const [name, change] of Object.entries(changes)) { + if (change) + files[name] = { + filename: name, + type: 'text/plain', + size: change.content.length, + content: change.content, + }; + else delete files[name]; + } + return files; +} + +/** Gists created, updated or deleted through this process's fixture servers. `null` is deleted. */ +const written = new Map | null>(); + +function revision(seed: string, changes: number) { + return { + version: crypto.createHash('sha1').update(seed).digest('hex'), + committed_at: new Date(Date.UTC(2026, 1, 1) + written.size * 60_000).toISOString(), + change_status: { total: changes, additions: changes, deletions: 0 }, + }; +} + +function github(method: string, parts: string[], body: string, base: string): Reply { + const [resource, id, sub] = parts; + if (resource === 'user' && method === 'GET') { + return { + ...json({ login: 'fiddle-e2e', id: 1, name: 'Fiddle E2E', avatar_url: '' }), + headers: { 'X-OAuth-Scopes': 'gist' }, + } as Reply; + } + if (resource !== 'gists') return notFound(`GitHub ${parts.join('/')}`); + if (method === 'POST' && !id) { + const input = JSON.parse(body || '{}') as { + description?: string; + public?: boolean; + files?: Record; + }; + const first = revision(body, Object.keys(input.files ?? {}).length); + const newId = first.version.slice(0, 32); + const gist = { + id: newId, + description: input.description ?? '', + public: input.public ?? false, + url: `${base}/github-api/gists/${newId}`, + html_url: `https://gist.github.com/fiddle-e2e/${newId}`, + files: applyFiles({}, input.files), + owner: { login: 'fiddle-e2e' }, + history: [first], + }; + written.set(newId, gist); + return json(gist, 201); + } + if (!id) return notFound('GitHub gists listing'); + const gist = written.has(id) ? written.get(id) : loadGist(id, base); + if (!gist) return json({ message: 'Not Found' }, 404); + if (method === 'DELETE') { + written.set(id, null); + return { status: 204, type: 'text/plain', body: '' }; + } + if (method === 'PATCH') { + const input = JSON.parse(body || '{}') as { + description?: string; + files?: Record; + }; + const updated = { + ...gist, + ...(input.description === undefined ? {} : { description: input.description }), + files: applyFiles(gist.files as Record, input.files), + history: [ + revision(`${id}${body}`, Object.keys(input.files ?? {}).length), + ...((gist.history as unknown[]) ?? []), + ], + }; + written.set(id, updated); + return json(updated); + } + if (sub === 'commits') return json(gist.history ?? []); + // `/gists/` or `/gists//`: the fixture has the same files at every revision. + return json(gist); +} + +function gistRaw(parts: string[], base: string): Reply { + // //raw// + const [, id, , , ...rest] = parts; + const gist = id ? loadGist(id, base) : undefined; + const files = gist?.files as Record | undefined; + const entry = files?.[rest.join('/')]; + return entry ? text(entry.content) : notFound(`raw gist file ${parts.join('/')}`); +} + +function unpkg(pathname: string, search: string): Reply { + if (search === '?meta') { + return json({ + path: '/', + type: 'directory', + files: [{ path: '/index.d.ts', type: 'file' }], + }); + } + if (!pathname.endsWith('.d.ts')) return notFound(`unpkg ${pathname}`); + return text(readData(pathname.includes('/electron') ? 'electron.d.ts' : 'node.d.ts')); +} + +function npm(parts: string[]): Reply { + const name = decodeURIComponent(parts.join('/')); + if (!name) return notFound('npm registry root'); + const version = (v: string) => ({ name, version: v, dist: { tarball: '' } }); + return json({ + name, + 'dist-tags': { latest: '1.1.0' }, + versions: { '1.0.0': version('1.0.0'), '1.1.0': version('1.1.0') }, + }); +} + +function minimalRepro(parts: string[]): Reply { + const branch = parts[1]?.replace(/\.zip$/, ''); + if (parts[0] !== 'archive' || !branch) + return notFound(`minimal-repro ${parts.join('/')}`); + const dir = path.join(DATA, 'minimal-repro'); + const files = Object.fromEntries( + fs + .readdirSync(dir) + .map((name) => [ + `minimal-repro-${branch}/${name}`, + fs.readFileSync(path.join(dir, name)), + ]), + ); + return { status: 200, type: 'application/zip', body: makeZip(files) }; +} + +function route(method: string, url: URL, body: string, base: string): Reply { + const [top = '', ...parts] = url.pathname.split('/').filter(Boolean); + switch (top) { + case 'releases.json': + return text(readData('releases.json'), 200, 'application/json'); + case 'electron-mirror': + case 'nightly-mirror': + return electronMirror(parts[0] ?? '', parts.slice(1).join('/')); + case 'github-api': + return github(method, parts, body, base); + case 'gist-raw': + return gistRaw(parts, base); + case 'unpkg': + return unpkg(url.pathname, url.search); + case 'algolia': { + const result = JSON.parse(readData('npm-search.json')) as unknown; + return json(url.pathname.endsWith('/queries') ? { results: [result] } : result); + } + case 'npm': + return npm(parts); + case 'minimal-repro': + return minimalRepro(parts); + default: + return notFound(url.pathname); + } +} + +export async function startFixtureServer(): Promise { + const requests: FixtureRequest[] = []; + let base = ''; + const server = http.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + const method = request.method ?? 'GET'; + const url = new URL(request.url ?? '/', base); + let reply: Reply; + try { + reply = route(method, url, body, base); + } catch (error) { + reply = text(`fixture server error: ${String(error)}\n`, 500); + } + const status = 'file' in reply ? 200 : reply.status; + requests.push({ + method, + path: url.pathname + url.search, + status, + ...(body ? { body } : {}), + }); + response.setHeader('Access-Control-Allow-Origin', '*'); + if ('file' in reply) { + response.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Length': fs.statSync(reply.file).size, + }); + fs.createReadStream(reply.file).pipe(response); + return; + } + response.writeHead(reply.status, { 'Content-Type': reply.type, ...reply.headers }); + response.end(reply.body); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('fixture server has no port'); + base = `http://127.0.0.1:${address.port}`; + return { + url: base, + requests, + close: () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + }; +} diff --git a/packages/app/e2e/fixtures/zip.ts b/packages/app/e2e/fixtures/zip.ts new file mode 100644 index 0000000000..98cf1b61f0 --- /dev/null +++ b/packages/app/e2e/fixtures/zip.ts @@ -0,0 +1,57 @@ +/** A minimal uncompressed (STORE) zip writer, enough for template fixtures. */ +import zlib from 'node:zlib'; + +export function makeZip(files: Record): Buffer { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + const DOS_DATE = 0x21; // 1980-01-01, so the output is reproducible. + + for (const [name, content] of Object.entries(files)) { + const data = Buffer.isBuffer(content) ? content : Buffer.from(content); + const fileName = Buffer.from(name); + const crc = zlib.crc32(data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0x0800, 6); // UTF-8 names + local.writeUInt16LE(0, 8); // STORE + local.writeUInt16LE(0, 10); // time + local.writeUInt16LE(DOS_DATE, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(fileName.length, 26); + local.writeUInt16LE(0, 28); + locals.push(local, fileName, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(0x031e, 4); // made by: Unix, 3.0 + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(0, 12); + central.writeUInt16LE(DOS_DATE, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(fileName.length, 28); + central.writeUInt32LE((0o100644 << 16) >>> 0, 38); // -rw-r--r-- + central.writeUInt32LE(offset, 42); + centrals.push(central, fileName); + + offset += local.length + fileName.length + data.length; + } + + const directory = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + const count = Object.keys(files).length; + end.writeUInt16LE(count, 8); + end.writeUInt16LE(count, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, directory, end]); +} diff --git a/packages/app/e2e/gists.e2e.ts b/packages/app/e2e/gists.e2e.ts new file mode 100644 index 0000000000..2d916d9a31 --- /dev/null +++ b/packages/app/e2e/gists.e2e.ts @@ -0,0 +1,258 @@ +// Gists against the fixture GitHub API: open by URL, sign in, publish, +// update, revision history, delete and sign out. +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + appState, + FIXTURE_GIST_ID, + makeFolder, + openedUrls, + role, + text, + useApp, + windowState, +} from './harness.ts'; + +const TOKEN = `ghp_${'a1B2'.repeat(9)}`; +// data/gists/.json: the newest revision, an empty one (hidden) and the first. +const LATEST_REVISION = '3333333333333333333333333333333333333333'; +const FIRST_REVISION = '1111111111111111111111111111111111111111'; + +describe('gists', () => { + const app = useApp(); + const fiddle = async () => (await windowState(app())).fiddle; + const requests = (method: string, prefix: string) => + app + .fixtures() + .requests.filter((r) => r.method === method && r.path.startsWith(prefix)); + const openGist = async (value: string) => { + await app().runCommand('gist.open'); + await app().query(role('dialog', 'Open a gist')); + // Once a gist link is on the clipboard the field offers it, selected; select all so typing replaces whatever is there. + await app().press('CmdOrCtrl+A', role('textbox', 'Gist URL or ID')); + await app().type(value); + }; + /** The open-gist field's value and selection. */ + const openGistField = () => + app().evaluate( + `(() => { + const input = [...document.querySelectorAll('input')].find((i) => i.labels?.[0]?.textContent?.includes('Gist URL or ID')); + return input ? [input.value, input.selectionStart, input.selectionEnd] : null; + })()`, + 0, + ); + const gistMenu = async (item: string) => { + await app().click(role('button', 'Publish')); + await app().click(role('menuitem', item)); + }; + + it('refuses text that is not a gist, and opens a gist by URL while signed out', async () => { + await openGist('not a gist'); + await app().press('Tab'); + await app().query(text('Enter a gist URL or a 32-character gist ID.')); + await app().press('CmdOrCtrl+A', role('textbox', 'Gist URL or ID')); + await app().type( + `https://gist.github.com/fiddle-e2e/${FIXTURE_GIST_ID}#file-main-js`, + ); + await app().click(role('button', 'Open')); + await app().waitForAbsent(role('dialog', 'Open a gist')); + + await expect.poll(async () => (await fiddle()).source.gistId).toBe(FIXTURE_GIST_ID); + const loaded = await fiddle(); + expect(loaded.source).toMatchObject({ + gistOwner: 'fiddle-e2e', + gistRevision: LATEST_REVISION, + }); + // A gist has no folder, so the project gets a random three-word name. + expect(loaded.name).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/); + await app().query(role('tab', 'renderer.js')); + expect((await appState(app())).githubLogin).toBeUndefined(); + }); + + it('shows the URL of the loaded gist', async () => { + await app().runCommand('gist.open'); + await app().query( + text(new RegExp(`^Loaded now: https://gist\\.github\\.com/.*${FIXTURE_GIST_ID}$`)), + ); + await app().click(role('button', 'Cancel')); + await app().waitForAbsent(role('dialog', 'Open a gist')); + }); + + it('unlinks the gist when the fiddle is saved to a new folder', async () => { + const dir = path.join(makeFolder(app(), 'gist-parent'), 'from-gist'); + await app().queueDialog('open', { filePaths: [dir] }); + await app().runCommand('file.saveAs'); + await expect.poll(async () => (await fiddle()).source.localPath).toBe(dir); + expect((await fiddle()).source.gistId).toBeUndefined(); + }); + + it('asks for a token before publishing, and checks it', async () => { + await app().click(role('button', 'Publish')); + await app().query(role('dialog', 'Sign in to GitHub')); + await app().type('not-a-token', role('textbox', 'Personal access token')); + await app().click(role('button', 'Sign in')); + await app().query( + text( + "That doesn't look like a GitHub token. Tokens start with ghp_ or github_pat_.", + ), + ); + + // Links ask before opening in the browser. + await app().queueDialog('messageBox', { button: 'Open link' }); + await app().click(role('button', 'Create a token on GitHub')); + await expect + .poll(() => openedUrls(app())) + .toContainEqual( + expect.stringMatching( + /^https:\/\/github\.com\/settings\/tokens\/new\?.*scopes=gist/, + ), + ); + + await app().press('CmdOrCtrl+A', role('textbox', 'Personal access token')); + await app().type(TOKEN); + await app().click(role('button', 'Sign in')); + await app().query(text('Signed in to GitHub as fiddle-e2e')); + await expect.poll(async () => (await appState(app())).githubLogin).toBe('fiddle-e2e'); + // Renderers only ever see the login. + expect(JSON.stringify(await app().stores(0))).not.toContain(TOKEN); + }); + + it('publishes with a description and visibility, then offers the link', async () => { + // Signing in continues the publish that asked for it. + await app().query(role('dialog', 'Publish to GitHub')); + expect( + await app().evaluate( + `[...document.querySelectorAll('input')].find((input) => input.labels?.[0]?.textContent?.includes('Description'))?.value`, + 0, + ), + ).toBe('Electron Fiddle Gist'); + + await app().press('CmdOrCtrl+A', role('textbox', 'Description')); + await app().press('Backspace'); + await app().query(text('Enter 1 to 256 characters.')); + await app().type('My e2e gist', role('textbox', 'Description')); + // The radio input sits under its label, so click the label's text. + await app().click(text(/^Public$/)); + await app().click(role('button', 'Publish')); + await app().query(text('Published to GitHub')); + + const [create] = requests('POST', '/github-api/gists'); + expect(JSON.parse(create?.body ?? '{}')).toMatchObject({ + description: 'My e2e gist', + public: true, + }); + // As a revision: created from the template, then updated with the fiddle's files. + await expect.poll(() => requests('PATCH', '/github-api/gists/').length).toBe(1); + + const published = await fiddle(); + expect(published.source.gistId).toMatch(/^[0-9a-f]{32}$/); + expect(published.source.localPath).toBeUndefined(); + expect((await appState(app())).settings.gistVisibility).toBe('public'); + + await app().click(role('button', 'Copy link')); + await expect.poll(() => app().clipboard()).toContain(published.source.gistId ?? '?'); + }); + + it('copies an https share link for the gist', async () => { + const { gistId } = (await fiddle()).source; + await gistMenu('Copy share link'); + await expect + .poll(() => app().clipboard()) + .toMatch(new RegExp(`^https://\\S+${gistId}`)); + }); + + it('opens the gist dialog with its shortcut, from the gist menu and from the title bar, offering a gist link on the clipboard', async () => { + const closeDialog = async () => { + await app().click(role('button', 'Cancel')); + await app().waitForAbsent(role('dialog', 'Open a gist')); + }; + await app().press('CmdOrCtrl+Shift+O'); + await app().query(role('dialog', 'Open a gist')); + await closeDialog(); + await gistMenu('Open gist…'); + await app().query(role('dialog', 'Open a gist')); + await closeDialog(); + + // The share link copied above is a gist URL, so the field offers it, selected. + const link = await app().clipboard(); + expect(link).toContain((await fiddle()).source.gistId); + await app().click(role('button', 'Open gist…')); + await app().query(role('dialog', 'Open a gist')); + await expect.poll(openGistField).toEqual([link, 0, link.length]); + await closeDialog(); + }); + + it('updates the gist with the changed files', async () => { + const { gistId } = (await fiddle()).source; + await app().click(role('tab', 'renderer.js')); + await app().click(role('code')); + await app().type('// updated'); + await expect.poll(async () => (await fiddle()).dirty).toBe(true); + + await gistMenu('Update gist'); + await app().query(text('Gist updated')); + const update = requests('PATCH', `/github-api/gists/${gistId}`).at(-1); + const sent = JSON.parse(update?.body ?? '{}') as { + files?: Record; + }; + expect(sent.files?.['renderer.js']?.content).toContain('// updated'); + await expect.poll(async () => (await fiddle()).dirty).toBe(false); + }); + + it('lists the revisions of a gist and loads one', async () => { + await openGist(FIXTURE_GIST_ID); + await app().click(role('button', 'Open')); + await expect.poll(async () => (await fiddle()).source.gistId).toBe(FIXTURE_GIST_ID); + + await app().runCommand('gist.history'); + await app().query(role('dialog', 'Gist history')); + await app().query(text('Created')); + await app().query(text('Active')); + // Three revisions, but the middle one changed nothing, so it isn't listed. + const snapshot = await app().snapshot(0); + expect(snapshot.match(/StaticText "Revision \d+"/g)).toHaveLength(1); + + await app().click(text('Created')); + await expect + .poll(async () => (await fiddle()).source.gistRevision) + .toBe(FIRST_REVISION); + expect(app.fixtures().requests.map((r) => r.path)).toContain( + `/github-api/gists/${FIXTURE_GIST_ID}/${FIRST_REVISION}`, + ); + }); + + it('deletes the gist and marks the fiddle unsaved', async () => { + // Loading a revision closes the history. + await app().waitForAbsent(role('dialog', 'Gist history')); + await gistMenu('Delete gist'); + await app().query(role('alertdialog', 'Delete this gist?')); + await app().click(role('button', 'Delete')); + await app().query(text('Gist deleted')); + expect(requests('DELETE', `/github-api/gists/${FIXTURE_GIST_ID}`)).toHaveLength(1); + await expect.poll(async () => (await fiddle()).dirty).toBe(true); + }); + + it('signs out from the settings, and forgets the stored token', async () => { + // Whether sign-in stored a token depends on the OS keyring, so put one where the app keeps + // it: under the mock keychain of macOS test runs, a file of its own. + const stored = path.join( + app().testDir ?? '', + 'userData/credentials', + process.platform === 'darwin' ? 'github-dev' : 'github', + ); + fs.mkdirSync(path.dirname(stored), { recursive: true }); + fs.writeFileSync(stored, 'stored token'); + await app().press('CmdOrCtrl+,'); + await app().click(role('button', 'GitHub')); + await app().query(text('Signed in as fiddle-e2e')); + await app().click(role('button', 'Sign out')); + await app().query(text('Not signed in')); + await expect.poll(async () => (await appState(app())).githubLogin).toBeUndefined(); + await expect.poll(() => fs.existsSync(stored)).toBe(false); + // The fiddle is unsaved: the harness quits after the last test. + await app().queueDialog('messageBox', { button: 'Quit' }); + }); +}); diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts new file mode 100644 index 0000000000..4d66da11f8 --- /dev/null +++ b/packages/app/e2e/global-setup.ts @@ -0,0 +1,8 @@ +// Builds the test build once per `yarn test:e2e` run. +// FIDDLE_E2E_SKIP_BUILD=1 reuses out/test-build as it is. +import { buildTestApp } from '../tools/driver-build.ts'; + +export default async function setup(): Promise { + if (process.env.FIDDLE_E2E_SKIP_BUILD === '1') return; + await buildTestApp(); +} diff --git a/packages/app/e2e/harness.ts b/packages/app/e2e/harness.ts new file mode 100644 index 0000000000..91c4920660 --- /dev/null +++ b/packages/app/e2e/harness.ts @@ -0,0 +1,106 @@ +/** Vitest glue for specs. See ./README.md. */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterAll, beforeAll, beforeEach } from 'vitest'; + +import type { AppState, WindowState } from '../src/shared/stores.ts'; +import { launchApp, type FiddleApp, type LaunchOptions } from './driver.ts'; +import { startFixtureServer, type FixtureServer } from './fixtures/server.ts'; + +export { FIXTURE_GIST_ID, role, text, type FiddleApp } from './driver.ts'; +export type { AppState, WindowState }; + +export interface AppHandle { + (): FiddleApp; + /** The app's fixture server, for asserting on the requests it received. */ + fixtures(): FixtureServer; +} + +/** + * One app per spec file, launched before the first test and closed after the last. A failing test + * prints diagnostics and keeps the temp dir; an isolation violation fails the file. + */ +export function useApp(options: Omit = {}): AppHandle { + let app: FiddleApp | undefined; + let fixtures: FixtureServer | undefined; + + beforeAll(async () => { + fixtures = await startFixtureServer(); + app = await launchApp({ ...options, fixtures }); + }); + + beforeEach((context) => { + context.onTestFailed(async () => { + if (!app) return; + app.markFailed(); + console.error(await app.report(context.task.name)); + }); + }); + + afterAll(async () => { + try { + await app?.assertClean(); + } finally { + await app?.close(); + await fixtures?.close(); + } + }); + + const get = () => { + if (!app) + throw new Error('useApp(): the app is only available inside tests and hooks'); + return app; + }; + return Object.assign(get, { + fixtures: () => { + if (!fixtures) + throw new Error('useApp(): fixtures are only available inside tests and hooks'); + return fixtures; + }, + }); +} + +/** The Window store of `window` (default: the first window). */ +export async function windowState( + app: FiddleApp, + window: number | string = 0, +): Promise { + return (await app.stores(window)).window as WindowState; +} + +export async function appState(app: FiddleApp): Promise { + return (await app.stores(0)).app as AppState; +} + +/** A folder in the app's temp dir (removed with it), holding `files` (name → content). */ +export function makeFolder( + app: FiddleApp, + name: string, + files: Record = {}, +): string { + const dir = path.join(app.testDir ?? os.tmpdir(), 'work', name); + fs.mkdirSync(dir, { recursive: true }); + for (const [file, content] of Object.entries(files)) + fs.writeFileSync(path.join(dir, file), content); + return dir; +} + +export function readJson>(file: string): T { + return JSON.parse(fs.readFileSync(file, 'utf8')) as T; +} + +/** What the app recorded for native dialogs, newest last. */ +export async function dialogMessages(app: FiddleApp): Promise { + return (await app.dialogs()).map((dialog) => + String(dialog.options.message ?? dialog.options.title ?? ''), + ); +} + +/** URLs passed to `shell.openExternal`. */ +export async function openedUrls(app: FiddleApp): Promise { + return (await app.sideEffects()) + .filter((effect) => effect.kind === 'shell.openExternal') + .map((effect) => String(effect.args[0])); +} diff --git a/packages/app/e2e/isolation.e2e.ts b/packages/app/e2e/isolation.e2e.ts new file mode 100644 index 0000000000..623035d4eb --- /dev/null +++ b/packages/app/e2e/isolation.e2e.ts @@ -0,0 +1,53 @@ +// Test mode's isolation: no real network, fixed locale and time zone, temp dirs. +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { launchApp, type FiddleApp } from './driver.ts'; +import { startFixtureServer, type FixtureServer } from './fixtures/server.ts'; + +describe('isolation', () => { + let fixtures: FixtureServer; + let app: FiddleApp; + + beforeAll(async () => { + fixtures = await startFixtureServer(); + app = await launchApp({ fixtures }); + }); + + afterAll(async () => { + await app?.close(); + await fixtures?.close(); + }); + + it('serves loopback fixtures and blocks every other host, from Node and Chromium', async () => { + expect(await app.mainFetch(`${fixtures.url}/releases.json`)).toEqual({ + status: 200, + }); + expect(fixtures.requests).toContainEqual( + expect.objectContaining({ method: 'GET', path: '/releases.json', status: 200 }), + ); + + expect(await app.mainFetch('https://example.com/')).toHaveProperty('error'); + expect(await app.mainFetch('https://example.org/', 'net')).toHaveProperty('error'); + const violations = await app.violations(); + expect(violations).toEqual( + expect.arrayContaining([ + expect.stringContaining('https://example.com/'), + expect.stringContaining('https://example.org/'), + ]), + ); + }); + + it('fixes the locale and time zone in the renderer', async () => { + expect( + await app.evaluate( + '[navigator.language, Intl.DateTimeFormat().resolvedOptions().timeZone]', + ), + ).toEqual(['en-US', 'UTC']); + }); + + it('keeps userData in the test dir', async () => { + expect(app.testDir).toBeTruthy(); + const logs = await app.logs(); + expect(logs.main.join('\n')).toContain('driver listening'); + }); +}); diff --git a/packages/app/e2e/menubar.e2e.ts b/packages/app/e2e/menubar.e2e.ts new file mode 100644 index 0000000000..a55f5ea984 --- /dev/null +++ b/packages/app/e2e/menubar.e2e.ts @@ -0,0 +1,176 @@ +// The title bar's menu bar on Windows and Linux. FIDDLE_TEST_MENUBAR forces it on +// every platform. At the default width the last titles may be folded into the +// More button, so tests drive File, Edit and View and read the whole list from +// the Window store. +import { beforeAll, describe, expect, it } from 'vitest'; + +import { role, text, useApp, windowState } from './harness.ts'; + +describe('menu bar', () => { + const app = useApp({ env: { FIDDLE_TEST_MENUBAR: '1' } }); + // The titles refold when the toolbar capsule grows with its version label, and a + // refold closes an open menu, so wait for the version to resolve first. + beforeAll(async () => { + await app().query(text('Ready to run', { timeout: 20_000 })); + }); + const states = async (roleName: string, name: string) => + (await app().query(role(roleName, name)))[0]?.states ?? []; + /** What has focus: its role, its accessible label or text, and whether it's in a code editor. */ + const focused = () => + app().evaluate(`(() => { + const element = document.activeElement; + if (!element) return null; + const labelledBy = document.getElementById(element.getAttribute('aria-labelledby') ?? ''); + return { + role: element.getAttribute('role'), + name: element.getAttribute('aria-label') ?? (labelledBy ?? element).textContent.trim(), + inEditor: element.closest('.monaco-editor') !== null, + }; + })()`) as Promise<{ role: string | null; name: string; inEditor: boolean } | null>; + + /** The menus main built for the window, from its store. */ + const menuBar = async () => (await windowState(app())).menuBar ?? []; + const menuLabels = async () => + (await menuBar()).flatMap((menu) => (menu.kind === 'submenu' ? [menu.label] : [])); + /** The bar's titles while no menu is open: every menu, or the ones that fit and More. */ + const barTitles = async () => + (await app().query(role('menuitem'))).map((item) => item.name); + /** Opens a menu from its title, or from the More button when it is folded there. */ + const openMenu = async (label: string) => { + if (!(await barTitles()).includes(label)) await app().click(role('menuitem', 'More')); + await app().click(role('menuitem', label)); + }; + + it('shows every menu in the title bar', async () => { + await app().query(role('menubar', 'Application menu')); + // Test builds are unpackaged, so they have the Develop menu too. + expect(await menuBar()).toMatchObject([ + { kind: 'submenu', id: 'menu:file', label: 'File' }, + { kind: 'submenu', id: 'menu:edit', label: 'Edit' }, + { kind: 'submenu', id: 'menu:view', label: 'View' }, + { kind: 'submenu', id: 'menu:run', label: 'Run' }, + { kind: 'submenu', id: 'menu:window', label: 'Window' }, + { kind: 'submenu', id: 'menu:develop', label: 'Develop' }, + { kind: 'submenu', id: 'menu:help', label: 'Help' }, + ]); + const labels = await menuLabels(); + // The bar shows them in order; what doesn't fit before the capsule is folded into More. + const titles = await barTitles(); + if (titles.at(-1) === 'More') { + expect(titles.length).toBeGreaterThanOrEqual(3); + expect(titles.slice(0, -1)).toEqual(labels.slice(0, titles.length - 1)); + } else { + expect(titles).toEqual(labels); + } + expect(titles.slice(0, 3)).toEqual(['File', 'Edit', 'View']); + }); + + it('opens a folded menu from the More button, when the window is too narrow for every title', async ({ + skip, + }) => { + const titles = await barTitles(); + if (titles.at(-1) !== 'More') + skip('every title fits at this width, so nothing is folded'); + const folded = (await menuLabels()).slice(titles.length - 1); + expect(folded.length).toBeGreaterThan(0); + await app().click(role('menuitem', 'More')); + await app().query(role('menu', 'More')); + expect(await states('menuitem', 'More')).toContain('expanded'); + for (const label of folded) await app().query(role('menuitem', label)); + // A folded menu is a submenu there: Right opens it on its first item, Escape backs out. + await app().press('ArrowDown'); + await expect.poll(focused).toMatchObject({ role: 'menuitem', name: folded[0] }); + await app().press('ArrowRight'); + await app().query(role('menu', folded[0])); + await app().press('Escape'); + await app().waitForAbsent(role('menu', folded[0])); + await app().press('Escape'); + await app().waitForAbsent(role('menu', 'More')); + await app().press('Escape'); + expect(await states('menuitem', 'More')).not.toContain('focused'); + }); + + it('opens File with its items, keys and disabled entries, and closes it on Escape', async () => { + await app().click(role('menuitem', 'File')); + await app().query(role('menu', 'File')); + expect(await states('menuitem', 'File')).toContain('expanded'); + await app().query(role('menuitem', 'New fiddle')); + await app().query(text('Ctrl+N')); + await app().query(role('menuitem', 'Open recent')); + await app().query(role('menuitem', 'Settings…')); + // No gist is loaded, so its history has nothing to show. + expect(await states('menuitem', 'Show gist history…')).toContain('disabled'); + expect(await states('menuitem', 'Save')).not.toContain('disabled'); + // Escape closes the menu and keeps the bar; a second Escape lets go. + await app().press('Escape'); + await app().waitForAbsent(role('menu', 'File')); + expect(await states('menuitem', 'File')).toContain('focused'); + await app().press('Escape'); + expect(await states('menuitem', 'File')).not.toContain('focused'); + }); + + it('runs what is chosen, with labels that follow the window', async () => { + await openMenu('View'); + await app().click(role('menuitem', 'Hide sidebar')); + await app().waitForAbsent(role('menu', 'View')); + await expect.poll(async () => (await windowState(app())).layout.sidebar).toBe(false); + await app().waitForAbsent(role('navigation', 'Files')); + await openMenu('View'); + await app().click(role('menuitem', 'Show sidebar')); + await expect.poll(async () => (await windowState(app())).layout.sidebar).toBe(true); + await app().query(role('navigation', 'Files')); + }); + + it('runs role items itself: Zoom in and Actual size', async () => { + const ratio = async () => Number(await app().evaluate('window.devicePixelRatio')); + const initial = await ratio(); + await openMenu('View'); + await app().click(role('menuitem', 'Zoom in')); + await expect.poll(ratio).toBeGreaterThan(initial); + // The narrower bar may now fold View into More: wait until its titles stop changing. + let last = ''; + await expect + .poll(async () => { + const previous = last; + last = (await barTitles()).join(); + return last === previous; + }) + .toBe(true); + await openMenu('View'); + await app().click(role('menuitem', 'Actual size')); + await expect.poll(ratio).toBe(initial); + }); + + it('gives editing items back the focus they act on: Select all', async () => { + await app().click(role('code')); + await app().click(role('menuitem', 'Edit')); + await app().click(role('menuitem', 'Select all')); + await expect.poll(async () => (await focused())?.inEditor).toBe(true); + }); + + it('takes the keyboard on Alt or Alt and a mnemonic, and gives it back on Escape', async () => { + await app().click(role('code')); + await expect.poll(async () => (await focused())?.inEditor).toBe(true); + await app().press('Alt'); + await expect.poll(focused).toMatchObject({ role: 'menuitem', name: 'File' }); + await app().press('ArrowRight'); + await expect.poll(focused).toMatchObject({ role: 'menuitem', name: 'Edit' }); + await app().press('ArrowDown'); + await app().query(role('menu', 'Edit')); + await expect.poll(focused).toMatchObject({ role: 'menuitem', name: 'Undo' }); + await app().press('Escape'); + await app().press('Escape'); + await expect.poll(async () => (await focused())?.inEditor).toBe(true); + // Alt+H opens Help on its first item: in place, or inside More when it's folded there. + await app().press('Alt+h'); + await app().query(role('menu', 'Help')); + await expect + .poll(focused) + .toMatchObject({ role: 'menuitem', name: 'Show welcome tour' }); + // Escape backs out a level at a time (submenu, menu, bar), then gives the editor its focus back. + for (let level = 0; level < 3 && !(await focused())?.inEditor; level++) + await app().press('Escape'); + await app().waitForAbsent(role('menu', 'Help')); + await expect.poll(async () => (await focused())?.inEditor).toBe(true); + }); +}); diff --git a/packages/app/e2e/packages.e2e.ts b/packages/app/e2e/packages.e2e.ts new file mode 100644 index 0000000000..97de504dcf --- /dev/null +++ b/packages/app/e2e/packages.e2e.ts @@ -0,0 +1,58 @@ +// npm packages: search the fixture Algolia index, add, change, remove and save. +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { makeFolder, readJson, role, useApp, windowState } from './harness.ts'; + +describe('packages', () => { + const app = useApp(); + const modules = async () => (await windowState(app())).fiddle.modules; + /** Adding a package closes the suggestions. */ + const suggestionsClosed = () => app().waitForAbsent(role('listbox', 'Suggestions')); + const add = async (query: string, name: string) => { + await app().type(query, role('combobox', 'Add a package from npm')); + await app().click(role('option', new RegExp(`^${name}\\b`))); + await suggestionsClosed(); + }; + + it('searches npm and adds the chosen package at its latest version', async () => { + await app().type('left', role('combobox', 'Add a package from npm')); + await app().query(role('option', /^left-pad\b/)); + // Debounced: one search for the pause, not one per key. + const searches = app + .fixtures() + .requests.filter((r) => r.path.startsWith('/algolia/')); + expect(searches.length).toBeGreaterThan(0); + expect(searches.length).toBeLessThan(4); + + await app().click(role('option', /^left-pad\b/)); + await expect.poll(modules).toEqual({ 'left-pad': '1.1.0' }); + await suggestionsClosed(); + await app().query(role('list', 'Packages in this fiddle')); + expect((await windowState(app())).fiddle.dirty).toBe(true); + }); + + it('changes a package version and removes the package', async () => { + await app().click(role('button', /Version of left-pad/)); + await app().click(role('option', /^1\.0\.0\b/)); + await expect.poll(modules).toEqual({ 'left-pad': '1.0.0' }); + await app().click(role('button', 'Remove left-pad')); + await expect.poll(modules).toEqual({}); + }); + + it('saves packages as dependencies', async () => { + await add('lodash', 'lodash'); + await expect.poll(modules).toEqual({ lodash: '1.1.0' }); + const dir = path.join(makeFolder(app(), 'packages-parent'), 'with-lodash'); + await app().queueDialog('open', { filePaths: [dir] }); + await app().press('CmdOrCtrl+S'); + await expect.poll(async () => (await windowState(app())).fiddle.dirty).toBe(false); + expect( + readJson<{ dependencies?: Record }>(path.join(dir, 'package.json')) + .dependencies, + ).toEqual({ + lodash: '1.1.0', + }); + }); +}); diff --git a/packages/app/e2e/run.e2e.ts b/packages/app/e2e/run.e2e.ts new file mode 100644 index 0000000000..f1166570b0 --- /dev/null +++ b/packages/app/e2e/run.e2e.ts @@ -0,0 +1,63 @@ +// Running the default template. Electron comes from the fixture server's +// mirror, which serves the locally cached zip, so nothing touches the network. +import { describe, expect, it } from 'vitest'; + +import { role, text, useApp, windowState } from './harness.ts'; + +describe('run', () => { + const app = useApp(); + + it('downloads Electron from the mirror and runs the default template', async () => { + const [picker] = await app().query(role('button', /^Electron \d+\.\d+\.\d+/)); + const version = /Electron (\S+)/.exec(picker?.name ?? '')?.[1]; + expect(version).toBeTruthy(); + + await app().click(role('button', 'Run')); + const zip = `electron-v${version}-${process.platform}-${process.arch}.zip`; + await expect + .poll(() => app.fixtures().requests.map((r) => `${r.status} ${r.path}`), { + timeout: 30_000, + }) + .toContain(`200 /electron-mirror/v${version}/${zip}`); + + // Running flips the button to Stop; stopping flips it back. The inspector line shows the + // fiddle's main process came up: a Chromium start-up abort (no usable sandbox) ends the run first. + await app().query(role('button', 'Stop', { timeout: 60_000 })); + await app().query(text(/^Inspector listening on /, { timeout: 60_000 })); + await app().click(role('button', 'Stop')); + await app().query(role('button', 'Run', { timeout: 15_000 })); + }, 120_000); + + it('reports the start and the end of the run in the console', async () => { + // The version and the app name, then how it ended. + await app().query(text(/^Electron v\d+\.\d+\.\d+ started as ".+"$/)); + await app().query(text(/^Electron (was stopped by SIG\w+|exited with code -?\d+)$/)); + expect((await windowState(app())).run?.status ?? 'ready').toBe('ready'); + }); + + it('clears the console with CmdOrCtrl+K while it has focus', async () => { + const before = (await windowState(app())).run?.clearedSeq ?? 0; + await app().press('CmdOrCtrl+K', role('textbox', 'Filter output')); + await expect + .poll(async () => (await windowState(app())).run?.clearedSeq ?? 0) + .toBeGreaterThan(before); + await app().waitForAbsent(text(/^Electron v\d+\.\d+\.\d+ started as/)); + }); + + it('runs with F5, reopening a hidden console, and stops with CmdOrCtrl+R', async () => { + await app().runCommand('view.toggleConsole'); + await app().waitForAbsent(role('region', 'Console')); + await app().press('F5'); + await app().query(role('button', 'Stop', { timeout: 60_000 })); + await app().query(role('region', 'Console')); + await app().query(text(/^Inspector listening on /, { timeout: 60_000 })); + await app().press('CmdOrCtrl+R'); + await app().query(role('button', 'Run', { timeout: 15_000 })); + }, 90_000); + + it('clears the console from its button', async () => { + await app().query(text(/^Electron v\d+\.\d+\.\d+ started as/)); + await app().click(role('button', 'Clear console')); + await app().waitForAbsent(text(/^Electron v\d+\.\d+\.\d+ started as/)); + }); +}); diff --git a/packages/app/e2e/session.e2e.ts b/packages/app/e2e/session.e2e.ts new file mode 100644 index 0000000000..8db99be496 --- /dev/null +++ b/packages/app/e2e/session.e2e.ts @@ -0,0 +1,60 @@ +// Session restore across a relaunch: the second app reuses the first one's +// userData (FIDDLE_TEST_DIR), so it sees what the first one saved on quit. +import { describe, expect, it } from 'vitest'; + +import { launchApp, removeTestDir, role, type FiddleApp } from './driver.ts'; +import { startFixtureServer } from './fixtures/server.ts'; +import { windowState } from './harness.ts'; + +describe('session', () => { + it('reopens every window with its fiddle, version and layout', async () => { + const fixtures = await startFixtureServer(); + let first: FiddleApp | undefined; + let second: FiddleApp | undefined; + try { + first = await launchApp({ fixtures, keepArtifacts: true }); + // Session restore is off by default. + await first.click(role('button', 'Settings')); + await first.click(role('switch', 'Restore windows on launch')); + await first.click(role('button', 'Close settings')); + await first.click(role('button', /^Electron 44\.3\.0\b/)); + await first.click(role('option', /^Electron 43\.7\.0\b/)); + await expect + .poll(async () => (await windowState(first!, 0)).fiddle.versionRef) + .toEqual({ kind: 'release', version: '43.7.0' }); + await first.click(role('button', 'Split editor')); + await expect + .poll(async () => (await windowState(first!, 0)).layout.panes) + .toHaveLength(2); + await first.runCommand('app.newWindow'); + await first.waitForWindow(1); + const before = [await windowState(first, 0), await windowState(first, 1)]; + // The version picked above is a full Electron download, which a busy Windows runner can take a while over. + await first.waitForIdle(60_000); + await first.assertClean(); + await first.close(); + + second = await launchApp({ + fixtures, + env: { FIDDLE_TEST_DIR: first.testDir ?? '' }, + }); + await second.waitForWindow(1); + const after = [await windowState(second, 0), await windowState(second, 1)]; + const summary = (states: typeof before) => + states + .map((s) => ({ + name: s.fiddle.name, + version: s.fiddle.versionRef, + panes: s.layout.panes, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + expect(summary(after)).toEqual(summary(before)); + await second.assertClean(); + } finally { + await second?.close(); + await first?.close(); + if (first?.testDir) removeTestDir(first.testDir); + await fixtures.close(); + } + }, 120_000); +}); diff --git a/packages/app/e2e/settings.e2e.ts b/packages/app/e2e/settings.e2e.ts new file mode 100644 index 0000000000..9aa2dc1e01 --- /dev/null +++ b/packages/app/e2e/settings.e2e.ts @@ -0,0 +1,261 @@ +// The settings page: defaults, changes, persistence, sync, reset, search, +// themes, and import and export. +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + appState, + makeFolder, + readJson, + role, + text, + useApp, + windowState, + type AppState, +} from './harness.ts'; + +describe('settings', () => { + const app = useApp(); + const settings = async () => (await appState(app())).settings; + const setting = (key: keyof AppState['settings']) => async () => + (await settings())[key]; + const open = async (section: string) => { + if ((await windowState(app())).view !== 'settings') + await app().click(role('button', 'Settings')); + await app().click(role('button', section)); + await app().query(role('region', section)); + }; + const states = async (query: Parameters['query']>[0]) => + (await app().query(query))[0]?.states ?? []; + const openedPaths = async () => + (await app().sideEffects()) + .filter((e) => e.kind === 'shell.openPath') + .map((e) => String(e.args[0])); + + it('starts with the documented defaults', async () => { + expect(await settings()).toMatchObject({ + appearance: 'system', + theme: 'lucent', + clearConsoleOnRun: false, + keepUserDataDirs: false, + electronLogging: false, + electronFlags: [], + environmentVariables: [], + packageManager: 'npm', + socketFirewall: true, + channels: ['stable', 'beta'], + showNotDownloaded: true, + showObsolete: false, + mirror: 'auto', + gistPublishAsRevision: true, + gistVisibility: 'secret', + gistShowHistory: true, + sessionRestore: false, + }); + await open('Execution'); + expect(await states(role('switch', 'Clear the console on every run'))).not.toContain( + 'checked', + ); + expect(await states(role('switch', 'Keep user data folders'))).not.toContain( + 'checked', + ); + expect(await states(role('switch', 'Advanced Electron logging'))).not.toContain( + 'checked', + ); + expect(await states(role('switch', 'Use Socket Firewall'))).toContain('checked'); + expect(await states(role('radio', 'npm'))).toContain('checked'); + }); + + it('saves a change to settings.json and syncs it to every window', async () => { + await open('Execution'); + await app().click(role('switch', 'Clear the console on every run')); + await expect.poll(setting('clearConsoleOnRun')).toBe(true); + + const file = path.join(app().testDir ?? '', 'userData', 'settings.json'); + await expect + .poll(() => fs.existsSync(file) && readJson(file).clearConsoleOnRun) + .toBe(true); + + await app().runCommand('app.newWindow'); + await app().waitForWindow(1); + expect(((await app().stores(1)).app as AppState).settings.clearConsoleOnRun).toBe( + true, + ); + await app().runCommand('file.close', 1); + await expect.poll(async () => (await app().windows()).length).toBe(1); + }); + + it('marks a changed value and resets it', async () => { + await open('Execution'); + expect(await app().snapshot(0)).toContain('image "Changed from the default"'); + await app().click(role('button', 'Reset Clear the console on every run')); + await expect.poll(setting('clearConsoleOnRun')).toBe(false); + expect(await app().snapshot(0)).not.toContain('Changed from the default'); + }); + + it('searches every section', async () => { + await open('Execution'); + await app().type('socket', role('textbox', 'Search settings')); + await app().query(role('switch', 'Use Socket Firewall')); + await app().waitForAbsent(role('radiogroup', 'Package manager')); + await app().press('CmdOrCtrl+A', role('textbox', 'Search settings')); + await app().press('Backspace'); + await app().query(role('radiogroup', 'Package manager')); + }); + + it('edits the execution settings', async () => { + await open('Execution'); + await app().click(role('radio', 'yarn')); + await expect.poll(setting('packageManager')).toBe('yarn'); + await app().click(role('radio', 'npm')); + await expect.poll(setting('packageManager')).toBe('npm'); + + await app().click(role('switch', 'Use Socket Firewall')); + await expect.poll(setting('socketFirewall')).toBe(false); + await app().click(role('switch', 'Keep user data folders')); + await expect.poll(setting('keepUserDataDirs')).toBe(true); + await app().click(role('switch', 'Advanced Electron logging')); + await expect.poll(setting('electronLogging')).toBe(true); + + await app().click(role('button', 'Add row', { nth: 0 })); + await app().type('--enable-logging', role('textbox', 'Electron flags')); + await app().press('Tab'); + await expect.poll(setting('electronFlags')).toEqual(['--enable-logging']); + + await app().click(role('button', 'Add row', { nth: 1 })); + await app().type('FOO=bar', role('textbox', 'Environment variables')); + await app().press('Tab'); + await expect.poll(setting('environmentVariables')).toEqual(['FOO=bar']); + }); + + it('edits the Electron settings', async () => { + await open('Electron'); + // The current version's channel can't be turned off. + // The version manager's filter has a "Stable" checkbox too, and query order isn't page order. + const stable = await app().query(role('checkbox', 'Stable')); + expect(stable.some((box) => box.states.includes('disabled'))).toBe(true); + await app().click(text(/^Nightly$/, { nth: 0 })); + await expect.poll(setting('channels')).toEqual(['stable', 'beta', 'nightly']); + + await app().click(role('switch', 'Show obsolete versions')); + await expect.poll(setting('showObsolete')).toBe(true); + + await app().click(role('switch', "Show versions that aren't downloaded")); + await expect.poll(setting('showNotDownloaded')).toBe(false); + // Nothing is downloaded, so the picker keeps only the current version. + await app().click(role('button', /^Electron \d+\.\d+\.\d+/)); + const options = (await app().query(role('option'))).map((option) => option.name); + expect(options.filter((name) => name.startsWith('Electron'))).toEqual([ + expect.stringMatching(/^Electron 44\.3\.0\b/), + ]); + await app().press('Escape'); + await app().waitForAbsent(role('listbox', 'Electron version')); + await app().click(role('switch', "Show versions that aren't downloaded")); + await expect.poll(setting('showNotDownloaded')).toBe(true); + + await app().click(text(/^China \(npmmirror\)$/)); + await expect.poll(setting('mirror')).toBe('china'); + await app().click(text(/^Custom$/)); + await app().query(role('textbox', 'Electron mirror URL')); + await app().query(role('textbox', 'Nightly mirror URL')); + await app().click(text(/^Automatic$/)); + await expect.poll(setting('mirror')).toBe('auto'); + }); + + it('edits the GitHub settings', async () => { + await open('GitHub'); + await app().click(role('switch', 'Publish as revision')); + await expect.poll(setting('gistPublishAsRevision')).toBe(false); + await app().click(role('switch', 'Show gist revision history')); + await expect.poll(setting('gistShowHistory')).toBe(false); + await app().click(role('radio', 'Public')); + await expect.poll(setting('gistVisibility')).toBe('public'); + await app().type('E2E Author', role('textbox', 'package.json author')); + await app().press('Tab'); + await expect.poll(setting('packageAuthor')).toBe('E2E Author'); + }); + + it('edits the editor font', async () => { + await open('Editor'); + await app().type('monospace', role('textbox', 'Font family')); + await app().press('Tab'); + await expect.poll(setting('editorFontFamily')).toBe('monospace'); + await app().type('16', role('textbox', 'Font size')); + await app().press('Tab'); + await expect.poll(setting('editorFontSize')).toBe(16); + }); + + it('switches between the light and dark themes and back to the system', async () => { + await open('General'); + const theme = () => + app().evaluate( + `[document.documentElement.dataset.theme ?? null, matchMedia('(prefers-color-scheme: dark)').matches]`, + 0, + ); + await app().click(role('radio', 'Dark')); + await expect.poll(theme).toEqual(['dark', true]); + await app().click(role('radio', 'Light')); + await expect.poll(theme).toEqual(['light', false]); + await app().click(role('radio', 'System')); + await expect.poll(async () => ((await theme()) as unknown[])[0]).toBe(null); + expect(await settings()).toMatchObject({ appearance: 'system' }); + }); + + it('creates and imports themes, and opens the themes folder', async () => { + await open('General'); + await app().click(role('button', 'Create from current')); + await expect.poll(async () => (await appState(app())).themes.length).toBe(1); + + const source = makeFolder(app(), 'theme-source', { + 'night.json': JSON.stringify({ + base: 'vs-dark', + inherit: true, + rules: [], + colors: { 'editor.background': '#101010' }, + }), + }); + await app().queueDialog('open', { filePaths: [path.join(source, 'night.json')] }); + await app().click(role('button', 'Import Monaco theme…')); + await expect.poll(async () => (await appState(app())).themes.length).toBe(2); + + await app().click(role('button', 'Open themes folder')); + await expect.poll(openedPaths).toContainEqual(expect.stringMatching(/themes$/)); + }); + + it('opens settings.json', async () => { + await app().click(role('button', 'Open settings.json')); + await expect + .poll(openedPaths) + .toContainEqual(expect.stringMatching(/settings\.json$/)); + }); + + it('exports and imports settings', async () => { + const dir = makeFolder(app(), 'settings-io'); + const exported = path.join(dir, 'exported.json'); + await app().queueDialog('save', { filePath: exported }); + await app().click(role('button', 'Export…')); + // The file exists before it's written (a plain writeFile), so wait for the content. + await expect + .poll(() => (fs.existsSync(exported) ? fs.readFileSync(exported, 'utf8') : '')) + .toContain('E2E Author'); + + const imported = path.join(dir, 'imported.json'); + fs.writeFileSync( + imported, + fs.readFileSync(exported, 'utf8').replace('E2E Author', 'Imported Author'), + ); + await app().queueDialog('open', { filePaths: [imported] }); + // Flags and variables change how fiddles run, so importing them asks first. + await app().queueDialog('messageBox', { button: 'Import' }); + await app().click(role('button', 'Import…')); + await expect.poll(setting('packageAuthor')).toBe('Imported Author'); + }); + + it('lists the contributors', async () => { + await open('About and credits'); + await app().query(role('heading', 'Contributors')); + await app().query(text(/^\d+ contributions?$/, { nth: 0 })); + }); +}); diff --git a/packages/app/e2e/smoke.e2e.ts b/packages/app/e2e/smoke.e2e.ts new file mode 100644 index 0000000000..9f1067d664 --- /dev/null +++ b/packages/app/e2e/smoke.e2e.ts @@ -0,0 +1,72 @@ +// Smoke specs: the app launches, and every driver capability works end to end. +import fs from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { role, useApp } from './harness.ts'; + +describe('smoke', () => { + const app = useApp(); + + it('launches and shows its window', async () => { + const [win] = await app().windows(); + expect(win).toMatchObject({ index: 0, visible: true }); + expect(win?.url).toBe('app://main/index.html'); + }); + + it('shows the title bar controls in the accessibility snapshot', async () => { + await app().query(role('button', 'Settings')); + const snapshot = await app().snapshot(); + // The document is named after the fiddle; the title bar is the banner. + expect(snapshot).toMatch(/^RootWebArea "[^"]+"/); + expect(snapshot).toMatch(/\n {2}banner\n/); + expect(snapshot).toContain('button "Run"'); + expect(snapshot).toContain('button "Settings"'); + expect(snapshot).toMatch(/button "(Show|Hide) sidebar"/); + }); + + it('clicks a control with real input', async () => { + const [sidebarButton] = await app().query(role('button', /^(Show|Hide) sidebar$/)); + const next = sidebarButton?.name === 'Hide sidebar' ? 'Show sidebar' : 'Hide sidebar'; + await app().click(role('button', sidebarButton?.name ?? '')); + await app().query(role('button', next)); + }); + + it('reads the stores', async () => { + const { app: appStore, window } = await app().stores(0); + expect(appStore).toMatchObject({ locale: 'en', platform: process.platform }); + expect(window).toMatchObject({ windowId: expect.any(String) }); + }); + + it('runs a command by ID', async () => { + await app().runCommand('app.newWindow'); + const second = await app().waitForWindow(1); + expect(second.visible).toBe(true); + expect(await app().windows()).toHaveLength(2); + }); + + it('takes a screenshot', async () => { + const shot = await app().screenshot(undefined, 0); + expect(shot.width).toBeGreaterThan(500); + expect(fs.statSync(shot.path).size).toBeGreaterThan(1000); + }); + + it('answers native dialogs from a script and records OS side effects', async () => { + // window.open is blocked and the link asks before opening in the browser. + await app().queueDialog('messageBox', { response: 0 }); + await app().evaluate(`window.open('https://example.com/docs')`, 0); + await expect + .poll(() => app().sideEffects()) + .toContainEqual({ + kind: 'shell.openExternal', + args: ['https://example.com/docs'], + }); + const [dialog] = await app().dialogs(); + expect(dialog).toMatchObject({ kind: 'messageBox', scripted: true }); + }); + + it('settles to idle', async () => { + const { waitedMs } = await app().waitForIdle(); + expect(waitedMs).toBeLessThan(10_000); + }); +}); diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json new file mode 100644 index 0000000000..562437a548 --- /dev/null +++ b/packages/app/e2e/tsconfig.json @@ -0,0 +1,18 @@ +{ + // E2E specs, the client and fixtures, and the driver tools. These run under + // plain Node (type stripping) or Vitest, so imports carry `.ts` extensions + // and only erasable TypeScript syntax is allowed. + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "allowJs": true, + "erasableSyntaxOnly": true, + "lib": ["esnext"], + "types": ["node"], + "noEmit": true, + "tsBuildInfoFile": "../../../node_modules/.tmp/tsc/app-e2e.tsbuildinfo" + }, + "include": ["./**/*.ts", "./*.mts", "../tools/driver*.ts"] +} diff --git a/packages/app/e2e/versions.e2e.ts b/packages/app/e2e/versions.e2e.ts new file mode 100644 index 0000000000..1bb067c60d --- /dev/null +++ b/packages/app/e2e/versions.e2e.ts @@ -0,0 +1,228 @@ +// The version picker, the version manager and a manual bisect over the +// fixture release list (44.3.0, 43.7.0, 42.11.3 and 45.0.0-alpha.6). +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { APP_DIR } from './driver.ts'; +import { + appState, + dialogMessages, + openedUrls, + role, + text, + useApp, + windowState, +} from './harness.ts'; + +describe('versions', () => { + const app = useApp(); + const fiddle = async () => (await windowState(app())).fiddle; + const picker = role('button', /^Electron \d+\.\d+\.\d+/); + + it('selects the latest stable version by default', async () => { + expect((await fiddle()).versionRef).toEqual({ kind: 'release', version: '44.3.0' }); + await app().query(role('button', /^Electron 44\.3\.0\b/)); + }); + + it('lists stable releases newest first, then pre-releases, and searches them', async () => { + await app().click(picker); + const names = async () => + (await app().query(role('option'))).map((option) => option.name); + // Until the fixture list arrives, a slow start still shows the bundled one. + await expect + .poll(names) + .toEqual([ + expect.stringMatching(/^Electron 44\.3\.0\b.*latest$/), + expect.stringMatching(/^Electron 43\.7\.0\b/), + expect.stringMatching(/^Electron 42\.11\.3\b/), + expect.stringMatching(/^Electron 45\.0\.0-alpha\.6\b.*beta$/), + 'Copy version number', + ]); + // Each row shows its install state. + expect(await app().query(role('StaticText', 'Not downloaded'))).toHaveLength(4); + + await app().type('43', role('searchbox', 'Search versions')); + await expect + .poll(async () => + (await app().query(role('option'))) + .map((option) => option.name) + .filter((n) => n.startsWith('Electron')), + ) + .toEqual([expect.stringMatching(/^Electron 43\.7\.0\b/)]); + // The first Escape clears the search, the second closes the menu. + await app().press('Escape'); + await app().press('Escape'); + await app().waitForAbsent(role('dialog', 'Electron version')); + }); + + it('copies the version number from the picker', async () => { + await app().click(picker); + await app().click(role('option', 'Copy version number')); + await expect.poll(() => app().clipboard()).toBe('44.3.0'); + await app().waitForAbsent(role('listbox', 'Electron version')); + }); + + it('downloads a version from the version manager', async () => { + await app().press('CmdOrCtrl+,'); + await app().click(role('button', 'Electron')); + await app().type('44', role('textbox', 'Filter versions')); + await app().waitForAbsent(role('cell', '43.7.0')); + await app().query(role('cell', 'Not downloaded')); + + await app().click(role('button', /^Download\b/, { nth: 0 })); + // Download also unzips. Wait for that too: while it unzips the row has no Remove button, + // and a click that lands as it starts is lost. + await expect + .poll(async () => (await appState(app())).versions?.installs['44.3.0']?.state, { + timeout: 60_000, + }) + .toBe('installed'); + await app().query(role('cell', 'Downloaded', { timeout: 30_000 })); + }, 90_000); + + it("can't remove the active version", async () => { + await app().click(role('button', /^Remove\b/, { nth: 0 })); + await expect + .poll( + async () => + `${await app().snapshot(0)}\n${(await dialogMessages(app())).join('\n')}`, + ) + .toContain("Electron 44.3.0 is in use, so it can't be removed."); + expect((await appState(app())).versions?.installs['44.3.0']?.state).toBe('installed'); + }); + + it('refreshes the release list on demand', async () => { + const count = () => + app.fixtures().requests.filter((r) => r.path === '/releases.json').length; + const before = count(); + await app().click(role('button', 'Refresh')); + await expect.poll(count).toBeGreaterThan(before); + await app().click(role('button', 'Close settings')); + await app().waitForAbsent(role('navigation', 'Settings sections')); + }); + + it("switches versions, swapping an unedited template for the new version's", async () => { + const { fiddleRev } = await fiddle(); + await app().click(picker); + await app().click(role('option', /^Electron 43\.7\.0\b/)); + await expect + .poll(async () => (await fiddle()).versionRef) + .toEqual({ kind: 'release', version: '43.7.0' }); + await app().query(role('button', /^Electron 43\.7\.0\b/)); + await expect + .poll(() => app.fixtures().requests.map((r) => r.path)) + .toContain('/minimal-repro/archive/43-x-y.zip'); + await expect.poll(async () => (await fiddle()).fiddleRev).toBeGreaterThan(fiddleRev); + }); + + it('refuses a bisect range whose good version is newer than the bad one', async () => { + await app().press('CmdOrCtrl+Shift+B'); + await app().query(role('dialog', 'Bisect')); + await app().click(role('button', /Good version$/)); + await app().click(role('option', /^45\.0\.0-alpha\.6\b/)); + await app().query(text('The good version must be older than the bad one.')); + expect((await app().query(role('button', 'Start')))[0]?.states).toContain('disabled'); + await app().click(role('button', 'Cancel')); + await app().waitForAbsent(role('dialog', 'Bisect')); + }); + + it('bisects by hand and shows the range with its GitHub comparison', async () => { + await app().press('CmdOrCtrl+Shift+B'); + await app().click(role('button', 'Start')); + const bisect = async () => (await windowState(app())).run?.bisect ?? null; + await expect.poll(async () => (await bisect())?.current ?? null).not.toBeNull(); + + // Marking every version bad narrows the range down to its good end. + for (let step = 0; step < 5; step++) { + const state = await bisect(); + if (!state?.current || state.result) break; + await app().query(text(`Bisecting ${state.current}`)); + expect((await fiddle()).versionRef).toEqual({ + kind: 'release', + version: state.current, + }); + expect((await app().query(picker))[0]?.states).toContain('disabled'); + await app().click(role('button', 'Bad')); + await expect + .poll(async () => { + const next = await bisect(); + return next?.result !== null || next?.current !== state.current; + }) + .toBe(true); + } + + await app().query(role('dialog', 'Bisect finished')); + const finished = await bisect(); + const result = finished?.result; + expect(result?.good).toBe(finished?.good); + const compare = `https://github.com/electron/electron/compare/v${result?.good}...v${result?.bad}`; + await app().query( + text(`The change happened between Electron ${result?.good} and ${result?.bad}.`), + ); + await app().query(text(compare)); + + await app().queueDialog('messageBox', { button: 'Open' }); + await app().click(role('button', 'Compare on GitHub')); + await expect.poll(() => openedUrls(app())).toContain(compare); + await app().click(role('button', 'Close')); + await app().waitForAbsent(role('dialog', 'Bisect finished')); + }); + + it('deletes every download except the active version', async () => { + const installed = async () => + Object.entries((await appState(app())).versions?.installs ?? {}) + .filter( + ([, install]) => + install.state === 'installed' || install.state === 'downloaded', + ) + .map(([version]) => version); + const active = (await fiddle()).versionRef; + const others = async () => + (await installed()).filter( + (version) => active.kind !== 'release' || version !== active.version, + ); + // 44.3.0 was downloaded above; the bisect left another version active. + expect(await others()).toContain('44.3.0'); + + await app().click(role('button', 'Settings')); + await app().click(role('button', 'Electron')); + await app().click(role('button', 'Delete all')); + await app().query(role('alertdialog', 'Delete all downloaded versions?')); + await app().click(role('button', 'Delete all')); + // On Windows the removal waits for the download's warm-up run, which a virus scan can hold up. + await expect.poll(others, { timeout: 20_000 }).toEqual([]); + expect((await fiddle()).versionRef).toEqual(active); + }); + + it('adds a local build, and offers to switch to it when it is added again', async () => { + // The build folder is electron's dist/; on macOS the executable is three levels down, in Electron.app/Contents/MacOS. + const exec = createRequire(path.join(APP_DIR, 'package.json'))('electron') as string; + const dist = + process.platform === 'darwin' + ? path.resolve(path.dirname(exec), '..', '..', '..') + : path.dirname(exec); + const builds = async () => (await appState(app())).versions?.localBuilds ?? []; + await app().queueDialog('open', { filePaths: [dist] }); + await app().click(role('button', 'Add local build')); + await expect.poll(async () => (await builds()).length).toBe(1); + const [build] = await builds(); + expect(build).toMatchObject({ path: dist, available: true }); + // The store writes the file asynchronously, so wait for it. + const stored = path.join(app().testDir ?? '', 'userData', 'local-builds.json'); + await expect + .poll(() => (fs.existsSync(stored) ? fs.readFileSync(stored, 'utf8') : '')) + .toContain(build?.id ?? '?'); + + // The same folder again: offer to switch to the registered build. + await app().queueDialog('open', { filePaths: [dist] }); + await app().queueDialog('messageBox', { response: 0 }); + await app().click(role('button', 'Add local build')); + await expect + .poll(async () => (await fiddle()).versionRef) + .toEqual({ kind: 'local', id: build?.id }); + expect(await builds()).toHaveLength(1); + }); +}); diff --git a/packages/app/e2e/vitest.config.mts b/packages/app/e2e/vitest.config.mts new file mode 100644 index 0000000000..2e8da8e396 --- /dev/null +++ b/packages/app/e2e/vitest.config.mts @@ -0,0 +1,33 @@ +// `yarn test:e2e`: builds the test build once (./global-setup.ts), then runs every *.e2e.ts in +// parallel forks. Each spec file launches its own app. On macOS and Windows the apps share the +// desktop and the GPU, so at most 4 run at once; FIDDLE_E2E_WORKERS overrides. +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'vitest/config'; + +function maxWorkers(): number | undefined { + const given = Number(process.env.FIDDLE_E2E_WORKERS); + if (Number.isInteger(given) && given > 0) return given; + if (process.platform === 'linux') return undefined; + return Math.max(1, Math.min(4, Math.floor(os.availableParallelism() / 2))); +} + +const workers = maxWorkers(); + +export default defineConfig({ + test: { + name: 'e2e', + root: path.dirname(fileURLToPath(import.meta.url)), + include: ['*.e2e.ts'], + environment: 'node', + pool: 'forks', + ...(workers === undefined ? {} : { maxWorkers: workers }), + globalSetup: ['./global-setup.ts'], + testTimeout: 30_000, + hookTimeout: 60_000, + // Polls wait on IPC and the fixture server; Vitest's default is 1 s. + expect: { poll: { timeout: 10_000 } }, + }, +}); diff --git a/packages/app/e2e/workspace.e2e.ts b/packages/app/e2e/workspace.e2e.ts new file mode 100644 index 0000000000..c55e4a6818 --- /dev/null +++ b/packages/app/e2e/workspace.e2e.ts @@ -0,0 +1,367 @@ +// The window's parts, panels, the split editor and multiple windows. +import { describe, expect, it } from 'vitest'; + +import { makeFolder, role, useApp, windowState } from './harness.ts'; + +describe('workspace', () => { + const app = useApp(); + const layout = async () => (await windowState(app())).layout; + + it('shows the editor, console, files, packages and the toolbar controls', async () => { + await app().query(role('code')); + await app().query(role('region', 'Console')); + await app().query(role('navigation', 'Files')); + await app().query(role('region', 'Packages')); + await app().query(role('group', 'Toolbar')); + await app().query(role('button', /^Electron \d+\.\d+\.\d+/)); + await app().query(role('button', 'Run')); + await app().query(role('button', 'Publish')); + }); + + it('opens one editor tab per visible file', async () => { + const { fiddle } = await windowState(app()); + const tabs = (await app().query(role('tab'))).map((tab) => tab.name).sort(); + expect(tabs).toEqual( + fiddle.files + .filter((file) => file.visible) + .map((file) => file.name) + .sort(), + ); + }); + + it('splits the editor and closes the split', async () => { + await app().click(role('button', 'Split editor')); + await expect + .poll(async () => (await layout()).panes) + .toEqual(['main.js', 'renderer.js']); + expect(await app().query(role('code'))).toHaveLength(2); + await app().click(role('button', 'Close split', { nth: 0 })); + await expect.poll(async () => (await layout()).panes).toEqual([]); + expect(await app().query(role('code'))).toHaveLength(1); + }); + + // Synthetic drag and drop: the driver has no pointer drag, so these dispatch + // the DragEvents a real drag produces, sharing one DataTransfer. + + const visibleTabs = async () => + (await windowState(app())).fiddle.files + .filter((file) => file.visible) + .map((file) => file.name); + const activeFile = async () => (await windowState(app())).fiddle.activeFile; + const panes = async () => (await layout()).panes; + const tabNamed = (name: string) => + `[...document.querySelectorAll('[role="tab"]')].find((tab) => tab.textContent.startsWith(${JSON.stringify(name)}))`; + /** Starts dragging `name`'s tab and waits until the sheet has taken note (drop zones are up). Leaves `tab` and `data` in scope. */ + const startDrag = (name: string) => ` + const tab = ${tabNamed(name)}; + const data = new DataTransfer(); + tab.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: data })); + for (let i = 0; i < 100 && !document.querySelector('[data-tab-dragging]'); i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + if (!document.querySelector('[data-tab-dragging]')) throw new Error('The tab drag did not start');`; + /** Drops `name`'s tab on a zone ('before', 'center' or 'after') of the pane at `index`. */ + const dragTabToPane = ( + name: string, + index: number, + zone: 'before' | 'center' | 'after', + ) => + app().evaluate(`(async () => { + ${startDrag(name)} + const target = document.querySelector('[data-pane-index="${index}"] [data-drop-zone="${zone}"]'); + if (!target) throw new Error('No ${zone} drop zone on pane ${index}'); + for (const type of ['dragenter', 'dragover', 'drop']) + target.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: data })); + tab.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer: data })); + })()`); + /** Drops `name`'s tab on the near or far half of `onto`'s tab, or past the last tab (`onto` null). */ + const dragTabAlongRow = ( + name: string, + onto: string | null, + side: 'before' | 'after' = 'before', + ) => + app().evaluate(`(async () => { + ${startDrag(name)} + const onto = ${onto === null ? 'null' : tabNamed(onto)}; + const row = tab.closest('[role="tablist"]').parentElement.parentElement; + const rect = (onto ?? row).getBoundingClientRect(); + const rtl = getComputedStyle(row).direction === 'rtl'; + // The near (start) half of a tab is its left half, or its right half in a right-to-left layout. + const left = onto ? (${JSON.stringify(side)} === 'before') !== rtl : rtl; + const clientX = onto ? (left ? rect.left + 4 : rect.right - 4) : (left ? rect.left + 2 : rect.right - 2); + const init = { bubbles: true, cancelable: true, dataTransfer: data, clientX, clientY: rect.top + rect.height / 2 }; + for (const type of ['dragenter', 'dragover', 'drop']) (onto ?? row).dispatchEvent(new DragEvent(type, init)); + tab.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer: data })); + })()`); + /** The zones each pane offers while `name`'s tab is dragged, e.g. `['before center', '', 'center after']`; ends the drag. */ + const dropZonesWhileDragging = (name: string) => + app().evaluate(`(async () => { + ${startDrag(name)} + const zones = [...document.querySelectorAll('[data-pane-index]')].map((pane) => + [...pane.querySelectorAll('[data-drop-zone]')].map((zone) => zone.dataset.dropZone).join(' ')); + tab.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer: data })); + return zones; + })()`); + + it('puts the keyboard focus in the editor of a clicked tab or file, and leaves it on the tabs for arrow keys', async () => { + /** The pane whose editor has the keyboard focus, or the role of what has it instead. */ + const focus = () => + app().evaluate(`(() => { + const element = document.activeElement; + return element?.closest('.monaco-editor') + ? Number(element.closest('[data-pane-index]').dataset.paneIndex) + : element?.getAttribute('role'); + })()`); + + await app().click(role('tab', /^renderer\.js\b/)); + await expect.poll(focus).toBe(0); + await expect.poll(activeFile).toBe('renderer.js'); + await app().click(role('row', 'index.html')); + await expect.poll(activeFile).toBe('index.html'); + await expect.poll(focus).toBe(0); + + // Arrow keys browse the tabs without leaving them. + await app().evaluate(`${tabNamed('index.html')}.focus()`); + await app().press('ArrowLeft'); + await expect.poll(activeFile).toBe('renderer.js'); + expect(await focus()).toBe('tab'); + + // In a split, clicking the other pane's tab or file moves the focus into that pane. + await app().click(role('button', 'Split editor')); + await expect.poll(panes).toEqual(['renderer.js', 'main.js']); + await app().click(role('tab', /^main\.js\b/)); + await expect.poll(focus).toBe(1); + await app().click(role('row', 'renderer.js')); + await expect.poll(focus).toBe(0); + await app().click(role('button', 'Close split', { nth: 0 })); + await expect.poll(panes).toEqual([]); + await app().click(role('tab', /^main\.js\b/)); + await expect.poll(activeFile).toBe('main.js'); + }); + + it('closes tabs and opens a dragged tab in a pane or beside it', async () => { + // Delete closes the focused tab. The file stays in the sidebar, and clicking it there reopens the tab. + await app().evaluate(`${tabNamed('main.js')}.focus()`); + await app().press('Delete'); + await expect.poll(visibleTabs).not.toContain('main.js'); + await app().waitForAbsent(role('tab', /^main\.js\b/)); + await app().click(role('row', 'main.js')); + await app().query(role('tab', /^main\.js\b/)); + await expect.poll(activeFile).toBe('main.js'); + + // Dropping a tab on the far edge of the pane opens it in a second pane and focuses it. + await dragTabToPane('renderer.js', 0, 'after'); + await expect.poll(panes).toEqual(['main.js', 'renderer.js']); + await expect.poll(activeFile).toBe('renderer.js'); + await expect.poll(async () => (await app().query(role('code'))).length).toBe(2); + // Clicking the other pane's tab moves focus there; the panes stay. + await app().click(role('tab', /^main\.js\b/)); + await expect.poll(activeFile).toBe('main.js'); + expect(await panes()).toEqual(['main.js', 'renderer.js']); + // Clicking a tab that no pane shows puts its file in the focused pane. + await app().click(role('tab', /^index\.html\b/)); + await expect.poll(panes).toEqual(['index.html', 'renderer.js']); + // Dropping a pane's file in the middle of the other pane shows it there and closes its own pane. + await dragTabToPane('renderer.js', 0, 'center'); + await expect.poll(panes).toEqual([]); + await expect.poll(activeFile).toBe('renderer.js'); + await expect.poll(async () => (await app().query(role('code'))).length).toBe(1); + + // The close glyph on a pane's tab closes the tab and its pane; focus moves to the neighbour. + await dragTabToPane('main.js', 0, 'before'); + await expect.poll(panes).toEqual(['main.js', 'renderer.js']); + await expect.poll(activeFile).toBe('main.js'); + await app().evaluate( + `${tabNamed('main.js')}.querySelector('[data-tab-close]').click()`, + ); + await expect.poll(panes).toEqual([]); + await expect.poll(visibleTabs).not.toContain('main.js'); + expect(await activeFile()).toBe('renderer.js'); + await app().click(role('row', 'main.js')); + await expect.poll(visibleTabs).toContain('main.js'); + await app().query(role('tab', /^main\.js\b/)); + }); + + it('moves tabs by dragging them along the row, and with Move tab left and right', async () => { + const before = await visibleTabs(); + expect(before.length).toBeGreaterThanOrEqual(3); + const [first, second] = before as [string, string]; + const last = before.at(-1)!; + const tabPattern = (name: string) => new RegExp(`^${name.replaceAll('.', '\\.')}\\b`); + + await dragTabAlongRow(last, first, 'before'); + await expect.poll(visibleTabs).toEqual([last, ...before.slice(0, -1)]); + // The sidebar follows: within its group, the file moved too (main owns one order for both). + expect( + (await windowState(app())).fiddle.files.map((file) => file.name).indexOf(last), + ).toBe(0); + + await app().click(role('tab', tabPattern(last))); + await expect.poll(activeFile).toBe(last); + await app().runCommand('editor.moveTabRight'); + await expect.poll(visibleTabs).toEqual([first, last, ...before.slice(1, -1)]); + // Ctrl+Shift+PageUp moves it back (Ctrl+Shift+PageDown the other way), as in VS Code; Ctrl+Cmd+Left and Right on macOS. + const [moveLeft, moveRight] = + process.platform === 'darwin' + ? ['Ctrl+Cmd+Left', 'Ctrl+Cmd+Right'] + : ['Ctrl+Shift+PageUp', 'Ctrl+Shift+PageDown']; + await app().press(moveLeft); + await expect.poll(visibleTabs).toEqual([last, ...before.slice(0, -1)]); + await app().press(moveRight); + await app().press(moveRight); + await expect.poll(visibleTabs).toEqual([first, second, last, ...before.slice(2, -1)]); + + // Dropping a tab past the last one moves it to the end; on a tab's far half, after that tab. + await dragTabAlongRow(last, null); + await expect.poll(visibleTabs).toEqual(before); + await dragTabAlongRow(first, second, 'after'); + await expect.poll(visibleTabs).toEqual([second, first, ...before.slice(2)]); + await app().click(role('tab', tabPattern(first))); + await app().runCommand('editor.moveTabLeft'); + await expect.poll(visibleTabs).toEqual(before); + }); + + it('splits a split view: up to four panes side by side, each its own drop target', async () => { + const files = await visibleTabs(); + expect(files.length).toBeGreaterThanOrEqual(4); + const [a, b, c, d] = files as [string, string, string, string]; + await app().click(role('tab', new RegExp(`^${a.replaceAll('.', '\\.')}\\b`))); + await expect.poll(activeFile).toBe(a); + await expect.poll(panes).toEqual([]); + + // Each drop on a far edge adds a pane after that one; the dropped file takes focus. + await dragTabToPane(b, 0, 'after'); + await expect.poll(panes).toEqual([a, b]); + await dragTabToPane(c, 1, 'after'); + await expect.poll(panes).toEqual([a, b, c]); + await expect.poll(activeFile).toBe(c); + await expect.poll(async () => (await app().query(role('code'))).length).toBe(3); + await dragTabToPane(d, 0, 'before'); + await expect.poll(panes).toEqual([d, a, b, c]); + await expect.poll(async () => (await app().query(role('code'))).length).toBe(4); + + // Four is the limit, and every file is showing, so a dragged tab can only move its pane: + // a pane offers just the drops that change something, and the file's own pane none. + expect(await dropZonesWhileDragging(a)).toEqual([ + 'before center', + '', + 'center after', + 'before center after', + ]); + await dragTabToPane(a, 3, 'after'); + await expect.poll(panes).toEqual([d, b, c, a]); + await expect.poll(async () => (await app().query(role('code'))).length).toBe(4); + // Dropped in another pane's middle, a showing file takes that pane over and its old pane closes. + await dragTabToPane(d, 3, 'center'); + await expect.poll(panes).toEqual([b, c, d]); + await expect.poll(activeFile).toBe(d); + + await app().click(role('button', 'Close pane', { nth: 0 })); + await expect.poll(panes).toEqual([c, d]); + expect(await visibleTabs()).toContain(b); + await app().click(role('button', 'Maximize', { nth: 1 })); + await expect.poll(panes).toEqual([]); + await expect.poll(activeFile).toBe(d); + + // CmdOrCtrl+\\ (the Split editor button) splits once; while split, it keeps only the focused pane. + await app().press('CmdOrCtrl+\\'); + await expect.poll(async () => (await panes()).length).toBe(2); + await dragTabToPane(a, 1, 'after'); + await expect.poll(async () => (await panes()).length).toBe(3); + await app().click(role('button', 'Close split')); + await expect.poll(panes).toEqual([]); + await expect.poll(async () => (await app().query(role('code'))).length).toBe(1); + }); + + it('hides and shows the sidebar and the console', async () => { + await app().query(role('separator', 'Resize sidebar')); + await app().query(role('separator', 'Resize console')); + await app().click(role('button', 'Hide sidebar')); + await app().waitForAbsent(role('navigation', 'Files')); + expect((await layout()).sidebar).toBe(false); + await app().click(role('button', 'Show sidebar')); + await app().query(role('navigation', 'Files')); + + await app().runCommand('view.toggleConsole'); + await app().waitForAbsent(role('region', 'Console')); + await app().runCommand('view.toggleConsole'); + await app().query(role('region', 'Console')); + }); + + it('routes undo and redo to the editor', async () => { + const dirty = async () => (await windowState(app())).fiddle.dirty; + await app().click(role('code')); + await app().type('// typed'); + await expect.poll(dirty).toBe(true); + for (let i = 0; i < 10 && (await dirty()); i++) await app().press('CmdOrCtrl+Z'); + await expect.poll(dirty).toBe(false); + await app().press('Shift+CmdOrCtrl+Z'); + await expect.poll(dirty).toBe(true); + for (let i = 0; i < 10 && (await dirty()); i++) await app().press('CmdOrCtrl+Z'); + await expect.poll(dirty).toBe(false); + }); + + it('shows the editor diagnostics on the file tab', async () => { + await app().click(role('tab', /^renderer\.js\b/)); + await app().click(role('code')); + await app().type('const = ;'); + await app().query(role('tab', /^renderer\.js , \d+ errors?\b/, { timeout: 10_000 })); + for (let i = 0; i < 10 && (await windowState(app())).fiddle.dirty; i++) + await app().press('CmdOrCtrl+Z'); + await expect.poll(async () => (await windowState(app())).fiddle.dirty).toBe(false); + await app().query(role('tab', 'renderer.js', { timeout: 10_000 })); + }); + + it('shows hover annotations in the editor', async () => { + await app().click(role('tab', /^main\.js\b/)); + await app().query(role('tabpanel', 'main.js')); + await app().click(role('code')); + // Onto `BrowserWindow` in line 2, `const { app, BrowserWindow } = ...`, with keys that + // move the same on every platform, then Monaco's "Show hover" chord. + for (const key of [ + 'PageUp', + 'ArrowDown', + 'Home', + ...Array(14).fill('ArrowRight'), + ]) + await app().press(key); + await app().press('CmdOrCtrl+K'); + await app().press('CmdOrCtrl+I'); + await app().query(role('tooltip', /BrowserWindow/, { timeout: 10_000 })); + await app().press('Escape'); + }); + + it('focuses the window that already has a folder open', async () => { + const dir = makeFolder(app(), 'shared', { 'main.js': '// shared\n' }); + await app().queueDialog('open', { filePaths: [dir] }); + await app().runCommand('file.open', 0); + await expect + .poll(async () => (await windowState(app(), 0)).fiddle.source.localPath) + .toBe(dir); + + await app().runCommand('app.newWindow'); + await app().waitForWindow(1); + const second = await windowState(app(), 1); + await app().queueDialog('open', { filePaths: [dir] }); + await app().runCommand('file.open', 1); + await expect.poll(async () => (await app().windows())[0]?.focused).toBe(true); + const after = await windowState(app(), 1); + expect(after.fiddle.source.localPath).toBeUndefined(); + expect(after.fiddle.fiddleRev).toBe(second.fiddle.fiddleRev); + + await app().runCommand('file.close', 1); + await expect.poll(async () => (await app().windows()).length).toBe(1); + }); + + it('opens a second window with its own fiddle, and closes it with CmdOrCtrl+W', async () => { + await app().press('CmdOrCtrl+Shift+N'); + await app().waitForWindow(1); + const first = await windowState(app(), 0); + const second = await windowState(app(), 1); + expect(second.windowId).not.toBe(first.windowId); + expect(second.fiddle.name).not.toBe(first.fiddle.name); + + await app().call('press', { key: 'CmdOrCtrl+W', window: 1 }); + await expect.poll(async () => (await app().windows()).length).toBe(1); + }); +}); diff --git a/packages/app/forge.config.ts b/packages/app/forge.config.ts new file mode 100644 index 0000000000..8c14bc558e --- /dev/null +++ b/packages/app/forge.config.ts @@ -0,0 +1,390 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +import { FuseV1Options, FuseVersion } from '@electron/fuses'; +import { MakerDeb } from '@electron-forge/maker-deb'; +import { MakerMSIX } from '@electron-forge/maker-msix'; +import { MakerRpm } from '@electron-forge/maker-rpm'; +import { MakerSquirrel } from '@electron-forge/maker-squirrel'; +import { MakerZIP } from '@electron-forge/maker-zip'; +import { FusesPlugin } from '@electron-forge/plugin-fuses'; +import { VitePlugin } from '@electron-forge/plugin-vite'; +import { PublisherGitHub } from '@electron-forge/publisher-github'; +import type { ForgeConfig } from '@electron-forge/shared-types'; + +import packageJson from './package.json'; +import { extractsWithoutAddon, isTargetAddon } from './tools/native-addons'; + +// The app identity here is checked against build/identity.json in CI +// (tools/release-identity.mjs). + +const appDir = import.meta.dirname; + +// Pseudo-locales are generated, so only the real locale folders count. +const shippedLocales = fs + .readdirSync(path.join(appDir, 'src/i18n/locales'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); +// macOS names Chinese by script, not region. +const macScripts: Record = { 'zh-CN': 'zh-Hans', 'zh-TW': 'zh-Hant' }; +const macLocales = shippedLocales.map((locale) => macScripts[locale] ?? locale); +const iconDir = path.join(appDir, 'assets', 'icons'); +const buildDir = path.join(appDir, 'build'); +const entitlements = path.join(buildDir, 'entitlements.plist'); +const disclaimDir = path.join(appDir, 'native', 'disclaim'); + +// deb and rpm take a single icon: the 1024px PNG. +const linuxOptions = { + bin: 'electron-fiddle', + categories: ['Development' as const, 'Utility' as const], + icon: path.join(iconDir, 'fiddle.png'), + mimeType: ['x-scheme-handler/electron-fiddle'], +}; + +// Derived from Forge so the type matches the version Forge depends on. +type WindowsSignOptions = NonNullable; + +/** + * Azure Trusted Signing. Authentication happens outside: the dlib picks up + * the Azure CLI session that `azure/login` leaves in CI. Returns `undefined` + * (an unsigned build) when no Azure variable is set, and throws when only some + * are. + */ +function getWindowsSignOptions(): WindowsSignOptions | undefined { + const { + AZURE_CODE_SIGNING_DLIB: dlib, + AZURE_CODE_SIGNING_ENDPOINT: endpoint, + AZURE_CODE_SIGNING_ACCOUNT_NAME: accountName, + AZURE_CODE_SIGNING_CERTIFICATE_PROFILE_NAME: certificateProfileName, + WINDOWS_SIGNTOOL_PATH: signToolPath, + } = process.env; + + if (!dlib && !endpoint && !accountName && !certificateProfileName) { + return undefined; + } + + if (!dlib || !endpoint || !accountName || !certificateProfileName) { + throw new Error( + 'Azure Trusted Signing is only partially configured. Set all of ' + + 'AZURE_CODE_SIGNING_DLIB, AZURE_CODE_SIGNING_ENDPOINT, ' + + 'AZURE_CODE_SIGNING_ACCOUNT_NAME and ' + + 'AZURE_CODE_SIGNING_CERTIFICATE_PROFILE_NAME, or none of them.', + ); + } + + if (!signToolPath) { + // The signtool.exe vendored by @electron/windows-sign predates /dlib + // support. Trusted Signing needs one from Windows SDK 10.0.22621.755+. + throw new Error( + 'Azure Trusted Signing needs a recent signtool.exe. Set ' + + 'WINDOWS_SIGNTOOL_PATH to one from Windows SDK 10.0.22621.755 or later.', + ); + } + + const metadataPath = path.join( + os.tmpdir(), + 'electron-fiddle-trusted-signing-metadata.json', + ); + + fs.writeFileSync( + metadataPath, + JSON.stringify( + { + Endpoint: endpoint, + CodeSigningAccountName: accountName, + CertificateProfileName: certificateProfileName, + // Skip the credential providers DefaultAzureCredential would probe + // besides the Azure CLI; managed identity times out slowly on runners. + ExcludeCredentials: [ + 'ManagedIdentityCredential', + 'WorkloadIdentityCredential', + 'SharedTokenCacheCredential', + 'VisualStudioCredential', + 'VisualStudioCodeCredential', + 'AzurePowerShellCredential', + 'AzureDeveloperCliCredential', + 'InteractiveBrowserCredential', + ], + }, + null, + 2, + ), + ); + + return { + signToolPath, + // Passed as an array so paths with spaces survive intact. + signWithParams: ['/dlib', dlib, '/dmdf', metadataPath], + timestampServer: 'http://timestamp.acs.microsoft.com', + // Trusted Signing certificates are SHA-256 only; no SHA-1 dual signing. + hashes: ['sha256'] as WindowsSignOptions['hashes'], + // The dlib selects the certificate, not signtool's `/a`. + automaticallySelectCertificate: false, + }; +} + +// macOS: see "Signed local builds" in CONTRIBUTING.md. Without +// APPLE_SIGNING_IDENTITY the app is left unsigned (CI and contributors). With +// it, signing must succeed, and the app is notarized when credentials are set. +const signingIdentity = process.env.APPLE_SIGNING_IDENTITY; +const teamId = signingIdentity && /\(([A-Z0-9]{10})\)$/.exec(signingIdentity)?.[1]; +// The pinned designated requirement lets releases update across the two teams +// that have signed Fiddle. A build signed by any other team keeps codesign's +// default requirement, or it would not satisfy its own. +const requirementsFile = path.join(buildDir, 'certs', 'requirements.txt'); +const requirements = + teamId && fs.readFileSync(requirementsFile, 'utf8').includes(teamId) + ? requirementsFile + : undefined; + +type NotarizeOptions = NonNullable['osxNotarize']; + +/** + * An App Store Connect API key (APPLE_API_KEY, APPLE_API_KEY_ID and, for a team + * key, APPLE_API_ISSUER), an Apple ID with an app-specific password (APPLE_ID, + * APPLE_ID_PASSWORD), or a profile saved with `xcrun notarytool + * store-credentials` (APPLE_KEYCHAIN_PROFILE). Throws when a set is incomplete. + */ +function getNotarizeOptions(): NotarizeOptions { + if (process.platform !== 'darwin' || !signingIdentity) return undefined; + + const { + APPLE_API_KEY: appleApiKey, + APPLE_API_KEY_ID: appleApiKeyId, + APPLE_API_ISSUER: appleApiIssuer, + APPLE_ID: appleId, + APPLE_ID_PASSWORD: appleIdPassword, + APPLE_KEYCHAIN_PROFILE: keychainProfile, + } = process.env; + + if (appleApiKey || appleApiKeyId) { + if (!appleApiKey || !appleApiKeyId) { + throw new Error( + 'Set both APPLE_API_KEY and APPLE_API_KEY_ID to notarize with an API key.', + ); + } + return { appleApiKey, appleApiKeyId, ...(appleApiIssuer && { appleApiIssuer }) }; + } + if (appleId || appleIdPassword) { + if (!appleId || !appleIdPassword || !teamId) { + throw new Error( + 'Set both APPLE_ID and APPLE_ID_PASSWORD to notarize with an Apple ID, and give ' + + 'APPLE_SIGNING_IDENTITY as "Developer ID Application: Name (TEAMID)".', + ); + } + return { appleId, appleIdPassword, teamId }; + } + if (keychainProfile) return { keychainProfile }; + + console.warn('No notarization credentials: the app is signed but not notarized.'); + return undefined; +} + +const windowsSignOptions = getWindowsSignOptions(); + +// Native modules stay external in the main bundle and Forge's Vite plugin packages +// only `.vite/`, so each is copied in with just the target's addon. A target with no +// addon fails the build unless core extracts without one (`extractsWithoutAddon`). +const NATIVE_MODULES = ['@electron-internal/extract-zip']; + +async function copyNativeModules(buildPath: string, platform: string, arch: string) { + const require = createRequire(import.meta.url); + for (const name of NATIVE_MODULES) { + const destination = path.join(buildPath, 'node_modules', name); + await fs.promises.cp(path.dirname(require.resolve(name)), destination, { + recursive: true, + filter: (file) => !file.endsWith('.node') || isTargetAddon(file, platform, arch), + }); + const hasAddon = fs.readdirSync(destination).some((file) => file.endsWith('.node')); + if (!hasAddon && !extractsWithoutAddon(platform, arch)) { + throw new Error(`${name} has no addon for ${platform}-${arch}`); + } + } +} + +// Module installs spawn `node sfw.mjs npm …` from a per-user copy of +// `/sfw/` (src/main/platform/sfw.ts), so the script and the +// package.json it reads ship outside the asar. `resolve` throws when the package +// is missing, so packaging fails rather than shipping without Socket Firewall. +function stageSfw(): string { + const root = path.dirname(createRequire(import.meta.url).resolve('sfw/package.json')); + const staged = path.join(appDir, 'out', '.sfw', 'sfw'); + fs.rmSync(staged, { recursive: true, force: true }); + for (const name of ['package.json', path.join('dist', 'sfw.mjs')]) { + fs.mkdirSync(path.dirname(path.join(staged, name)), { recursive: true }); + fs.copyFileSync(path.join(root, name), path.join(staged, name)); + } + return staged; +} + +// releases.json and contributors.json are compiled into main, so the copy of +// `static/` in `` leaves them out. +function stageStatic(): string { + const source = path.join(appDir, 'static'); + const staged = path.join(appDir, 'out', '.static'); + fs.rmSync(staged, { recursive: true, force: true }); + fs.cpSync(source, path.join(staged, 'static'), { + recursive: true, + filter: (file) => + !['releases.json', 'contributors.json'].includes(path.relative(source, file)), + }); + return path.join(staged, 'static'); +} + +// The macOS helper that starts fiddles without the app's privacy grants ships at +// `/fiddle-disclaim`, on darwin targets only. +const DISCLAIM_HELPER = 'fiddle-disclaim'; + +const config: ForgeConfig = { + hooks: { + // `yarn generate`: offline and idempotent. The Electron release list and + // the contributors list are committed snapshots, so nothing is fetched. + generateAssets: async () => { + execFileSync(process.execPath, [path.join(appDir, 'tools', 'generate.mjs')], { + stdio: 'inherit', + }); + }, + prePackage: async (forgeConfig, platform) => { + // `static/` holds Show Me, the quick-start template and + // import-local-storage.html (`staticDir()` in src/main/documents/service.ts). + // fiddle.png is the Linux About panel icon, which GTK reads from disk. + const extraResource = [stageStatic(), path.join(iconDir, 'fiddle.png'), stageSfw()]; + if (platform === 'darwin') { + if (process.platform !== 'darwin') { + throw new Error('The macOS privacy helper can only be built on macOS.'); + } + execFileSync('sh', [path.join(disclaimDir, 'build.sh')], { stdio: 'inherit' }); + extraResource.push(path.join(disclaimDir, 'build', DISCLAIM_HELPER)); + } + (forgeConfig.packagerConfig ??= {}).extraResource = extraResource; + }, + // Source maps are about three quarters of the asar and nothing at runtime + // reads them. The release workflow uploads them from `.vite/`. + packageAfterCopy: async (_config, buildPath, _electronVersion, platform, arch) => { + await copyNativeModules(buildPath, platform, arch); + const built = path.join(buildPath, '.vite'); + for (const file of await fs.promises.readdir(built, { recursive: true })) { + if (file.endsWith('.map')) await fs.promises.rm(path.join(built, file)); + } + }, + }, + packagerConfig: { + name: 'Electron Fiddle', + executableName: 'electron-fiddle', + asar: { unpack: '**/*.node' }, + icon: path.join(iconDir, 'fiddle'), + appBundleId: 'com.electron.fiddle', + extendInfo: { CFBundleLocalizations: macLocales }, + appCategoryType: 'public.app-category.developer-tools', + protocols: [ + { name: 'Electron Fiddle Launch Protocol', schemes: ['electron-fiddle'] }, + ], + win32metadata: { + CompanyName: 'Electron Community', + OriginalFilename: 'Electron Fiddle', + }, + // Hardened runtime. + osxSign: signingIdentity + ? { + identity: signingIdentity, + continueOnError: false, + optionsForFile: (filePath) => { + // The privacy helper only execs Electron: it needs no entitlements. + if (path.basename(filePath) === DISCLAIM_HELPER) { + return { entitlements: [], requirements }; + } + return ['(Plugin).app', '(GPU).app', '(Renderer).app'].some((helper) => + filePath.includes(helper), + ) + ? { requirements } + : { entitlements, requirements }; + }, + } + : undefined, + osxNotarize: getNotarizeOptions(), + }, + makers: [ + new MakerSquirrel((arch: string) => ({ + name: 'electron-fiddle', + authors: 'Electron Community', + exe: 'electron-fiddle.exe', + // Control Panel's Programs list fetches this, so it must be a public URL. + iconUrl: + 'https://raw.githubusercontent.com/electron/fiddle/main/packages/app/assets/icons/fiddle.ico', + loadingGif: path.join(appDir, 'assets', 'loading.gif'), + noMsi: true, + setupExe: `electron-fiddle-${packageJson.version}-win32-${arch}-setup.exe`, + setupIcon: path.join(iconDir, 'fiddle.ico'), + windowsSign: windowsSignOptions, + })), + new MakerMSIX({ + manifestVariables: { + // Must match the subject of the Azure Trusted Signing certificate + // exactly, or signtool refuses to sign the package. + publisher: + 'CN=OpenJS Foundation, O=OpenJS Foundation, L=San Francisco, S=California, C=US', + publisherDisplayName: 'OpenJS Foundation', + packageIdentity: 'ElectronCommunity.ElectronFiddle', + appExecutable: 'electron-fiddle.exe', + packageDisplayName: 'Electron Fiddle', + appDisplayName: 'Electron Fiddle', + packageDescription: packageJson.description, + }, + windowsSignOptions, + }), + new MakerZIP({}, ['darwin']), + new MakerDeb({ options: linuxOptions }), + new MakerRpm({ options: linuxOptions }), + { + name: '@reforged/maker-appimage', + platforms: ['linux'], + // The AppImage maker takes an icon set: the 1024px PNG and the SVG. + config: { + options: { + ...linuxOptions, + icon: { + '1024x1024': path.join(iconDir, 'fiddle.png'), + scalable: path.join(iconDir, 'fiddle.svg'), + }, + }, + }, + }, + ], + publishers: [ + new PublisherGitHub({ + repository: { owner: 'electron', name: 'fiddle' }, + draft: true, + // Publishing an alpha as a full release would make it the update target. + prerelease: packageJson.version.includes('-'), + generateReleaseNotes: true, + }), + ], + plugins: [ + new VitePlugin({ + build: [ + { entry: 'src/main/index.ts', config: 'vite.main.config.mts', target: 'main' }, + { + entry: 'src/preload/index.ts', + config: 'vite.preload.config.mts', + target: 'preload', + }, + ], + renderer: [{ name: 'main_window', config: 'vite.renderer.config.mts' }], + }), + new FusesPlugin({ + version: FuseVersion.V1, + [FuseV1Options.RunAsNode]: false, + [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, + [FuseV1Options.EnableNodeCliInspectArguments]: false, + [FuseV1Options.GrantFileProtocolExtraPrivileges]: false, + [FuseV1Options.OnlyLoadAppFromAsar]: true, + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, + [FuseV1Options.EnableCookieEncryption]: true, + }), + ], +}; + +export default config; diff --git a/packages/app/index.html b/packages/app/index.html new file mode 100644 index 0000000000..b18c81c9fd --- /dev/null +++ b/packages/app/index.html @@ -0,0 +1,11 @@ + + + + + Electron Fiddle + + +
+ + + diff --git a/packages/app/native/disclaim/build.sh b/packages/app/native/disclaim/build.sh new file mode 100755 index 0000000000..5d383f5d21 --- /dev/null +++ b/packages/app/native/disclaim/build.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Builds fiddle-disclaim and selfcheck as universal (arm64 and x86_64) binaries +# in build/, or in the folder given as the first argument. macOS only. +# +# MACOSX_DEPLOYMENT_TARGET sets the oldest macOS they run on. The linker signs +# them ad hoc; the release build signs fiddle-disclaim again (forge.config.ts). +set -eu + +here=$(cd "$(dirname "$0")" && pwd) +out=${1:-"$here/build"} +target=${MACOSX_DEPLOYMENT_TARGET:-12.0} + +if [ "$(uname -s)" != Darwin ]; then + echo "build.sh: the privacy helper builds on macOS only" >&2 + exit 1 +fi + +mkdir -p "$out" + +build() { + clang -std=c11 -O2 -Wall -Wextra -arch arm64 -arch x86_64 \ + -mmacosx-version-min="$target" -o "$out/$1" "$here/$2" + codesign --force --sign - "$out/$1" +} + +build fiddle-disclaim disclaim.c +build selfcheck selfcheck.c diff --git a/packages/app/native/disclaim/check-packaged.sh b/packages/app/native/disclaim/check-packaged.sh new file mode 100755 index 0000000000..6b3e9d6948 --- /dev/null +++ b/packages/app/native/disclaim/check-packaged.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Checks the privacy helper in a packaged app: +# +# check-packaged.sh [--release] +# +# The helper must be there, executable, universal and validly signed, and it +# must pass build/selfcheck when that was built. With --release it must also +# carry the Developer ID, the hardened runtime and no entitlements. macOS only. +set -eu + +app=${1:?usage: check-packaged.sh [--release]} +release=${2:-} +here=$(cd "$(dirname "$0")" && pwd) +helper="$app/Contents/Resources/fiddle-disclaim" + +fail() { + echo "check-packaged.sh: $1" >&2 + exit 1 +} + +if [ ! -f "$helper" ] || [ ! -x "$helper" ]; then + fail "$helper is missing or not executable" +fi + +archs=$(lipo -archs "$helper") +for arch in arm64 x86_64; do + case " $archs " in + *" $arch "*) ;; + *) fail "no $arch slice, only: $archs" ;; + esac +done + +codesign --verify --strict "$helper" || fail "the signature of $helper is invalid" + +if [ "$release" = "--release" ]; then + details=$(codesign -dvv "$helper" 2>&1) + echo "$details" | grep -q 'Authority=Developer ID Application' || fail "not signed with a Developer ID" + echo "$details" | grep -q 'flags=.*runtime' || fail "the hardened runtime is off" + if codesign -d --entitlements :- "$helper" 2>/dev/null | grep -q ''; then + fail "the helper carries entitlements" + fi +fi + +if [ -x "$here/build/selfcheck" ]; then + "$here/build/selfcheck" "$helper" +fi + +echo "ok: $helper ($archs)" diff --git a/packages/app/native/disclaim/disclaim.c b/packages/app/native/disclaim/disclaim.c new file mode 100644 index 0000000000..95e27fb284 --- /dev/null +++ b/packages/app/native/disclaim/disclaim.c @@ -0,0 +1,64 @@ +/* + * fiddle-disclaim: runs a program with macOS "responsibility" disclaimed. + * + * fiddle-disclaim [argument...] + * + * macOS charges a process's privacy (TCC) requests to its responsible process, + * so a fiddle would use Electron Fiddle's grants. This helper disclaims + * responsibility and replaces itself with (POSIX_SPAWN_SETEXEC, no + * fork), so pid, environment and signals reach the program unchanged. + * is a path: there is no PATH search. + * + * Exit codes (the program's own when it runs): + * 64 usage: no program given (EX_USAGE) + * 69 this macOS has no disclaim call (EX_UNAVAILABLE); the program is not + * run, because it would inherit the caller's privacy grants + * 71 the spawn could not be set up (EX_OSERR) + * 126 the program was found but could not be executed + * 127 the program was not found + */ +#include +#include +#include +#include +#include +#include + +/* Like a shell: 126 found but not executable, 127 not found. */ +enum { EXIT_NOT_EXECUTABLE = 126, EXIT_NOT_FOUND = 127 }; + +extern char **environ; + +/* Private libSystem API, so it is looked up at run time rather than linked. */ +typedef int (*setdisclaim_fn)(posix_spawnattr_t *attr, int disclaim); + +int main(int argc, char **argv) { + if (argc < 2 || argv[1][0] == '\0') { + fprintf(stderr, "usage: fiddle-disclaim [argument...]\n"); + return EX_USAGE; + } + + setdisclaim_fn setdisclaim = + (setdisclaim_fn)dlsym(RTLD_DEFAULT, "responsibility_spawnattrs_setdisclaim"); + if (setdisclaim == NULL) { + fprintf(stderr, + "fiddle-disclaim: this macOS cannot disclaim responsibility, so " + "%s was not started\n", + argv[1]); + return EX_UNAVAILABLE; + } + + posix_spawnattr_t attr; + int err = posix_spawnattr_init(&attr); + if (err == 0) err = posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETEXEC); + if (err == 0) err = setdisclaim(&attr, 1); + if (err != 0) { + fprintf(stderr, "fiddle-disclaim: cannot set up the spawn: %s\n", strerror(err)); + return EX_OSERR; + } + + /* Returns only when the exec failed. */ + err = posix_spawn(NULL, argv[1], NULL, &attr, argv + 1, environ); + fprintf(stderr, "fiddle-disclaim: cannot run %s: %s\n", argv[1], strerror(err)); + return err == ENOENT || err == ENOTDIR ? EXIT_NOT_FOUND : EXIT_NOT_EXECUTABLE; +} diff --git a/packages/app/native/disclaim/selfcheck.c b/packages/app/native/disclaim/selfcheck.c new file mode 100644 index 0000000000..17ee0adb4b --- /dev/null +++ b/packages/app/native/disclaim/selfcheck.c @@ -0,0 +1,161 @@ +/* + * selfcheck: proves that fiddle-disclaim disclaims responsibility, and that + * it is otherwise transparent, on the macOS it runs on. + * + * ./selfcheck + * + * Exits 0 when every check passes, 1 when one fails, 2 when it cannot check. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +/* Private libSystem API, so it is looked up at run time rather than linked. */ +static pid_t (*responsible_for)(pid_t); + +static int failures; + +static void check(int ok, const char *what) { + printf("%s %s\n", ok ? "ok " : "FAIL", what); + if (!ok) failures++; +} + +static int fatal(const char *what) { + fprintf(stderr, "selfcheck: %s\n", what); + return 2; +} + +/* The program side: the copies the check starts. */ +static int child_main(int argc, char **argv) { + const char *mode = argv[1]; + if (strcmp(mode, "--exit") == 0 && argc > 2) return atoi(argv[2]); + const char *marker = getenv("FIDDLE_SELFCHECK_MARKER"); + printf("pid=%d responsible=%d marker=%s args=", (int)getpid(), + (int)responsible_for(getpid()), marker ? marker : "-"); + for (int i = 2; i < argc; i++) printf("[%s]", argv[i]); + printf("\n"); + fflush(stdout); + if (strcmp(mode, "--wait") == 0) sleep(30); + return 0; +} + +/* Starts argv with its stdout on a pipe; returns the pid and the read end. */ +static pid_t start(char *const argv[], int *read_fd) { + int fds[2]; + if (pipe(fds) != 0) exit(fatal("pipe failed")); + posix_spawn_file_actions_t actions; + posix_spawn_file_actions_init(&actions); + posix_spawn_file_actions_adddup2(&actions, fds[1], STDOUT_FILENO); + posix_spawn_file_actions_addclose(&actions, fds[0]); + posix_spawn_file_actions_addclose(&actions, fds[1]); + pid_t pid; + int err = posix_spawn(&pid, argv[0], &actions, NULL, argv, environ); + posix_spawn_file_actions_destroy(&actions); + close(fds[1]); + if (err != 0) { + fprintf(stderr, "selfcheck: cannot start %s: %s\n", argv[0], strerror(err)); + exit(2); + } + *read_fd = fds[0]; + return pid; +} + +/* One line of the child's output, without the newline. */ +static void read_line(int fd, char *line, size_t size) { + size_t n = 0; + char c; + while (n + 1 < size && read(fd, &c, 1) == 1 && c != '\n') line[n++] = c; + line[n] = '\0'; +} + +static int wait_status(pid_t pid) { + int status = 0; + waitpid(pid, &status, 0); + return status; +} + +struct report { + int pid; + int responsible; + char rest[256]; +}; + +/* Runs argv, which prints a report line, and waits for it. */ +static struct report run_report(char *const argv[], pid_t *spawned) { + int fd; + *spawned = start(argv, &fd); + char line[512]; + read_line(fd, line, sizeof line); + close(fd); + wait_status(*spawned); + struct report r = {-1, -1, ""}; + sscanf(line, "pid=%d responsible=%d %255[^\n]", &r.pid, &r.responsible, r.rest); + return r; +} + +int main(int argc, char **argv) { + responsible_for = (pid_t (*)(pid_t))dlsym(RTLD_DEFAULT, + "responsibility_get_pid_responsible_for_pid"); + if (responsible_for == NULL) return fatal("this macOS cannot tell who is responsible"); + if (argc > 1 && strncmp(argv[1], "--", 2) == 0) return child_main(argc, argv); + if (argc != 2) return fatal("usage: selfcheck "); + + char *self = argv[0]; + char *helper = argv[1]; + char marker[64]; + snprintf(marker, sizeof marker, "FIDDLE_SELFCHECK_MARKER=%d", (int)getpid()); + if (putenv(marker) != 0) return fatal("putenv failed"); + char expected[512]; + snprintf(expected, sizeof expected, "marker=%d args=[one][two words]", (int)getpid()); + + pid_t spawned; + char *direct[] = {self, "--report", "one", "two words", NULL}; + struct report control = run_report(direct, &spawned); + check(control.responsible == (int)responsible_for(getpid()) && + control.responsible != control.pid, + "a program started directly shares this process's responsibility"); + + char *through[] = {helper, self, "--report", "one", "two words", NULL}; + struct report r = run_report(through, &spawned); + check(r.responsible == r.pid && r.pid > 0, "through the helper, it is its own responsible process"); + check(r.pid == (int)spawned, "the helper's pid becomes the program's pid"); + check(strcmp(r.rest, expected) == 0, "arguments and environment arrive unchanged"); + + char *exits[] = {helper, self, "--exit", "42", NULL}; + int fd; + pid_t pid = start(exits, &fd); + close(fd); + int status = wait_status(pid); + check(WIFEXITED(status) && WEXITSTATUS(status) == 42, "the program's exit status is the caller's"); + + char *waits[] = {helper, self, "--wait", NULL}; + pid = start(waits, &fd); + char line[512]; + read_line(fd, line, sizeof line); + close(fd); + kill(pid, SIGTERM); + status = wait_status(pid); + check(WIFSIGNALED(status) && WTERMSIG(status) == SIGTERM, "signals reach the program"); + + char *none[] = {helper, NULL}; + pid = start(none, &fd); + close(fd); + status = wait_status(pid); + check(WIFEXITED(status) && WEXITSTATUS(status) == 64, "no program: exit 64"); + + char *missing[] = {helper, "/no/such/program", NULL}; + pid = start(missing, &fd); + close(fd); + status = wait_status(pid); + check(WIFEXITED(status) && WEXITSTATUS(status) == 127, "a missing program: exit 127"); + + return failures == 0 ? 0 : 1; +} diff --git a/packages/app/package.json b/packages/app/package.json new file mode 100644 index 0000000000..c4cdc60ea7 --- /dev/null +++ b/packages/app/package.json @@ -0,0 +1,66 @@ +{ + "name": "electron-fiddle", + "productName": "Electron Fiddle", + "version": "1.0.0-alpha.0", + "private": true, + "description": "The easiest way to get started with Electron", + "repository": "https://github.com/electron/fiddle", + "homepage": "https://electronjs.org/fiddle", + "author": "Electron Community", + "license": "MIT", + "main": ".vite/build/main.cjs", + "scripts": { + "postinstall": "install-electron", + "generate": "node tools/generate.mjs", + "i18n:check": "node tools/i18n-check.mjs", + "i18n:translate": "node tools/i18n-translate.mjs", + "i18n:test": "node --test tools/i18n-translate.test.mjs", + "start": "node tools/generate.mjs && electron-forge start", + "start:xvfb": "node tools/generate.mjs && node tools/electron-run.mjs xvfb", + "fiddle": "node tools/electron-run.mjs headless", + "package": "electron-forge package", + "make": "electron-forge make", + "publish": "electron-forge publish", + "driver": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON tools/driver.ts", + "driver:build": "node tools/generate.mjs && node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON tools/driver-build.ts" + }, + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/fiddle-core": "workspace:*", + "@sentry/electron": "^7.18.0", + "i18next": "^26.4.2", + "monaco-editor": "^0.56.0", + "prettier": "^3.9.6", + "react": "^19.2.8", + "react-aria-components": "^1.21.1", + "react-dom": "^19.2.8", + "react-i18next": "^17.0.13", + "semver": "^7.8.5", + "sfw": "2.0.6", + "update-electron-app": "^3.3.0", + "zod": "^4.5.4" + }, + "devDependencies": { + "@electron-forge/cli": "8.0.0-alpha.10", + "@electron-forge/maker-deb": "8.0.0-alpha.10", + "@electron-forge/maker-msix": "8.0.0-alpha.10", + "@electron-forge/maker-rpm": "8.0.0-alpha.10", + "@electron-forge/maker-squirrel": "8.0.0-alpha.10", + "@electron-forge/maker-zip": "8.0.0-alpha.10", + "@electron-forge/plugin-fuses": "8.0.0-alpha.10", + "@electron-forge/plugin-vite": "8.0.0-alpha.10", + "@electron-forge/publisher-github": "8.0.0-alpha.10", + "@electron-forge/shared-types": "8.0.0-alpha.10", + "@electron/fuses": "^2.1.3", + "@marshallofsound/ipc": "^2.7.1", + "@reforged/maker-appimage": "^5.3.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@types/semver": "^7.8.0", + "@vitejs/plugin-react": "^6.1.1", + "electron": "^44.3.0", + "vite": "8.2.2" + } +} diff --git a/packages/app/src/fiddle/bisect.test.ts b/packages/app/src/fiddle/bisect.test.ts new file mode 100644 index 0000000000..0bcd00135a --- /dev/null +++ b/packages/app/src/fiddle/bisect.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { FiddleError } from '../shared/errors'; +import { bisectCompareUrl, Bisector, type BisectStep } from './bisect'; + +const versions = Array.from({ length: 10 }, (_, i) => `${i + 1}.0.0`); + +function run(bisector: Bisector, firstBad: number): BisectStep { + let step = bisector.current(); + let guard = 0; + while (!step.done && guard++ < 50) { + step = versions.indexOf(step.version) < firstBad ? bisector.good() : bisector.bad(); + } + return step; +} + +describe('Bisector', () => { + it('starts at the midpoint', () => { + expect(new Bisector(versions).current()).toEqual({ done: false, version: '5.0.0' }); + }); + + it.each(Array.from({ length: 9 }, (_, i) => i + 1))( + 'finds the first bad version at index %i', + (firstBad) => { + expect(run(new Bisector(versions), firstBad)).toEqual({ + done: true, + good: versions[firstBad - 1], + bad: versions[firstBad], + }); + }, + ); + + it('is done at once with two versions', () => { + expect(new Bisector(['1.0.0', '2.0.0']).current()).toEqual({ + done: true, + good: '1.0.0', + bad: '2.0.0', + }); + }); + + it('needs at least two versions', () => { + expect(() => new Bisector(['1.0.0'])).toThrow(FiddleError); + }); + + it('skips to a weighted-random version and never revisits it', () => { + const bisector = new Bisector(versions, () => 0); + expect(bisector.current()).toEqual({ done: false, version: '5.0.0' }); + expect(bisector.skip()).toEqual({ done: false, version: '2.0.0' }); + expect(bisector.good()).toEqual({ done: false, version: '6.0.0' }); + const high = new Bisector(versions, () => 0.9999); + expect(high.skip()).toEqual({ done: false, version: '9.0.0' }); + }); + + it('favours older versions when skipping', () => { + const picks = [0.1, 0.5, 0.9].map((r) => { + const bisector = new Bisector(versions, () => r); + const step = bisector.skip(); + return step.done ? null : versions.indexOf(step.version); + }); + // floor(r ** 1.5 * 7) over the candidates [1, 2, 3, 5, 6, 7, 8]. + expect(picks).toEqual([1, 3, 7]); + }); + + it('ends with the wider range when every candidate is skipped', () => { + const bisector = new Bisector(['1.0.0', '2.0.0', '3.0.0']); + expect(bisector.current()).toEqual({ done: false, version: '2.0.0' }); + expect(bisector.skip()).toEqual({ done: true, good: '1.0.0', bad: '3.0.0' }); + }); + + it('stays done', () => { + const bisector = new Bisector(['1.0.0', '2.0.0', '3.0.0']); + const done = bisector.bad(); + expect(done).toEqual({ done: true, good: '1.0.0', bad: '2.0.0' }); + expect(bisector.good()).toEqual(done); + expect(bisector.skip()).toEqual(done); + }); +}); + +describe('bisectCompareUrl', () => { + it('links the electron compare view', () => { + expect(bisectCompareUrl('30.0.0', '30.0.1')).toBe( + 'https://github.com/electron/electron/compare/v30.0.0...v30.0.1', + ); + }); +}); diff --git a/packages/app/src/fiddle/bisect.ts b/packages/app/src/fiddle/bisect.ts new file mode 100644 index 0000000000..8d15f8625b --- /dev/null +++ b/packages/app/src/fiddle/bisect.ts @@ -0,0 +1,88 @@ +import { ErrorCode, FiddleError } from '../shared/errors'; + +export type BisectStep = + | { done: false; version: string } + /** `good` is the last good version, `bad` the first bad one. */ + | { done: true; good: string; bad: string }; + +/** + * Manual bisect over versions ordered oldest → newest. The first is known + * good and the last known bad; each step tests a version strictly between. + */ +export class Bisector { + private lo = 0; + private hi: number; + private pivot = -1; + private readonly skipped = new Set(); + private step: BisectStep; + + constructor( + private readonly versions: readonly string[], + private readonly random: () => number = Math.random, + ) { + if (versions.length < 2) { + throw new FiddleError( + ErrorCode.invalidArgument, + 'Bisect needs at least two versions', + ); + } + this.hi = versions.length - 1; + this.step = this.next(); + } + + current(): BisectStep { + return this.step; + } + + good(): BisectStep { + if (this.step.done) return this.step; + this.lo = this.pivot; + return (this.step = this.next()); + } + + bad(): BisectStep { + if (this.step.done) return this.step; + this.hi = this.pivot; + return (this.step = this.next()); + } + + /** Skips the current version for a random one in range, weighted towards older versions. */ + skip(): BisectStep { + if (this.step.done) return this.step; + this.skipped.add(this.pivot); + const candidates = this.candidates(); + if (candidates.length === 0) return (this.step = this.finish()); + this.pivot = + candidates[ + Math.min( + candidates.length - 1, + Math.floor(this.random() ** 1.5 * candidates.length), + ) + ]!; + return (this.step = { done: false, version: this.versions[this.pivot]! }); + } + + private candidates(): number[] { + const result: number[] = []; + for (let i = this.lo + 1; i < this.hi; i++) if (!this.skipped.has(i)) result.push(i); + return result; + } + + private next(): BisectStep { + const candidates = this.candidates(); + if (candidates.length === 0) return this.finish(); + const mid = (this.lo + this.hi) / 2; + this.pivot = candidates.reduce((best, i) => + Math.abs(i - mid) < Math.abs(best - mid) ? i : best, + ); + return { done: false, version: this.versions[this.pivot]! }; + } + + private finish(): BisectStep { + return { done: true, good: this.versions[this.lo]!, bad: this.versions[this.hi]! }; + } +} + +export function bisectCompareUrl(good: string, bad: string): string { + return `https://github.com/electron/electron/compare/v${good}...v${bad}`; +} diff --git a/packages/app/src/fiddle/deep-link.test.ts b/packages/app/src/fiddle/deep-link.test.ts new file mode 100644 index 0000000000..6000affe84 --- /dev/null +++ b/packages/app/src/fiddle/deep-link.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; + +import { + type DeepLink, + findDeepLinkInArgv, + isDeepLink, + parseDeepLink, + versionFromTag, +} from './deep-link'; + +const ID = '8c5fc0c6a5153d49b5a4a56d3ed9da8f'; +const SHA = '0123456789abcdef0123456789abcdef01234567'; + +/** Real-world link shapes: docs "Open in Fiddle" links and gist links with and without an owner. */ +const valid: [string, DeepLink][] = [ + [`electron-fiddle://gist/${ID}`, { kind: 'gist', id: ID }], + [`electron-fiddle://gist/ckerr/${ID}`, { kind: 'gist', id: ID, owner: 'ckerr' }], + [ + `electron-fiddle://gist/some-user/${ID}/`, + { kind: 'gist', id: ID, owner: 'some-user' }, + ], + [`ELECTRON-FIDDLE://GIST/${ID.toUpperCase()}`, { kind: 'gist', id: ID }], + [ + `electron-fiddle://gist/${ID}?revision=${SHA}`, + { kind: 'gist', id: ID, revision: SHA }, + ], + [ + `electron-fiddle://gist/${ID}?revision=${SHA.toUpperCase()}&x=1`, + { kind: 'gist', id: ID, revision: SHA }, + ], + [`electron-fiddle://gist/${ID}?foo=bar#fragment`, { kind: 'gist', id: ID }], + [`electron-fiddle://gist/${ID}/#top`, { kind: 'gist', id: ID }], + [ + 'electron-fiddle://electron/v30.0.0/docs/fiddles/quick-start', + { + kind: 'electron', + tag: 'v30.0.0', + version: '30.0.0', + path: 'docs/fiddles/quick-start', + }, + ], + [ + 'electron-fiddle://electron/v22.0.0-beta.1/docs/fiddles/features/web-bluetooth', + { + kind: 'electron', + tag: 'v22.0.0-beta.1', + version: '22.0.0-beta.1', + path: 'docs/fiddles/features/web-bluetooth', + }, + ], + [ + 'electron-fiddle://electron/v27.0.0/docs/fiddles/menus/customize-menus/', + { + kind: 'electron', + tag: 'v27.0.0', + version: '27.0.0', + path: 'docs/fiddles/menus/customize-menus', + }, + ], + [ + 'electron-fiddle://Electron/28.1.0/docs/fiddles/ipc/pattern-3?revision=ignored', + { + kind: 'electron', + tag: '28.1.0', + version: '28.1.0', + path: 'docs/fiddles/ipc/pattern-3', + }, + ], +]; + +const invalid = [ + `electron-fiddle://gist/${ID.slice(1)}`, + `electron-fiddle://gist/${ID}0`, + `electron-fiddle://gist/${'g'.repeat(32)}`, + `electron-fiddle://gist/a/b/${ID}`, + 'electron-fiddle://gist', + 'electron-fiddle://gist/', + `electron-fiddle://gist/-bad/${ID}`, + `electron-fiddle://gist/bad-/${ID}`, + `electron-fiddle://gist/two--dashes/${ID}`, + `electron-fiddle://gist/${'a'.repeat(40)}/${ID}`, + `electron-fiddle://gist/${ID}?revision=abc`, + `electron-fiddle://gist/${ID}?revision=`, + `electron-fiddle://gist//${ID}`, + `electron-fiddle://gist/${ID}//`, + 'electron-fiddle://electron/v30.0.0', + 'electron-fiddle://electron/v30.0.0/', + 'electron-fiddle://electron/vfoo/docs/x', + 'electron-fiddle://electron/=30.0.0/docs/x', + 'electron-fiddle://electron/V30.0.0/docs/x', + 'electron-fiddle://electron/30/docs/x', + 'electron-fiddle://electron/v30.0.0/docs/../secret', + 'electron-fiddle://electron/v30.0.0/./docs', + 'electron-fiddle://electron/v30.0.0/docs//fiddles', + 'electron-fiddle://electron/v30.0.0/docs/%2e%2e/x', + 'electron-fiddle://electron/v30.0.0/docs%2Ffiddles', + 'electron-fiddle://electron/v30.0.0/docs%2ffiddles', + 'electron-fiddle://electron/v30.0.0/docs%5Cfiddles', + 'electron-fiddle://electron/v30.0.0/docs\\fiddles', + 'electron-fiddle://electron/v30.0.0/docs/%zz', + 'electron-fiddle://electron/v30.0.0/docs/a b', + 'electron-fiddle://electron/v30.0.0/docs%0Afiddles', + 'electron-fiddle://electron/v30.0.0/docs/%E2%80%AEx', + 'electron-fiddle://electron/v30.0.0/docs/%C2%85x', + 'electron-fiddle:///gist', + 'electron-fiddle:gist/x', + `https://gist.github.com/${ID}`, + '', +]; + +describe('parseDeepLink', () => { + it.each(valid)('parses %s', (url, link) => { + expect(parseDeepLink(url)).toEqual({ ok: true, url, link }); + }); + + it.each(invalid)('rejects %j and names the link', (url) => { + expect(parseDeepLink(url)).toEqual({ ok: false, url, error: 'invalid' }); + }); + + it('has a distinct result for unknown hosts', () => { + const url = 'electron-fiddle://workspace/abc'; + expect(parseDeepLink(url)).toEqual({ + ok: false, + url, + error: 'unknown-host', + host: 'workspace', + }); + expect(parseDeepLink('Electron-Fiddle://NEW')).toMatchObject({ + error: 'unknown-host', + host: 'new', + }); + }); +}); + +describe('versionFromTag', () => { + it('takes an optional v and a strict semver version', () => { + expect(versionFromTag('v30.0.0')).toBe('30.0.0'); + expect(versionFromTag('30.0.0-alpha.1')).toBe('30.0.0-alpha.1'); + expect(versionFromTag('vv30.0.0')).toBeNull(); + expect(versionFromTag('30.0')).toBeNull(); + expect(versionFromTag('main')).toBeNull(); + }); +}); + +describe('argv', () => { + it('finds the deep link in argv', () => { + const link = `electron-fiddle://gist/${ID}`; + expect( + findDeepLinkInArgv(['/opt/electron-fiddle', '--no-sandbox', link, 'other']), + ).toBe(link); + expect(findDeepLinkInArgv(['app', 'ELECTRON-FIDDLE://gist/x'])).toBe( + 'ELECTRON-FIDDLE://gist/x', + ); + expect(findDeepLinkInArgv(['app', '/some/folder'])).toBeUndefined(); + }); + + it('treats any electron-fiddle: argument as a link', () => { + expect(isDeepLink('electron-fiddle:anything')).toBe(true); + expect(isDeepLink('/path/electron-fiddle://x')).toBe(false); + }); +}); diff --git a/packages/app/src/fiddle/deep-link.ts b/packages/app/src/fiddle/deep-link.ts new file mode 100644 index 0000000000..b5ff6d791c --- /dev/null +++ b/packages/app/src/fiddle/deep-link.ts @@ -0,0 +1,114 @@ +import * as semver from 'semver'; + +import { isGistId, isRevisionSha } from './gist-id'; + +export const DEEP_LINK_SCHEME = 'electron-fiddle'; + +export type DeepLink = + | { kind: 'gist'; id: string; owner?: string; revision?: string } + | { kind: 'electron'; tag: string; version: string; path: string }; + +export type DeepLinkResult = + | { ok: true; url: string; link: DeepLink } + /** Malformed link: show an error that names `url`. */ + | { ok: false; url: string; error: 'invalid' } + /** A host this version doesn't know: "This link needs a newer version of Electron Fiddle". */ + | { ok: false; url: string; error: 'unknown-host'; host: string }; + +const PREFIX = `${DEEP_LINK_SCHEME}://`; +const LOGIN_RE = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i; +const BAD_CHARS_RE = + // eslint-disable-next-line no-control-regex + /[\s\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/; + +/** True for any `electron-fiddle:` argument, which is never treated as anything else. */ +export function isDeepLink(arg: string): boolean { + return arg.toLowerCase().startsWith(`${DEEP_LINK_SCHEME}:`); +} + +export function findDeepLinkInArgv(argv: readonly string[]): string | undefined { + return argv.find(isDeepLink); +} + +/** Decodes a path segment; null for empty, `.`, `..`, bad escapes and (encoded) separators or control characters. */ +function decodeSegment(raw: string): string | null { + let decoded: string; + try { + decoded = decodeURIComponent(raw); + } catch { + return null; + } + if ( + decoded === '' || + decoded === '.' || + decoded === '..' || + /[/\\]/.test(decoded) || + BAD_CHARS_RE.test(decoded) + ) + return null; + return decoded; +} + +/** The semver version for an optional-`v` tag, or null. Strict: no `=`, no loose forms. */ +export function versionFromTag(tag: string): string | null { + const version = tag.startsWith('v') ? tag.slice(1) : tag; + return semver.valid(version) === version ? version : null; +} + +/** Parses an `electron-fiddle://` link. Never throws. */ +export function parseDeepLink(url: string): DeepLinkResult { + const invalid: DeepLinkResult = { ok: false, url, error: 'invalid' }; + if (!url.toLowerCase().startsWith(PREFIX) || BAD_CHARS_RE.test(url)) return invalid; + + let rest = url.slice(PREFIX.length); + const hash = rest.indexOf('#'); + if (hash >= 0) rest = rest.slice(0, hash); + let query = ''; + const q = rest.indexOf('?'); + if (q >= 0) { + query = rest.slice(q + 1); + rest = rest.slice(0, q); + } + if (rest.endsWith('/')) rest = rest.slice(0, -1); + + const [rawHost = '', ...rawSegments] = rest.split('/'); + const host = rawHost.toLowerCase(); + if (host === '') return invalid; + + const segments: string[] = []; + for (const raw of rawSegments) { + const segment = decodeSegment(raw); + if (segment === null) return invalid; + segments.push(segment); + } + + switch (host) { + case 'gist': { + if (segments.length < 1 || segments.length > 2) return invalid; + const id = segments.at(-1)!; + const owner = segments.length === 2 ? segments[0]! : undefined; + if (!isGistId(id)) return invalid; + if (owner !== undefined && !LOGIN_RE.test(owner)) return invalid; + const params = new URLSearchParams(query); + const revision = params.get('revision'); + if (revision !== null && !isRevisionSha(revision)) return invalid; + const link: DeepLink = { kind: 'gist', id: id.toLowerCase() }; + if (owner !== undefined) link.owner = owner; + if (revision !== null) link.revision = revision.toLowerCase(); + return { ok: true, url, link }; + } + case 'electron': { + if (segments.length < 2) return invalid; + const tag = segments[0]!; + const version = versionFromTag(tag); + if (!version) return invalid; + return { + ok: true, + url, + link: { kind: 'electron', tag, version, path: segments.slice(1).join('/') }, + }; + } + default: + return { ok: false, url, error: 'unknown-host', host }; + } +} diff --git a/packages/app/src/fiddle/docs-examples.test.ts b/packages/app/src/fiddle/docs-examples.test.ts new file mode 100644 index 0000000000..6b4cefa241 --- /dev/null +++ b/packages/app/src/fiddle/docs-examples.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; + +import { ErrorCode } from '../shared/errors'; +import { loadDocsExample } from './docs-examples'; +import type { RepoContentEntry } from './github'; + +const template = { + 'main.js': '// template main', + 'preload.js': '// template preload', + 'index.html': '