From 0cbb9a2398bc1c36468fd9f9f2e01000cf1ce900 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Sat, 26 Sep 2026 19:15:46 +0000 Subject: [PATCH 1/4] Export and import a workspace's overrides and rules as one file Export writes the active workspace's overrides (content, the base when it differs, match, on/off, a response override's settings) and rules to one JSON file through a save dialog. Import adds a file's to the active workspace: each entry is checked as the app's own input, one the workspace already has is left out (its own version is kept), and the toast says what was added and left out before the page reloads. Both are in a menu on the Explorer's Overrides section and in the palette. --- CHANGELOG.md | 4 + README.md | 1 + docs/SPEC.md | 15 +- src/main/ipc/registerIpc.ts | 2 + src/main/ipc/registerOverridesFileIpc.ts | 42 +++++ src/main/overridesFile/buildOverridesFile.ts | 26 +++ src/main/overridesFile/constants.ts | 17 ++ .../overridesFile/exportedOverrideSchema.ts | 23 +++ src/main/overridesFile/exportedRuleOf.ts | 7 + src/main/overridesFile/importOverrides.ts | 27 +++ src/main/overridesFile/importRules.ts | 28 +++ src/main/overridesFile/index.ts | 7 + src/main/overridesFile/overrideInputOf.ts | 18 ++ src/main/overridesFile/overridesFileName.ts | 5 + src/main/overridesFile/readOverridesFile.ts | 20 +++ src/main/overridesFile/sameOverrideTarget.ts | 9 + src/main/overridesFile/types.ts | 22 +++ src/preload/index.ts | 2 + .../src/features/override/share/index.ts | 2 + .../override/share/model/countLabel.ts | 4 + .../override/share/model/exportOverrides.ts | 21 +++ .../override/share/model/importOverrides.ts | 29 +++ src/renderer/src/shared/lib/index.ts | 2 +- src/renderer/src/shared/lib/url/index.ts | 1 + .../src/shared/lib/url/pathFileName.ts | 7 + .../command-palette/ui/AppCommandPalette.tsx | 4 +- .../command-palette/ui/overrideFileItems.ts | 16 ++ .../src/widgets/explorer/ui/Explorer.tsx | 4 +- .../ui/OverrideList/ShareOverridesMenu.tsx | 24 +++ .../widgets/explorer/ui/OverrideList/index.ts | 1 + src/shared/ipcChannels.ts | 2 + src/shared/types/api.ts | 6 +- src/shared/types/index.ts | 2 +- src/shared/types/overrides.ts | 17 ++ test/e2e/app.e2e.test.ts | 41 ++++- test/renderer/share-overrides.test.ts | 63 +++++++ test/unit/overridesFile.test.ts | 168 ++++++++++++++++++ 37 files changed, 679 insertions(+), 10 deletions(-) create mode 100644 src/main/ipc/registerOverridesFileIpc.ts create mode 100644 src/main/overridesFile/buildOverridesFile.ts create mode 100644 src/main/overridesFile/constants.ts create mode 100644 src/main/overridesFile/exportedOverrideSchema.ts create mode 100644 src/main/overridesFile/exportedRuleOf.ts create mode 100644 src/main/overridesFile/importOverrides.ts create mode 100644 src/main/overridesFile/importRules.ts create mode 100644 src/main/overridesFile/index.ts create mode 100644 src/main/overridesFile/overrideInputOf.ts create mode 100644 src/main/overridesFile/overridesFileName.ts create mode 100644 src/main/overridesFile/readOverridesFile.ts create mode 100644 src/main/overridesFile/sameOverrideTarget.ts create mode 100644 src/main/overridesFile/types.ts create mode 100644 src/renderer/src/features/override/share/index.ts create mode 100644 src/renderer/src/features/override/share/model/countLabel.ts create mode 100644 src/renderer/src/features/override/share/model/exportOverrides.ts create mode 100644 src/renderer/src/features/override/share/model/importOverrides.ts create mode 100644 src/renderer/src/shared/lib/url/pathFileName.ts create mode 100644 src/renderer/src/widgets/command-palette/ui/overrideFileItems.ts create mode 100644 src/renderer/src/widgets/explorer/ui/OverrideList/ShareOverridesMenu.tsx create mode 100644 test/renderer/share-overrides.test.ts create mode 100644 test/unit/overridesFile.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b634431b..f4ee4717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Added + +- **Share your overrides.** The new menu on the Explorer's **Overrides** section (or the command palette) exports a workspace's overrides and rules as one file, and imports one: send it to a teammate and they see the page with your fix, or copy your changes to another workspace. An import adds only what the workspace doesn't have yet, so your own versions are kept, and tells you what it left out. + ## [0.4.0] - 2026-09-26 Console Editor now shows how a site works, not just what it loaded. Pick any element to see the React, Vue, Angular or web component behind it, traced to its source file even in a production build, and record why components rendered and what the page's stores did. A new Network panel overrides API responses and pauses requests, rules block requests and change headers, and actions run code in any frame with one click. diff --git a/README.md b/README.md index f1d67faa..d76088d4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Each workspace keeps its own page, tabs, overrides, rules and actions, so a fix - **Edit what the page actually loaded.** Scripts, stylesheets and HTML appear under **Page resources** as they load, grouped by origin, iframes and workers included. Minified bundles are pretty-printed when you open them. - **Save to serve it.** Ctrl/⌘ + S reloads the page with your version, before the page runs anything, on every load, until you switch the override off. +- **Share a fix.** Export a workspace's overrides and rules as one file; a teammate imports it and sees the same page. - **Survives deploys.** A hashed bundle name such as `main.3f9a1c2b.js` can be matched as `main.*.js` in one click, and you're warned when the live file changes under your override. **Diff** (Ctrl/⌘ + Shift + D) shows what you changed. - **Handles the hard cases:** compressed responses, Subresource Integrity (static and set at runtime), the HTTP cache, service workers and worklets, stale source maps. - **Reads the original sources.** When the site publishes source maps, expand a bundle to open the TypeScript, JSX or SCSS it was built from, and jump between a line of it and the bundle code it became (Ctrl/⌘ + Shift + M). diff --git a/docs/SPEC.md b/docs/SPEC.md index a0414c32..c6c68592 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -30,7 +30,7 @@ A desktop app where you enter a website's URL, see every script, stylesheet and | U11 | I can edit override files in my own editor (VS Code) and the app picks up changes | 🔜 M2 | | U12 | Overrides and file listing inside iframes, including cross-site (out-of-process) and nested ones | ✅ | | U12b | Overrides and file listing for what workers load: dedicated, shared and service workers, and worklets | ✅ (except a nested worker's first script, §6.6) | -| U13 | Export/import a workspace's overrides to share them with teammates | 🔜 M2 | +| U13 | Export/import a workspace's overrides to share them with teammates | ✅ (§5, Sharing) | | U14 | Use my own Chrome (existing profile, extensions) instead of the embedded browser | 🔜 M3 | | U15 | Browse original sources from source maps (read-only) and jump to the matching bundle code | ✅ (§6.8) | | U16 | I keep a workspace per site or task (its page, tabs, unsaved edits and overrides) and switch between them from the rail, which shows each one's favicon or a colour I pick | ✅ (§5.1) | @@ -338,6 +338,16 @@ session/drafts/.base.txt what that tab's editing started from (tabs not y A version 1 `session.json` (one page and its tabs) becomes the first workspace, and overrides and rules saved before workspaces existed (or whose workspace is gone) are given to the active one at start. +**Sharing.** **Export overrides and rules…** (the Explorer's Overrides menu, the palette) writes the active workspace's overrides and rules to one JSON file where the user picks (a native dialog, named after the page's host: `shop.test-overrides.json`); **Import overrides and rules…** adds a file's to the active workspace (`src/main/overridesFile/`): + +``` +{ format: 'console-editor-overrides', version: 1, + overrides: { kind, sourceUrl, match, enabled, originalHash, request?, response?, content, base? }[], // base only when it differs + rules: (CreateRuleInput & { enabled })[] } // no ids or times: the importing store sets them +``` + +An import reads a file of at most 200 MB listing at most 500 overrides (a newer `version` is refused, with a word to update), and checks each entry as the app's own input: an override's shapes (`exportedOverrideSchema`: a known kind, an http(s) source URL, a usable matcher, a SHA-256 or null) and then what a response override matches and answers (`responseFieldsOf`); a rule with `ruleInputSchema`. An entry that fails is left out and counted. So is one the workspace already has, which keeps its own: an override answering the same requests (kind, matcher and, for a response override, method and GraphQL operation), or a rule doing the same (`sameRuleInput`). The rest are created in order, turned off when they were off, and `overrides-changed` and `rules-changed` follow, even when a later one fails (a full workspace refuses a rule). The renderer says what was added and left out, and reloads the page if that setting is on. + **Rules on disk.** `RuleStore` writes a change to disk before it reaches memory (and so the engine); a failed write leaves the rules as they were. Entries this build can't read (a newer version's action or request type, a hand edit) are neither listed nor applied, and are written back as they were. A `rules.json` that isn't JSON of the right shape is moved to `rules.json.broken` and the app starts with no rules; one that can't be read, or moved aside, is left alone and every change is refused, so it is never written over. Either way the window says so once it opens. **Session restore.** The main process remembers the page URL on every main-frame navigation, for the active workspace. The renderer writes the tab list 300 ms after it changes and a tab's draft 800 ms after typing pauses (the base once per tab); a draft is deleted when its tab is saved, undone back to the saved text, or closed. Closing the window runs a handshake: main sends `flush-session`, the renderer writes whatever is pending and answers `sessionFlushed(ok)`, and only then does the window close (after 5 s, or if a write failed, it asks before closing). On start, the app loads the last URL (a URL on the command line wins), then reopens each tab: an override from the store, a tab with a draft entirely from disk (no network), any other tab by fetching the file again; drafts are applied as one undoable edit, so the tab shows as unsaved and undo reveals the saved text. Tab ids are unique across runs because they name the drafts. Session syncing starts only after restoring, so a fresh start never overwrites the session being restored. Each sync run is bound to one workspace: tab lists are written with its id, and one that arrives for a deleted workspace is ignored. @@ -717,6 +727,7 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g - Packaged builds drop `--remote-debugging-port`/`--remote-debugging-pipe` when the data folder is the default one (compared after resolving links, so pointing `CONSOLE_EDITOR_USER_DATA` at it doesn't count as another), as Chrome does for its default profile: otherwise any local program could start the app with a port and read the site view's logins. - One instance per data folder (`app.requestSingleInstanceLock`), so two processes never write the same overrides and session files. A second launch hands its URL to the running window and exits; if the first is still starting, that URL replaces the one it was about to open. On macOS, where Finder and `open` reopen the running app instead of starting a second process, the app also takes URLs from `open-url` the same way (following Electron's documentation; not yet checked on a Mac). Runs from source use a separate `… (dev)` data folder, so they never share one with an installed copy. - IPC inputs are type-checked; rules, rule edits, matchers and actions are parsed with the Zod schemas the forms use (`src/shared/rules`, `src/shared/actions`) before storage: unknown keys are dropped, a field of the wrong shape is refused by name (`Invalid rule: match`), and a value by what is wrong with it (header names are tokens, values hold no line breaks, protected headers are refused, at most 32 header changes and 200 rules per workspace). Files are read with the same schemas. Settings are filtered to known boolean keys. Source-map calls take only an http(s) bundle URL: main derives the map URL itself. +- An imported file of overrides is code the page will run, as a HAR import's responses are: only what the user picks is read, every entry is checked as the app's own input before it is stored, and nothing a workspace already has is replaced. - Original sources are only ever shown as editor text, and their names (which the page chooses) as plain labels with control characters removed. - The page stack's detector runs in each frame's main world, where the page can change what it answers: only known ids and signals are kept, versions only when they look like one, and everything shown besides a version is the app's own text (§6.12). The React hook stand-in is a global the page can see; **Framework hooks** turns it off. - The component inspector's adapter runs in the main world of the frame picked in, where the page could change what it answers: the answer is checked the same way (§6.13), and names, previews and keys are only ever shown as plain labels. Reading where a function is defined turns the page's Debugger domain on for a moment, with pauses skipped, and off again. Setting state runs in the page with a value the renderer wrote as JSON, parsed by main and passed as an argument, never as code. @@ -782,7 +793,7 @@ The package manager is asked rather than electron-builder's `resources/package-t - ✅ Workers: dedicated, shared and service workers, and worklets (§6.6). - Watch `workspace/files` for external edits (edit in VS Code, the app reloads the page), with an "Open in external editor" action. - ✅ Workspaces: a page, tabs and overrides per site or task (§5.1). -- Export/import a workspace's overrides as a zip or JSON, so a teammate can reproduce your fix. +- ✅ Export/import a workspace's overrides and rules as one JSON file, so a teammate can reproduce your fix (§5, Sharing). - ✅ Response header overrides (CORS, CSP, cache) and request blocking (e.g. disable an analytics script): rules (§6.3). - Network panel ([NETWORK_PANEL.md](NETWORK_PANEL.md)): ✅ the page's requests (§6.10), response overrides for fetch/XHR with a JSON editor (§6.3, §7), answering without sending the request and patching the live response (§6.3), breakpoints and Copy as fetch (§6.11), quick edits for UI states (§7), network speed, WebSocket messages (read-only) and HAR export and import (§6.10), and a response's JSON as a tree with in-place edits (§7). - Search across all page resources (find which bundle defines a function). diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 05c1d9a2..d50ed0d8 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -16,6 +16,7 @@ import { registerConsoleIpc } from './registerConsoleIpc'; import { registerHarIpc } from './registerHarIpc'; import { registerInspectorIpc } from './registerInspectorIpc'; import { registerNetworkIpc } from './registerNetworkIpc'; +import { registerOverridesFileIpc } from './registerOverridesFileIpc'; import { registerRuleIpc } from './registerRuleIpc'; import { registerSettingsIpc } from './registerSettingsIpc'; import { registerSourceMapIpc } from './registerSourceMapIpc'; @@ -117,6 +118,7 @@ export function registerIpc({ win, page, store, rules, settings, session, action registerActionsWindowIpc(handle, handleActions, actionsWindow); registerNetworkIpc(handle, page.network); registerHarIpc(handle, { win, page, store }); + registerOverridesFileIpc(handle, { win, page, store, rules }); handle(IPC_CHANNEL.getSession, () => session.get()); handle(IPC_CHANNEL.saveSessionTabs, (workspaceId: unknown, tabs: unknown, activeTabId: unknown) => session.setTabs(workspaceId, tabs, activeTabId)); diff --git a/src/main/ipc/registerOverridesFileIpc.ts b/src/main/ipc/registerOverridesFileIpc.ts new file mode 100644 index 00000000..6b1502f4 --- /dev/null +++ b/src/main/ipc/registerOverridesFileIpc.ts @@ -0,0 +1,42 @@ +import { writeFile } from 'node:fs/promises'; +import { dialog, type BrowserWindow } from 'electron'; +import { IPC_CHANNEL } from '../../shared/ipcChannels'; +import type { OverridesImport } from '../../shared/types'; +import { buildOverridesFile, importOverrides, importRules, OVERRIDES_FILE_FILTERS, overridesFileName, readOverridesFile } from '../overridesFile'; +import type { PageController } from '../PageController'; +import type { OverrideStore } from '../store/OverrideStore'; +import type { RuleStore } from '../store/RuleStore'; +import type { IpcHandle } from './types'; + +interface OverridesFileDeps { + win: BrowserWindow; + page: PageController; + store: OverrideStore; + rules: RuleStore; +} + +/** Exporting the active workspace's overrides and rules as one file, and importing one into it; each through a file dialog. */ +export function registerOverridesFileIpc(handle: IpcHandle, { win, page, store, rules }: OverridesFileDeps): void { + handle(IPC_CHANNEL.exportOverrides, async () => { + const { canceled, filePath } = await dialog.showSaveDialog(win, { title: 'Export overrides and rules', defaultPath: overridesFileName(page.state().url), filters: OVERRIDES_FILE_FILTERS }); + if (canceled || !filePath) return null; + const file = await buildOverridesFile(store, rules); + await writeFile(filePath, `${JSON.stringify(file, null, 2)}\n`); + return { path: filePath, overrides: file.overrides.length, rules: file.rules.length }; + }); + handle(IPC_CHANNEL.importOverrides, async () => { + const { canceled, filePaths } = await dialog.showOpenDialog(win, { title: 'Import overrides and rules', filters: OVERRIDES_FILE_FILTERS, properties: ['openFile'] }); + if (canceled || !filePaths[0]) return null; + const file = await readOverridesFile(filePaths[0]); + const counts: OverridesImport = { overrides: 0, rules: 0, present: 0, unreadable: 0 }; + try { + await importOverrides(store, file.overrides, counts); + await importRules(rules, file.rules, counts); + } finally { + // What was added before a failure is served, and listed, all the same. + if (counts.overrides) await page.overridesChanged(); + if (counts.rules) await page.rulesChanged(); + } + return counts; + }); +} diff --git a/src/main/overridesFile/buildOverridesFile.ts b/src/main/overridesFile/buildOverridesFile.ts new file mode 100644 index 00000000..8fd433f8 --- /dev/null +++ b/src/main/overridesFile/buildOverridesFile.ts @@ -0,0 +1,26 @@ +import type { OverrideStore } from '../store/OverrideStore'; +import type { RuleStore } from '../store/RuleStore'; +import { OVERRIDES_FILE_FORMAT, OVERRIDES_FILE_VERSION } from './constants'; +import { exportedRuleOf } from './exportedRuleOf'; +import type { ExportedOverride, OverridesFile } from './types'; + +/** The active workspace's overrides and rules, as an export file holds them. */ +export async function buildOverridesFile(store: OverrideStore, rules: RuleStore): Promise { + const overrides: ExportedOverride[] = []; + for (const o of store.list()) { + const base = await store.base(o.id); + overrides.push({ + kind: o.kind, + sourceUrl: o.sourceUrl, + match: o.match, + enabled: o.enabled, + originalHash: o.originalHash, + ...(o.request ? { request: o.request } : {}), + ...(o.response ? { response: o.response } : {}), + content: o.content, + // Diffs, and a response override patching the live response (what changed from it), need the base. + ...(base !== o.content ? { base } : {}), + }); + } + return { format: OVERRIDES_FILE_FORMAT, version: OVERRIDES_FILE_VERSION, overrides, rules: rules.forRenderer().map(exportedRuleOf) }; +} diff --git a/src/main/overridesFile/constants.ts b/src/main/overridesFile/constants.ts new file mode 100644 index 00000000..236a72d9 --- /dev/null +++ b/src/main/overridesFile/constants.ts @@ -0,0 +1,17 @@ +/** What an export of overrides says it is, so an import can tell it from any other JSON. */ +export const OVERRIDES_FILE_FORMAT = 'console-editor-overrides'; + +/** The version of the file written; an import refuses a newer one. */ +export const OVERRIDES_FILE_VERSION = 1; + +/** The file types the export and import dialogs offer. */ +export const OVERRIDES_FILE_FILTERS = [{ name: 'Console Editor overrides', extensions: ['json'] }]; + +/** The biggest file imported, in bytes: overrides hold whole bundles. */ +export const MAX_OVERRIDES_FILE_BYTES = 200 * 1024 * 1024; + +/** The most overrides one file may list. */ +export const MAX_IMPORTED_OVERRIDES = 500; + +/** A SHA-256 as the engine writes it: what an override's `originalHash` holds. */ +export const SHA256_HEX = /^[0-9a-f]{64}$/; diff --git a/src/main/overridesFile/exportedOverrideSchema.ts b/src/main/overridesFile/exportedOverrideSchema.ts new file mode 100644 index 00000000..da4df8d1 --- /dev/null +++ b/src/main/overridesFile/exportedOverrideSchema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { urlMatcherSchema } from '../../shared/matcher'; +import { headerEditSchema } from '../../shared/rules'; +import { RESOURCE_KINDS, type ResourceKind } from '../../shared/types'; +import { HTTP_URL } from '../constants'; +import { MAX_URL_CHARS } from '../ipc/constants'; +import { SHA256_HEX } from './constants'; + +/** + * An override as an export file lists it. The shapes only: what a response override matches and + * answers is checked as the store checks it (`responseFieldsOf`). + */ +export const exportedOverrideSchema = z.object({ + kind: z.custom((value) => RESOURCE_KINDS.includes(value as ResourceKind)), + sourceUrl: z.string().max(MAX_URL_CHARS).regex(HTTP_URL), + match: urlMatcherSchema, + enabled: z.boolean(), + originalHash: z.string().regex(SHA256_HEX).nullable(), + request: z.object({ method: z.string(), operation: z.string() }).optional(), + response: z.object({ status: z.number(), delayMs: z.number(), headers: z.array(headerEditSchema), send: z.boolean(), patch: z.boolean() }).optional(), + content: z.string(), + base: z.string().optional(), +}); diff --git a/src/main/overridesFile/exportedRuleOf.ts b/src/main/overridesFile/exportedRuleOf.ts new file mode 100644 index 00000000..18b0c055 --- /dev/null +++ b/src/main/overridesFile/exportedRuleOf.ts @@ -0,0 +1,7 @@ +import type { Rule } from '../../shared/types'; +import type { ExportedRule } from './types'; + +/** A rule as an export lists it: without its id and times, which the importing store sets. */ +export function exportedRuleOf({ id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...rule }: Rule): ExportedRule { + return rule as ExportedRule; +} diff --git a/src/main/overridesFile/importOverrides.ts b/src/main/overridesFile/importOverrides.ts new file mode 100644 index 00000000..e8c6af7e --- /dev/null +++ b/src/main/overridesFile/importOverrides.ts @@ -0,0 +1,27 @@ +import type { OverridesImport } from '../../shared/types'; +import type { OverrideStore } from '../store/OverrideStore'; +import { overrideInputOf } from './overrideInputOf'; +import { sameOverrideTarget } from './sameOverrideTarget'; + +/** + * Adds an export's overrides to the active workspace, counting into `counts`: none that one of its + * own already answers (it keeps yours), and none that can't be read. + */ +export async function importOverrides(store: OverrideStore, entries: readonly unknown[], counts: OverridesImport): Promise { + for (const entry of entries) { + const input = overrideInputOf(entry); + if (!input) { + counts.unreadable++; + continue; + } + // Listed again each time, so a file naming one twice adds it once. + if (store.list().some((o) => sameOverrideTarget(o, input))) { + counts.present++; + continue; + } + const { enabled, ...create } = input; + const created = await store.create(create); + if (!enabled) await store.update(created.id, { enabled }); + counts.overrides++; + } +} diff --git a/src/main/overridesFile/importRules.ts b/src/main/overridesFile/importRules.ts new file mode 100644 index 00000000..158acc6e --- /dev/null +++ b/src/main/overridesFile/importRules.ts @@ -0,0 +1,28 @@ +import { ruleInputSchema, sameRuleInput } from '../../shared/rules'; +import type { CreateRuleInput, OverridesImport } from '../../shared/types'; +import { isRecord } from '../store/isRecord'; +import { parseInput } from '../store/parseInput'; +import type { RuleStore } from '../store/RuleStore'; + +/** + * Adds an export's rules to the active workspace, counting into `counts`: none that one of its own + * already does, and none that can't be read. A store that refuses one (the workspace is full) stops it. + */ +export async function importRules(rules: RuleStore, entries: readonly unknown[], counts: OverridesImport): Promise { + for (const entry of entries) { + let input: CreateRuleInput; + try { + input = parseInput(ruleInputSchema, entry, 'rule'); + } catch { + counts.unreadable++; + continue; + } + if (rules.forRenderer().some((rule) => sameRuleInput(rule, input))) { + counts.present++; + continue; + } + const created = await rules.create(input); + if (isRecord(entry) && entry.enabled === false) await rules.update(created.id, { enabled: false }); + counts.rules++; + } +} diff --git a/src/main/overridesFile/index.ts b/src/main/overridesFile/index.ts new file mode 100644 index 00000000..b949d1fa --- /dev/null +++ b/src/main/overridesFile/index.ts @@ -0,0 +1,7 @@ +export { buildOverridesFile } from './buildOverridesFile'; +export { OVERRIDES_FILE_FILTERS } from './constants'; +export { importOverrides } from './importOverrides'; +export { importRules } from './importRules'; +export { overridesFileName } from './overridesFileName'; +export { readOverridesFile } from './readOverridesFile'; +export type { OverridesFile } from './types'; diff --git a/src/main/overridesFile/overrideInputOf.ts b/src/main/overridesFile/overrideInputOf.ts new file mode 100644 index 00000000..2eab0163 --- /dev/null +++ b/src/main/overridesFile/overrideInputOf.ts @@ -0,0 +1,18 @@ +import type { CreateOverrideInput } from '../../shared/types'; +import { responseFieldsOf } from '../store/OverrideStore/responseFieldsOf'; +import { exportedOverrideSchema } from './exportedOverrideSchema'; + +/** + * What recreates an override an export file lists, checked as the app's own input is, with whether + * it was on; null for an entry that isn't one (a hand edit, a newer version's). + */ +export function overrideInputOf(entry: unknown): (CreateOverrideInput & { enabled: boolean }) | null { + const parsed = exportedOverrideSchema.safeParse(entry); + if (!parsed.success) return null; + const { request, response, ...input } = parsed.data; + try { + return { ...input, ...responseFieldsOf(input.kind, request, response) }; + } catch { + return null; + } +} diff --git a/src/main/overridesFile/overridesFileName.ts b/src/main/overridesFile/overridesFileName.ts new file mode 100644 index 00000000..e635b2a2 --- /dev/null +++ b/src/main/overridesFile/overridesFileName.ts @@ -0,0 +1,5 @@ +/** What an export of overrides is called at first: the page's host (`shop.test-overrides.json`). */ +export function overridesFileName(pageUrl: string): string { + const host = URL.canParse(pageUrl) ? new URL(pageUrl).hostname : ''; + return `${host || 'workspace'}-overrides.json`; +} diff --git a/src/main/overridesFile/readOverridesFile.ts b/src/main/overridesFile/readOverridesFile.ts new file mode 100644 index 00000000..f36becf9 --- /dev/null +++ b/src/main/overridesFile/readOverridesFile.ts @@ -0,0 +1,20 @@ +import { readFile, stat } from 'node:fs/promises'; +import { isRecord } from '../store/isRecord'; +import { MAX_IMPORTED_OVERRIDES, MAX_OVERRIDES_FILE_BYTES, OVERRIDES_FILE_FORMAT, OVERRIDES_FILE_VERSION } from './constants'; +import type { OverridesFileEntries } from './types'; + +/** An export file's overrides and rules, as found. Throws for a file too big, not JSON, not an export, or made by a newer version. */ +export async function readOverridesFile(path: string): Promise { + if ((await stat(path)).size > MAX_OVERRIDES_FILE_BYTES) throw new Error(`That file is over ${MAX_OVERRIDES_FILE_BYTES / 1024 / 1024} MB`); + let file: unknown; + try { + file = JSON.parse(await readFile(path, 'utf8')); + } catch { + throw new Error("That file isn't JSON, so it isn't an export of overrides"); + } + if (!isRecord(file) || file.format !== OVERRIDES_FILE_FORMAT) throw new Error("That file isn't an export of Console Editor's overrides"); + if (typeof file.version !== 'number' || file.version > OVERRIDES_FILE_VERSION) throw new Error('A newer version of Console Editor made that file: update the app to import it'); + const overrides = Array.isArray(file.overrides) ? file.overrides : []; + if (overrides.length > MAX_IMPORTED_OVERRIDES) throw new Error(`That file lists over ${MAX_IMPORTED_OVERRIDES} overrides`); + return { overrides, rules: Array.isArray(file.rules) ? file.rules : [] }; +} diff --git a/src/main/overridesFile/sameOverrideTarget.ts b/src/main/overridesFile/sameOverrideTarget.ts new file mode 100644 index 00000000..2e8a755d --- /dev/null +++ b/src/main/overridesFile/sameOverrideTarget.ts @@ -0,0 +1,9 @@ +import { sameMatcher } from '../../shared/matcher'; +import type { CreateOverrideInput } from '../../shared/types'; + +type Target = Pick; + +/** Whether two overrides answer the same requests: kind, matcher and, for a response override, method and GraphQL operation. */ +export function sameOverrideTarget(a: Target, b: Target): boolean { + return a.kind === b.kind && !!a.match && !!b.match && sameMatcher(a.match, b.match) && a.request?.method === b.request?.method && a.request?.operation === b.request?.operation; +} diff --git a/src/main/overridesFile/types.ts b/src/main/overridesFile/types.ts new file mode 100644 index 00000000..f299c35b --- /dev/null +++ b/src/main/overridesFile/types.ts @@ -0,0 +1,22 @@ +import type { CreateOverrideInput, CreateRuleInput } from '../../shared/types'; + +/** An override as an export lists it: what recreates it, its content, and the text editing started from when that differs. */ +export type ExportedOverride = Required> & + Pick & { enabled: boolean }; + +/** A rule as an export lists it. */ +export type ExportedRule = CreateRuleInput & { enabled: boolean }; + +/** A workspace's overrides and rules, as one JSON file to import elsewhere (another workspace, a teammate's app). */ +export interface OverridesFile { + format: string; + version: number; + overrides: ExportedOverride[]; + rules: ExportedRule[]; +} + +/** An export file's entries, as found (each checked when imported). */ +export interface OverridesFileEntries { + overrides: unknown[]; + rules: unknown[]; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index e2413911..e3a3b59b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -25,6 +25,8 @@ const api: ConsoleEditorApi = { forgetSourceMapFile: (bundleUrl) => ipcRenderer.invoke(IPC_CHANNEL.forgetSourceMapFile, bundleUrl), listOverrides: () => ipcRenderer.invoke(IPC_CHANNEL.listOverrides), + exportOverrides: () => ipcRenderer.invoke(IPC_CHANNEL.exportOverrides), + importOverrides: () => ipcRenderer.invoke(IPC_CHANNEL.importOverrides), getOverride: (id) => ipcRenderer.invoke(IPC_CHANNEL.getOverride, id), getOverrideBase: (id) => ipcRenderer.invoke(IPC_CHANNEL.getOverrideBase, id), createOverride: (input) => ipcRenderer.invoke(IPC_CHANNEL.createOverride, input), diff --git a/src/renderer/src/features/override/share/index.ts b/src/renderer/src/features/override/share/index.ts new file mode 100644 index 00000000..d3586b20 --- /dev/null +++ b/src/renderer/src/features/override/share/index.ts @@ -0,0 +1,2 @@ +export { exportOverrides } from './model/exportOverrides'; +export { importOverrides } from './model/importOverrides'; diff --git a/src/renderer/src/features/override/share/model/countLabel.ts b/src/renderer/src/features/override/share/model/countLabel.ts new file mode 100644 index 00000000..914b7a5f --- /dev/null +++ b/src/renderer/src/features/override/share/model/countLabel.ts @@ -0,0 +1,4 @@ +/** `1 override`, `3 rules`: a count and its noun. */ +export function countLabel(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}`; +} diff --git a/src/renderer/src/features/override/share/model/exportOverrides.ts b/src/renderer/src/features/override/share/model/exportOverrides.ts new file mode 100644 index 00000000..b07f39c2 --- /dev/null +++ b/src/renderer/src/features/override/share/model/exportOverrides.ts @@ -0,0 +1,21 @@ +import { api, errorMessage } from '@/shared/api'; +import { TOAST_DURATION } from '@/shared/config'; +import { pathFileName } from '@/shared/lib'; +import { toast } from '@/shared/ui/toast'; +import { countLabel } from './countLabel'; + +/** Saves the active workspace's overrides and rules as one file where the user picks, to import in another workspace or a teammate's app. */ +export async function exportOverrides(): Promise { + try { + const result = await api.exportOverrides(); + if (!result) return; + toast({ + title: `Exported ${countLabel(result.overrides, 'override')} and ${countLabel(result.rules, 'rule')}`, + description: `Saved as ${pathFileName(result.path)}. Import it in another workspace, or send it to a teammate.`, + tone: 'success', + duration: TOAST_DURATION.actionable, + }); + } catch (err) { + toast({ title: 'Could not export the overrides', description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/override/share/model/importOverrides.ts b/src/renderer/src/features/override/share/model/importOverrides.ts new file mode 100644 index 00000000..d5dfdd2c --- /dev/null +++ b/src/renderer/src/features/override/share/model/importOverrides.ts @@ -0,0 +1,29 @@ +import { api, errorMessage } from '@/shared/api'; +import { TOAST_DURATION } from '@/shared/config'; +import { toast } from '@/shared/ui/toast'; +import { useSettingsStore } from '@/entities/settings'; +import { countLabel } from './countLabel'; + +/** Adds the overrides and rules of an export the user picks to the active workspace, says what it left out, and reloads the page as a save does. */ +export async function importOverrides(): Promise { + try { + const result = await api.importOverrides(); + if (!result) return; + const added = [result.overrides ? countLabel(result.overrides, 'override') : '', result.rules ? countLabel(result.rules, 'rule') : ''].filter(Boolean); + const reload = added.length > 0 && useSettingsStore.getState().settings.autoReloadOnSave; + const notes = [ + result.present ? `${result.present} already in this workspace (yours kept).` : '', + result.unreadable ? `${result.unreadable} couldn't be read.` : '', + reload ? 'Reloading the page.' : '', + ].filter(Boolean); + toast({ + title: added.length ? `Imported ${added.join(' and ')}` : 'Nothing to import', + description: notes.join(' ') || undefined, + tone: added.length ? 'success' : 'warning', + duration: TOAST_DURATION.actionable, + }); + if (reload) await api.reload(); + } catch (err) { + toast({ title: 'Could not import the overrides', description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/shared/lib/index.ts b/src/renderer/src/shared/lib/index.ts index f924465c..b76e365c 100644 --- a/src/renderer/src/shared/lib/index.ts +++ b/src/renderer/src/shared/lib/index.ts @@ -3,7 +3,7 @@ export { clamp, clampPosition } from './clamp'; export { cn } from './cn'; export { firstErrorMessage } from './form'; export { DURATION, EASE_OUT, ICON_PRESS_SCALE, PRESS_SCALE, SLIDE_IN_X, SPRING_LAYOUT, SPRING_PANEL, SPRING_PRESS, SPRING_SWAP } from './motion'; -export { fileName, hostOf, pathOf, pathSegments, webAddress } from './url'; +export { fileName, hostOf, pathFileName, pathOf, pathSegments, webAddress } from './url'; export { predicateFor } from './match'; export { isMac, keyLabel } from './platform'; export { formatCode, looksMinified, responseText } from './format'; diff --git a/src/renderer/src/shared/lib/url/index.ts b/src/renderer/src/shared/lib/url/index.ts index be71ead8..0a9e6167 100644 --- a/src/renderer/src/shared/lib/url/index.ts +++ b/src/renderer/src/shared/lib/url/index.ts @@ -1,5 +1,6 @@ export { fileName } from './fileName'; export { hostOf } from './hostOf'; +export { pathFileName } from './pathFileName'; export { pathOf } from './pathOf'; export { pathSegments } from './pathSegments'; export { webAddress } from './webAddress'; diff --git a/src/renderer/src/shared/lib/url/pathFileName.ts b/src/renderer/src/shared/lib/url/pathFileName.ts new file mode 100644 index 00000000..a248ea0a --- /dev/null +++ b/src/renderer/src/shared/lib/url/pathFileName.ts @@ -0,0 +1,7 @@ +/** Either separator of a file path: `/`, or Windows' `\`. */ +const PATH_SEPARATOR = /[\\/]/; + +/** A file path's last part, the file's name (`/home/me/shop.json` → `shop.json`, `C:\x\shop.json` too). */ +export function pathFileName(path: string): string { + return path.split(PATH_SEPARATOR).pop() || path; +} diff --git a/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx b/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx index 3ebfa03d..4d117272 100644 --- a/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx +++ b/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { SHORTCUT } from '@common/constants'; -import { api } from '@/shared/api'; import { icons } from '@/shared/config'; import { fileName, hostOf, pathOf } from '@/shared/lib'; import { CommandPalette, type CommandGroup } from '@/shared/ui/command-palette'; @@ -26,6 +25,7 @@ import { sourceActions, useOriginalSources } from '../model/sources'; import { KIND_ICON, OVERRIDE_ITEM_PREFIX, WORKSPACE_ITEM_PREFIX } from './constants'; import { inspectItems, type InspectLog } from './inspectItems'; import { newRuleItems } from './newRuleItems'; +import { overrideFileItems } from './overrideFileItems'; import { ruleItems } from './ruleItems'; import { saveItem } from './saveItem'; @@ -106,7 +106,7 @@ export function AppCommandPalette({ onShowSettings, onShowExplorer, onFocusAddre ...inspectItems({ renders: recordingRenders, stores: recordingStores }, onShowLog), { id: 'devtools', label: 'Open DevTools for the page', icon: icons.DevToolsIcon, shortcut: SHORTCUT.pageDevTools, onSelect: () => void openPageDevTools() }, ...newRuleItems(), - { id: 'folder', label: 'Open the overrides folder', icon: icons.FolderIcon, onSelect: () => void api.revealOverridesFolder() }, + ...overrideFileItems(overrides.length + rules.length > 0), { id: 'settings', label: 'Settings', icon: icons.SettingsIcon, onSelect: onShowSettings }, { id: 'whats-new', label: "What's New", icon: icons.WhatsNewIcon, keywords: ['release notes', 'changelog', 'version'], onSelect: openWhatsNew }, { id: 'check-updates', label: 'Check for updates', icon: icons.DownloadIcon, keywords: ['update', 'upgrade', 'version'], onSelect: () => void checkForUpdatesNow() }, diff --git a/src/renderer/src/widgets/command-palette/ui/overrideFileItems.ts b/src/renderer/src/widgets/command-palette/ui/overrideFileItems.ts new file mode 100644 index 00000000..1822c1f3 --- /dev/null +++ b/src/renderer/src/widgets/command-palette/ui/overrideFileItems.ts @@ -0,0 +1,16 @@ +import { api } from '@/shared/api'; +import { icons } from '@/shared/config'; +import type { CommandItem } from '@/shared/ui/command-palette'; +import { exportOverrides, importOverrides } from '@/features/override/share'; + +/** Keywords for sharing overrides as a file. */ +const SHARE_KEYWORDS = ['share', 'teammate', 'file', 'json']; + +/** The workspace's override files: their folder, exporting them with its rules (when it has any) as one file, and importing one. */ +export function overrideFileItems(hasAny: boolean): CommandItem[] { + return [ + { id: 'folder', label: 'Open the overrides folder', icon: icons.FolderIcon, onSelect: () => void api.revealOverridesFolder() }, + ...(hasAny ? [{ id: 'overrides-export', label: 'Export overrides and rules…', icon: icons.ExportIcon, keywords: SHARE_KEYWORDS, onSelect: () => void exportOverrides() }] : []), + { id: 'overrides-import', label: 'Import overrides and rules…', icon: icons.ImportIcon, keywords: SHARE_KEYWORDS, onSelect: () => void importOverrides() }, + ]; +} diff --git a/src/renderer/src/widgets/explorer/ui/Explorer.tsx b/src/renderer/src/widgets/explorer/ui/Explorer.tsx index dddb49c6..e290b3ff 100644 --- a/src/renderer/src/widgets/explorer/ui/Explorer.tsx +++ b/src/renderer/src/widgets/explorer/ui/Explorer.tsx @@ -11,7 +11,7 @@ import { selectOverrideList, useOverrideStore } from '@/entities/override'; import { selectResourceCount, useResourceStore } from '@/entities/resource'; import { useRuleStore } from '@/entities/rule'; import { useResourceFilter } from '@/features/filter-resources'; -import { OverrideList } from './OverrideList'; +import { OverrideList, ShareOverridesMenu } from './OverrideList'; import { ResourceTree } from './ResourceTree'; import { NewRuleMenu, RuleList } from './RuleList'; @@ -47,7 +47,7 @@ export function Explorer() { />
-
+
} actionsVisible="always" defaultOpen>
} actionsVisible="always" defaultOpen> diff --git a/src/renderer/src/widgets/explorer/ui/OverrideList/ShareOverridesMenu.tsx b/src/renderer/src/widgets/explorer/ui/OverrideList/ShareOverridesMenu.tsx new file mode 100644 index 00000000..d75a4389 --- /dev/null +++ b/src/renderer/src/widgets/explorer/ui/OverrideList/ShareOverridesMenu.tsx @@ -0,0 +1,24 @@ +import { icons } from '@/shared/config'; +import { IconButton } from '@/shared/ui/icon-button'; +import { Menu } from '@/shared/ui/menu'; +import { useOverrideStore } from '@/entities/override'; +import { useRuleStore } from '@/entities/rule'; +import { exportOverrides, importOverrides } from '@/features/override/share'; + +/** The Overrides section's menu: the workspace's overrides and rules saved as one file, or a file's added to it (a teammate's fix). */ +export function ShareOverridesMenu() { + const hasOverrides = useOverrideStore((s) => Object.keys(s.byId).length > 0); + const hasRules = useRuleStore((s) => Object.keys(s.byId).length > 0); + return ( + void exportOverrides() }, + { label: 'Import overrides and rules…', icon: icons.ImportIcon, onSelect: () => void importOverrides() }, + ]} + > + + + ); +} diff --git a/src/renderer/src/widgets/explorer/ui/OverrideList/index.ts b/src/renderer/src/widgets/explorer/ui/OverrideList/index.ts index b67d7475..4a003e09 100644 --- a/src/renderer/src/widgets/explorer/ui/OverrideList/index.ts +++ b/src/renderer/src/widgets/explorer/ui/OverrideList/index.ts @@ -1,2 +1,3 @@ // Keeps Explorer's `./OverrideList` import working now that each component has its own file. export { OverrideList } from './OverrideList'; +export { ShareOverridesMenu } from './ShareOverridesMenu'; diff --git a/src/shared/ipcChannels.ts b/src/shared/ipcChannels.ts index a49b25a4..888c185f 100644 --- a/src/shared/ipcChannels.ts +++ b/src/shared/ipcChannels.ts @@ -79,6 +79,8 @@ export const IPC_CHANNEL = { getNetworkMessages: 'network:messages', exportHar: 'har:export', importHar: 'har:import', + exportOverrides: 'overrides:export', + importOverrides: 'overrides:import', clearNetworkLog: 'network:clear', listHeldRequests: 'network:held', resumeHeldRequest: 'network:resume', diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9cc84981..f511d991 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -3,7 +3,7 @@ import type { ConsoleEntry, ConsoleFrame, ConsoleProperty } from './console'; import type { InspectorApi } from './inspector'; import type { WireEvent } from './events'; import type { NetworkApi } from './networkApi'; -import type { CreateOverrideInput, OverrideMeta, OverridePatch, OverrideWithContent } from './overrides'; +import type { CreateOverrideInput, OverrideMeta, OverridePatch, OverridesExport, OverridesImport, OverrideWithContent } from './overrides'; import type { PageState, Rect } from './page'; import type { ResourceContent, ResourceEntry } from './resources'; import type { CreateRuleInput, Rule, RulePatch } from './rules'; @@ -48,6 +48,10 @@ export interface ConsoleEditorApi extends InspectorApi, NetworkApi { /** The active workspace's overrides. */ listOverrides(): Promise; + /** Saves the active workspace's overrides and rules as one file where the user picks; null when cancelled. */ + exportOverrides(): Promise; + /** Adds the overrides and rules of an export the user picks to the active workspace; null when cancelled. */ + importOverrides(): Promise; /** Metadata + served content. Large files cross IPC once, when a tab opens. */ getOverride(id: string): Promise; /** The diff base, fetched only when a diff is actually shown. */ diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 97d3fefa..431645c1 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -46,7 +46,7 @@ export type { export { INSPECT_FRAMEWORKS, RENDER_KINDS, RENDER_REASONS, STATE_KINDS, STORE_LIBRARIES } from './inspector'; export type { MenuCommand } from './menu'; export type { HarImport, HttpHeader, NetworkBody, NetworkBodyGap, NetworkRequest, NetworkRequestDetail, NetworkRequestState, SocketDirection, SocketMessage, SocketMessages } from './network'; -export type { CreateOverrideInput, MatchType, Override, OverrideMeta, OverridePatch, OverrideWithContent, RequestMatch, ResponseSettings, UnpatchedReason, UrlMatcher } from './overrides'; +export type { CreateOverrideInput, MatchType, Override, OverrideMeta, OverridePatch, OverridesExport, OverridesImport, OverrideWithContent, RequestMatch, ResponseSettings, UnpatchedReason, UrlMatcher } from './overrides'; export { MATCH_TYPES } from './overrides'; export type { PageState, Rect } from './page'; export type { FileKind, ResourceContent, ResourceEntry, ResourceKind } from './resources'; diff --git a/src/shared/types/overrides.ts b/src/shared/types/overrides.ts index 0adc4164..d011e3cc 100644 --- a/src/shared/types/overrides.ts +++ b/src/shared/types/overrides.ts @@ -95,6 +95,23 @@ export interface CreateOverrideInput { response?: ResponseSettings; } +/** What exporting the active workspace's overrides and rules wrote. */ +export interface OverridesExport { + path: string; + overrides: number; + rules: number; +} + +/** What importing an export of overrides and rules did to the active workspace. */ +export interface OverridesImport { + overrides: number; + rules: number; + /** Left out: the workspace already has an override answering the same requests, or a rule doing the same. */ + present: number; + /** Left out: not an override or rule this version can read (a hand edit, a newer version's). */ + unreadable: number; +} + export interface OverridePatch { content?: string; match?: UrlMatcher; diff --git a/test/e2e/app.e2e.test.ts b/test/e2e/app.e2e.test.ts index c4016e09..1a90fdf2 100644 --- a/test/e2e/app.e2e.test.ts +++ b/test/e2e/app.e2e.test.ts @@ -4,7 +4,7 @@ * code. */ import { execFile } from 'node:child_process'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; @@ -241,4 +241,43 @@ describe.skipIf(!built)('Console Editor app', () => { await expect.poll(() => inSite('location.pathname'), { timeout: 15_000 }).toBe('/store/'); await expect.poll(() => win.locator('[data-override-id]').count()).toBe(0); }); + + it("shares a workspace's overrides as a file: another workspace imports them, and keeps its own", async () => { + const tiles = () => win.getByTestId('workspace-tile'); + const overrideRows = () => win.locator('[data-override-id]'); + const menuItem = (name: string) => win.getByRole('menuitem', { name }); + const exported = join(userData, 'shared-overrides.json'); + // Stand-ins for the native file dialogs, which Playwright can't drive. + await app.evaluate(({ dialog }, path) => { + dialog.showSaveDialog = (async () => ({ canceled: false, filePath: path })) as unknown as typeof dialog.showSaveDialog; + dialog.showOpenDialog = (async () => ({ canceled: false, filePaths: [path] })) as unknown as typeof dialog.showOpenDialog; + }, exported); + + await tiles().first().click(); + await expect.poll(() => overrideRows().count()).toBe(4); + await win.getByTestId('overrides-share').click(); + await menuItem('Export overrides and rules…').click(); + await expect.poll(async () => JSON.parse(await readFile(exported, 'utf8').catch(() => '{}')).overrides?.length).toBe(4); + expect(JSON.parse(await readFile(exported, 'utf8'))).toMatchObject({ format: 'console-editor-overrides', version: 1 }); + + // The second workspace has none of them, until it imports the file. + await tiles().nth(1).click(); + await expect.poll(() => tiles().nth(1).getAttribute('aria-current')).toBe('true'); + await expect.poll(() => overrideRows().count()).toBe(0); + await goTo(win, site.url); + await expect.poll(() => inSite('window.appValue'), { timeout: 15_000 }).toBe('original'); + expect(await inSite('typeof window.patchedByEditor')).toBe('undefined'); + await expect.poll(() => win.getByRole('menu').count()).toBe(0); + await win.getByTestId('overrides-share').click(); + await menuItem('Import overrides and rules…').click(); + await expect.poll(() => overrideRows().count()).toBe(4); + await expect.poll(() => inSite('window.patchedByEditor'), { timeout: 15_000 }).toBe(true); + + // Importing it again adds nothing: the workspace has them all. + await expect.poll(() => win.getByRole('menu').count()).toBe(0); + await win.getByTestId('overrides-share').click(); + await menuItem('Import overrides and rules…').click(); + await win.getByText('Nothing to import').waitFor(); + expect(await overrideRows().count()).toBe(4); + }); }); diff --git a/test/renderer/share-overrides.test.ts b/test/renderer/share-overrides.test.ts new file mode 100644 index 00000000..e000cfb2 --- /dev/null +++ b/test/renderer/share-overrides.test.ts @@ -0,0 +1,63 @@ +/** Exporting and importing a workspace's overrides and rules: what the toasts say, and the reload after an import. */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SETTINGS } from '../../src/shared/types'; +import { useSettingsStore } from '@/entities/settings'; +import { exportOverrides, importOverrides } from '@/features/override/share'; + +const api = vi.hoisted(() => ({ exportOverrides: vi.fn(), importOverrides: vi.fn(), reload: vi.fn(async () => {}) })); +const toast = vi.hoisted(() => Object.assign(vi.fn(() => 'toast-1'), { dismiss: vi.fn(), update: vi.fn() })); + +vi.mock('@/shared/api', () => ({ api, onAppEvent: () => () => {}, errorMessage: (err: unknown) => (err as Error).message })); +vi.mock('@/shared/ui/toast', () => ({ toast })); + +beforeEach(() => { + vi.clearAllMocks(); + useSettingsStore.getState().setSettings(DEFAULT_SETTINGS); +}); + +describe('export', () => { + it('says what it saved, and where', async () => { + api.exportOverrides.mockResolvedValue({ path: '/home/me/shop.test-overrides.json', overrides: 3, rules: 1 }); + await exportOverrides(); + expect(toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Exported 3 overrides and 1 rule', description: expect.stringContaining('Saved as shop.test-overrides.json.'), tone: 'success' })); + }); + + it('says nothing when the dialog was cancelled, and why it failed otherwise', async () => { + api.exportOverrides.mockResolvedValue(null); + await exportOverrides(); + expect(toast).not.toHaveBeenCalled(); + api.exportOverrides.mockRejectedValue(new Error('EACCES')); + await exportOverrides(); + expect(toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Could not export the overrides', description: 'EACCES', tone: 'danger' })); + }); +}); + +describe('import', () => { + it('says what it added and left out, and reloads the page', async () => { + api.importOverrides.mockResolvedValue({ overrides: 2, rules: 1, present: 1, unreadable: 1 }); + await importOverrides(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Imported 2 overrides and 1 rule', description: "1 already in this workspace (yours kept). 1 couldn't be read. Reloading the page.", tone: 'success' }), + ); + expect(api.reload).toHaveBeenCalledTimes(1); + }); + + it("names only what it added, and doesn't reload when nothing was, or reloading is off", async () => { + api.importOverrides.mockResolvedValue({ overrides: 0, rules: 2, present: 0, unreadable: 0 }); + useSettingsStore.getState().setSettings({ ...DEFAULT_SETTINGS, autoReloadOnSave: false }); + await importOverrides(); + expect(toast).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Imported 2 rules', description: undefined })); + + api.importOverrides.mockResolvedValue({ overrides: 0, rules: 0, present: 4, unreadable: 0 }); + useSettingsStore.getState().setSettings(DEFAULT_SETTINGS); + await importOverrides(); + expect(toast).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Nothing to import', description: '4 already in this workspace (yours kept).', tone: 'warning' })); + expect(api.reload).not.toHaveBeenCalled(); + }); + + it('says why a file was refused', async () => { + api.importOverrides.mockRejectedValue(new Error("That file isn't an export of Console Editor's overrides")); + await importOverrides(); + expect(toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Could not import the overrides', tone: 'danger' })); + }); +}); diff --git a/test/unit/overridesFile.test.ts b/test/unit/overridesFile.test.ts new file mode 100644 index 00000000..9f57d95e --- /dev/null +++ b/test/unit/overridesFile.test.ts @@ -0,0 +1,168 @@ +/** + * A workspace's overrides and rules as one file: what an export holds, and what an import adds to + * another workspace (never one it already has, never an entry it can't read). + */ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { buildOverridesFile, importOverrides, importRules, overridesFileName, readOverridesFile, type OverridesFile } from '../../src/main/overridesFile'; +import { OverrideStore } from '../../src/main/store/OverrideStore'; +import { RuleStore } from '../../src/main/store/RuleStore'; +import type { OverridesImport } from '../../src/shared/types'; + +let dir: string; +let store: OverrideStore; +let rules: RuleStore; + +const HASH = 'a'.repeat(64); +const script = { kind: 'Script' as const, sourceUrl: 'https://a.com/app.js', content: 'mine();', base: 'original();', originalHash: HASH }; +const response = { + kind: 'Fetch' as const, + sourceUrl: 'https://a.com/api/cart', + content: '{"items":[]}', + originalHash: null, + request: { method: 'POST', operation: 'Cart' }, + response: { status: 503, delayMs: 3000, headers: [{ operation: 'set' as const, name: 'Retry-After', value: '5' }], send: false, patch: false }, +}; +const block = { action: 'block' as const, match: { type: 'exact' as const, pattern: 'https://a.com/ads.js', ignoreQuery: true }, resourceTypes: [] }; +const cors = { action: 'cors' as const, match: { type: 'glob' as const, pattern: 'https://api.a.com/*', ignoreQuery: false }, resourceTypes: [] }; + +const counts = (): OverridesImport => ({ overrides: 0, rules: 0, present: 0, unreadable: 0 }); +/** Imports `file` into the active workspace, as the IPC handler does. */ +async function importFile(file: Pick): Promise { + const result = counts(); + await importOverrides(store, file.overrides, result); + await importRules(rules, file.rules, result); + return result; +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'console-editor-share-')); + store = new OverrideStore(dir); + rules = new RuleStore(dir); + await Promise.all([store.load(), rules.load()]); + store.setWorkspace('ws1'); + rules.setWorkspace('ws1'); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +/** The first workspace: a script edited from its base, a response answered without sending, one turned off; a block rule and a CORS one turned off. */ +async function fillFirstWorkspace(): Promise { + await store.create(script); + await store.create(response); + const off = await store.create({ kind: 'Stylesheet', sourceUrl: 'https://a.com/app.css', content: 'body{}', originalHash: null }); + await store.update(off.id, { enabled: false }); + await rules.create(block); + const corsRule = await rules.create(cors); + await rules.update(corsRule.id, { enabled: false }); +} + +describe('export', () => { + it("lists the active workspace's overrides with their content and base, and its rules, without ids", async () => { + await fillFirstWorkspace(); + store.setWorkspace('ws2'); + await store.create({ ...script, sourceUrl: 'https://b.com/other.js' }); + store.setWorkspace('ws1'); + + const file = await buildOverridesFile(store, rules); + expect(file).toMatchObject({ format: 'console-editor-overrides', version: 1 }); + expect(file.overrides).toEqual([ + { kind: 'Script', sourceUrl: script.sourceUrl, match: { type: 'exact', pattern: script.sourceUrl, ignoreQuery: true }, enabled: true, originalHash: HASH, content: 'mine();', base: 'original();' }, + expect.objectContaining({ kind: 'Fetch', request: response.request, response: response.response, content: response.content }), + expect.objectContaining({ kind: 'Stylesheet', enabled: false }), + ]); + // No base is written when editing started from the content itself. + expect(file.overrides[1]).not.toHaveProperty('base'); + expect(file.rules).toEqual([ + { ...block, enabled: true }, + { ...cors, enabled: false }, + ]); + expect(JSON.stringify(file)).not.toMatch(/"(id|createdAt|updatedAt|workspaceId)"/); + }); + + it('is named after the page', () => { + expect(overridesFileName('https://shop.test/cart?x=1')).toBe('shop.test-overrides.json'); + expect(overridesFileName('')).toBe('workspace-overrides.json'); + }); +}); + +describe('import', () => { + it('recreates them in another workspace as they were: content, base, settings, on or off', async () => { + await fillFirstWorkspace(); + const file = JSON.parse(JSON.stringify(await buildOverridesFile(store, rules))) as OverridesFile; + store.setWorkspace('ws2'); + rules.setWorkspace('ws2'); + + expect(await importFile(file)).toEqual({ overrides: 3, rules: 2, present: 0, unreadable: 0 }); + const [first, second, third] = store.list(); + expect(first).toMatchObject({ kind: 'Script', content: 'mine();', enabled: true, originalHash: HASH }); + expect(await store.base(first.id)).toBe('original();'); + expect(second).toMatchObject({ request: response.request, response: response.response }); + expect(third.enabled).toBe(false); + expect(rules.forRenderer().map((r) => [r.action, r.enabled])).toEqual([ + ['block', true], + ['cors', false], + ]); + // The first workspace's are untouched. + store.setWorkspace('ws1'); + expect(store.list()).toHaveLength(3); + }); + + it('keeps what the workspace already has, and adds one listed twice once', async () => { + await store.create({ ...script, content: 'my own version();' }); + await rules.create(block); + const file = await buildOverridesFile(store, rules); + const other = { ...file.overrides[0], sourceUrl: 'https://a.com/b.js', match: { type: 'exact' as const, pattern: 'https://a.com/b.js', ignoreQuery: true } }; + + expect(await importFile({ overrides: [{ ...file.overrides[0], content: 'theirs();' }, other, other], rules: file.rules })).toEqual({ overrides: 1, rules: 0, present: 3, unreadable: 0 }); + expect(store.list().map((o) => o.content)).toEqual(['my own version();', 'my own version();']); + }); + + it("tells a response override apart by its method and operation", async () => { + await store.create(response); + const file = await buildOverridesFile(store, rules); + const getter = { ...file.overrides[0], request: { method: 'GET', operation: 'Cart' } }; + expect(await importFile({ overrides: [getter], rules: [] })).toMatchObject({ overrides: 1, present: 0 }); + }); + + it("leaves out what it can't read, and adds the rest", async () => { + const good = { ...script, match: { type: 'exact', pattern: script.sourceUrl, ignoreQuery: true }, enabled: true }; + const unreadable = [ + null, + { ...good, kind: 'Image' }, + { ...good, sourceUrl: 'ftp://a.com/app.js' }, + { ...good, match: { type: 'regex', pattern: '(', ignoreQuery: false } }, + { ...good, originalHash: 'abc' }, + { ...good, content: 42 }, + // Only a response override matches a method. + { ...good, request: { method: 'GET', operation: '' } }, + { ...good, kind: 'Fetch', response: { ...response.response, status: 999 } }, + ]; + const rulesIn = [{ ...block, action: 'rewrite' }, { ...block, match: { type: 'exact', pattern: 'data:text/plain,x', ignoreQuery: false } }, cors]; + expect(await importFile({ overrides: [...unreadable, good], rules: rulesIn } as never)).toEqual({ overrides: 1, rules: 1, present: 0, unreadable: 10 }); + expect(store.list()).toHaveLength(1); + }); +}); + +describe('reading a file', () => { + const write = async (content: unknown) => { + const path = join(dir, 'import.json'); + await writeFile(path, typeof content === 'string' ? content : JSON.stringify(content)); + return path; + }; + + it('takes an export, with a missing list read as empty', async () => { + expect(await readOverridesFile(await write({ format: 'console-editor-overrides', version: 1, overrides: [{ a: 1 }] }))).toEqual({ overrides: [{ a: 1 }], rules: [] }); + }); + + it('refuses a file that is not JSON, not an export, from a newer version, or too long', async () => { + await expect(readOverridesFile(await write('{nope'))).rejects.toThrow("isn't JSON"); + await expect(readOverridesFile(await write({ log: { entries: [] } }))).rejects.toThrow("isn't an export of Console Editor's overrides"); + await expect(readOverridesFile(await write({ format: 'console-editor-overrides', version: 2 }))).rejects.toThrow('newer version'); + await expect(readOverridesFile(await write({ format: 'console-editor-overrides', version: 1, overrides: Array(501).fill({}) }))).rejects.toThrow('over 500 overrides'); + }); +}); From e26ca0deeed1bee6bfcae37d9992a8630eafabd4 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Sat, 26 Sep 2026 19:28:16 +0000 Subject: [PATCH 2/4] Wait for the workspace switch to settle before opening the Overrides menu in e2e A menu closes when the window blurs or its list scrolls, both of which a switch can do while its page loads and its tabs come back; the test opened the menu right after the override rows appeared. --- test/e2e/app.e2e.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/e2e/app.e2e.test.ts b/test/e2e/app.e2e.test.ts index 1a90fdf2..6e2230aa 100644 --- a/test/e2e/app.e2e.test.ts +++ b/test/e2e/app.e2e.test.ts @@ -254,7 +254,11 @@ describe.skipIf(!built)('Console Editor app', () => { }, exported); await tiles().first().click(); + await expect.poll(() => tiles().first().getAttribute('aria-current')).toBe('true'); await expect.poll(() => overrideRows().count()).toBe(4); + // Once the switch has settled (its page loaded, its tabs back): a menu closes when the window blurs or its list scrolls. + await expect.poll(() => inSite('window.patchedByEditor'), { timeout: 15_000 }).toBe(true); + await expect.poll(() => win.locator('[role="tab"]').count()).toBeGreaterThan(0); await win.getByTestId('overrides-share').click(); await menuItem('Export overrides and rules…').click(); await expect.poll(async () => JSON.parse(await readFile(exported, 'utf8').catch(() => '{}')).overrides?.length).toBe(4); From ea62bcce42ee4d83970c89af946f4b53089e8c9d Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Sat, 26 Sep 2026 20:24:24 +0000 Subject: [PATCH 3/4] Read exports of overrides with schemas in src/shared, bound to their types The file's envelope and each override it lists are read with Zod schemas in src/shared/overrides (overridesFileSchema, exportedOverrideSchema), bound with z.toZod to the types in src/shared/types/overridesFile.ts, and the envelope goes through parseInput as other files do. MAX_URL_CHARS moves to src/shared/constants.ts, since the schema needs it too. --- docs/SPEC.md | 2 +- src/main/ipc/assertBundleUrl.ts | 2 +- src/main/ipc/assertSourceMapRequest.ts | 2 +- src/main/ipc/constants.ts | 2 - src/main/overridesFile/buildOverridesFile.ts | 5 +- src/main/overridesFile/constants.ts | 12 ----- .../overridesFile/exportedOverrideSchema.ts | 23 -------- src/main/overridesFile/exportedRuleOf.ts | 3 +- src/main/overridesFile/index.ts | 1 - src/main/overridesFile/overrideInputOf.ts | 2 +- src/main/overridesFile/readOverridesFile.ts | 15 +++--- src/main/overridesFile/types.ts | 22 -------- src/shared/constants.ts | 3 ++ src/shared/overrides/constants.ts | 15 ++++++ .../overrides/exportedOverrideSchema.ts | 31 +++++++++++ src/shared/overrides/index.ts | 4 +- src/shared/overrides/overridesFileSchema.ts | 22 ++++++++ src/shared/types/api.ts | 3 +- src/shared/types/index.ts | 3 +- src/shared/types/overrides.ts | 17 ------ src/shared/types/overridesFile.ts | 52 +++++++++++++++++++ test/unit/overridesFile.test.ts | 6 +-- 22 files changed, 147 insertions(+), 100 deletions(-) delete mode 100644 src/main/ipc/constants.ts delete mode 100644 src/main/overridesFile/exportedOverrideSchema.ts delete mode 100644 src/main/overridesFile/types.ts create mode 100644 src/shared/overrides/exportedOverrideSchema.ts create mode 100644 src/shared/overrides/overridesFileSchema.ts create mode 100644 src/shared/types/overridesFile.ts diff --git a/docs/SPEC.md b/docs/SPEC.md index c6c68592..af7e9013 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -346,7 +346,7 @@ A version 1 `session.json` (one page and its tabs) becomes the first workspace, rules: (CreateRuleInput & { enabled })[] } // no ids or times: the importing store sets them ``` -An import reads a file of at most 200 MB listing at most 500 overrides (a newer `version` is refused, with a word to update), and checks each entry as the app's own input: an override's shapes (`exportedOverrideSchema`: a known kind, an http(s) source URL, a usable matcher, a SHA-256 or null) and then what a response override matches and answers (`responseFieldsOf`); a rule with `ruleInputSchema`. An entry that fails is left out and counted. So is one the workspace already has, which keeps its own: an override answering the same requests (kind, matcher and, for a response override, method and GraphQL operation), or a rule doing the same (`sameRuleInput`). The rest are created in order, turned off when they were off, and `overrides-changed` and `rules-changed` follow, even when a later one fails (a full workspace refuses a rule). The renderer says what was added and left out, and reloads the page if that setting is on. +An import reads a file of at most 200 MB with `overridesFileSchema` (`src/shared/overrides`): the format, a `version` this build reads (a newer one is refused, with a word to update), at most 500 overrides. It then checks each entry as the app's own input: an override's shapes with `exportedOverrideSchema` (a known kind, an http(s) source URL, a usable matcher, a SHA-256 or null), then what a response override matches and answers as the store does (`responseFieldsOf`); a rule with `ruleInputSchema`. An entry that fails is left out and counted. So is one the workspace already has, which keeps its own: an override answering the same requests (kind, matcher and, for a response override, method and GraphQL operation), or a rule doing the same (`sameRuleInput`). The rest are created in order, turned off when they were off, and `overrides-changed` and `rules-changed` follow, even when a later one fails (a full workspace refuses a rule). The renderer says what was added and left out, and reloads the page if that setting is on. **Rules on disk.** `RuleStore` writes a change to disk before it reaches memory (and so the engine); a failed write leaves the rules as they were. Entries this build can't read (a newer version's action or request type, a hand edit) are neither listed nor applied, and are written back as they were. A `rules.json` that isn't JSON of the right shape is moved to `rules.json.broken` and the app starts with no rules; one that can't be read, or moved aside, is left alone and every change is refused, so it is never written over. Either way the window says so once it opens. diff --git a/src/main/ipc/assertBundleUrl.ts b/src/main/ipc/assertBundleUrl.ts index 203fd5c3..3262cc66 100644 --- a/src/main/ipc/assertBundleUrl.ts +++ b/src/main/ipc/assertBundleUrl.ts @@ -1,5 +1,5 @@ import { HTTP_URL } from '../constants'; -import { MAX_URL_CHARS } from './constants'; +import { MAX_URL_CHARS } from '../../shared/constants'; /** Checks a bundle URL from the renderer: an http(s) URL of a length a listed file can have. */ export function assertBundleUrl(value: unknown): asserts value is string { diff --git a/src/main/ipc/assertSourceMapRequest.ts b/src/main/ipc/assertSourceMapRequest.ts index d684306d..98853ca9 100644 --- a/src/main/ipc/assertSourceMapRequest.ts +++ b/src/main/ipc/assertSourceMapRequest.ts @@ -1,6 +1,6 @@ import { SOURCE_MAP_KINDS, type SourceMapKind, type SourceMapRequest } from '../../shared/types'; import { assertBundleUrl } from './assertBundleUrl'; -import { MAX_URL_CHARS } from './constants'; +import { MAX_URL_CHARS } from '../../shared/constants'; /** A content hash as the engine writes it (sha256, hex). */ const CONTENT_HASH = /^[0-9a-f]{64}$/; diff --git a/src/main/ipc/constants.ts b/src/main/ipc/constants.ts deleted file mode 100644 index c80509ef..00000000 --- a/src/main/ipc/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Longer URLs aren't a bundle the page listed. */ -export const MAX_URL_CHARS = 8192; diff --git a/src/main/overridesFile/buildOverridesFile.ts b/src/main/overridesFile/buildOverridesFile.ts index 8fd433f8..f7539a8e 100644 --- a/src/main/overridesFile/buildOverridesFile.ts +++ b/src/main/overridesFile/buildOverridesFile.ts @@ -1,8 +1,9 @@ +import { OVERRIDES_FILE_FORMAT, OVERRIDES_FILE_VERSION } from '../../shared/overrides'; +import type { ExportedOverride, OverridesFile } from '../../shared/types'; import type { OverrideStore } from '../store/OverrideStore'; import type { RuleStore } from '../store/RuleStore'; -import { OVERRIDES_FILE_FORMAT, OVERRIDES_FILE_VERSION } from './constants'; import { exportedRuleOf } from './exportedRuleOf'; -import type { ExportedOverride, OverridesFile } from './types'; + /** The active workspace's overrides and rules, as an export file holds them. */ export async function buildOverridesFile(store: OverrideStore, rules: RuleStore): Promise { diff --git a/src/main/overridesFile/constants.ts b/src/main/overridesFile/constants.ts index 236a72d9..5510334c 100644 --- a/src/main/overridesFile/constants.ts +++ b/src/main/overridesFile/constants.ts @@ -1,17 +1,5 @@ -/** What an export of overrides says it is, so an import can tell it from any other JSON. */ -export const OVERRIDES_FILE_FORMAT = 'console-editor-overrides'; - -/** The version of the file written; an import refuses a newer one. */ -export const OVERRIDES_FILE_VERSION = 1; - /** The file types the export and import dialogs offer. */ export const OVERRIDES_FILE_FILTERS = [{ name: 'Console Editor overrides', extensions: ['json'] }]; /** The biggest file imported, in bytes: overrides hold whole bundles. */ export const MAX_OVERRIDES_FILE_BYTES = 200 * 1024 * 1024; - -/** The most overrides one file may list. */ -export const MAX_IMPORTED_OVERRIDES = 500; - -/** A SHA-256 as the engine writes it: what an override's `originalHash` holds. */ -export const SHA256_HEX = /^[0-9a-f]{64}$/; diff --git a/src/main/overridesFile/exportedOverrideSchema.ts b/src/main/overridesFile/exportedOverrideSchema.ts deleted file mode 100644 index da4df8d1..00000000 --- a/src/main/overridesFile/exportedOverrideSchema.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from 'zod'; -import { urlMatcherSchema } from '../../shared/matcher'; -import { headerEditSchema } from '../../shared/rules'; -import { RESOURCE_KINDS, type ResourceKind } from '../../shared/types'; -import { HTTP_URL } from '../constants'; -import { MAX_URL_CHARS } from '../ipc/constants'; -import { SHA256_HEX } from './constants'; - -/** - * An override as an export file lists it. The shapes only: what a response override matches and - * answers is checked as the store checks it (`responseFieldsOf`). - */ -export const exportedOverrideSchema = z.object({ - kind: z.custom((value) => RESOURCE_KINDS.includes(value as ResourceKind)), - sourceUrl: z.string().max(MAX_URL_CHARS).regex(HTTP_URL), - match: urlMatcherSchema, - enabled: z.boolean(), - originalHash: z.string().regex(SHA256_HEX).nullable(), - request: z.object({ method: z.string(), operation: z.string() }).optional(), - response: z.object({ status: z.number(), delayMs: z.number(), headers: z.array(headerEditSchema), send: z.boolean(), patch: z.boolean() }).optional(), - content: z.string(), - base: z.string().optional(), -}); diff --git a/src/main/overridesFile/exportedRuleOf.ts b/src/main/overridesFile/exportedRuleOf.ts index 18b0c055..c6531760 100644 --- a/src/main/overridesFile/exportedRuleOf.ts +++ b/src/main/overridesFile/exportedRuleOf.ts @@ -1,5 +1,4 @@ -import type { Rule } from '../../shared/types'; -import type { ExportedRule } from './types'; +import type { ExportedRule, Rule } from '../../shared/types'; /** A rule as an export lists it: without its id and times, which the importing store sets. */ export function exportedRuleOf({ id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...rule }: Rule): ExportedRule { diff --git a/src/main/overridesFile/index.ts b/src/main/overridesFile/index.ts index b949d1fa..21c93fd2 100644 --- a/src/main/overridesFile/index.ts +++ b/src/main/overridesFile/index.ts @@ -4,4 +4,3 @@ export { importOverrides } from './importOverrides'; export { importRules } from './importRules'; export { overridesFileName } from './overridesFileName'; export { readOverridesFile } from './readOverridesFile'; -export type { OverridesFile } from './types'; diff --git a/src/main/overridesFile/overrideInputOf.ts b/src/main/overridesFile/overrideInputOf.ts index 2eab0163..e64b89bd 100644 --- a/src/main/overridesFile/overrideInputOf.ts +++ b/src/main/overridesFile/overrideInputOf.ts @@ -1,6 +1,6 @@ +import { exportedOverrideSchema } from '../../shared/overrides'; import type { CreateOverrideInput } from '../../shared/types'; import { responseFieldsOf } from '../store/OverrideStore/responseFieldsOf'; -import { exportedOverrideSchema } from './exportedOverrideSchema'; /** * What recreates an override an export file lists, checked as the app's own input is, with whether diff --git a/src/main/overridesFile/readOverridesFile.ts b/src/main/overridesFile/readOverridesFile.ts index f36becf9..9e59462a 100644 --- a/src/main/overridesFile/readOverridesFile.ts +++ b/src/main/overridesFile/readOverridesFile.ts @@ -1,9 +1,10 @@ import { readFile, stat } from 'node:fs/promises'; -import { isRecord } from '../store/isRecord'; -import { MAX_IMPORTED_OVERRIDES, MAX_OVERRIDES_FILE_BYTES, OVERRIDES_FILE_FORMAT, OVERRIDES_FILE_VERSION } from './constants'; -import type { OverridesFileEntries } from './types'; +import { overridesFileSchema } from '../../shared/overrides'; +import type { OverridesFileEntries } from '../../shared/types'; +import { parseInput } from '../store/parseInput'; +import { MAX_OVERRIDES_FILE_BYTES } from './constants'; -/** An export file's overrides and rules, as found. Throws for a file too big, not JSON, not an export, or made by a newer version. */ +/** An export's overrides and rules, as found. Throws for a file too big, not JSON, not an export, or made by a newer version. */ export async function readOverridesFile(path: string): Promise { if ((await stat(path)).size > MAX_OVERRIDES_FILE_BYTES) throw new Error(`That file is over ${MAX_OVERRIDES_FILE_BYTES / 1024 / 1024} MB`); let file: unknown; @@ -12,9 +13,5 @@ export async function readOverridesFile(path: string): Promise OVERRIDES_FILE_VERSION) throw new Error('A newer version of Console Editor made that file: update the app to import it'); - const overrides = Array.isArray(file.overrides) ? file.overrides : []; - if (overrides.length > MAX_IMPORTED_OVERRIDES) throw new Error(`That file lists over ${MAX_IMPORTED_OVERRIDES} overrides`); - return { overrides, rules: Array.isArray(file.rules) ? file.rules : [] }; + return parseInput(overridesFileSchema, file, 'export'); } diff --git a/src/main/overridesFile/types.ts b/src/main/overridesFile/types.ts deleted file mode 100644 index f299c35b..00000000 --- a/src/main/overridesFile/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { CreateOverrideInput, CreateRuleInput } from '../../shared/types'; - -/** An override as an export lists it: what recreates it, its content, and the text editing started from when that differs. */ -export type ExportedOverride = Required> & - Pick & { enabled: boolean }; - -/** A rule as an export lists it. */ -export type ExportedRule = CreateRuleInput & { enabled: boolean }; - -/** A workspace's overrides and rules, as one JSON file to import elsewhere (another workspace, a teammate's app). */ -export interface OverridesFile { - format: string; - version: number; - overrides: ExportedOverride[]; - rules: ExportedRule[]; -} - -/** An export file's entries, as found (each checked when imported). */ -export interface OverridesFileEntries { - overrides: unknown[]; - rules: unknown[]; -} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index ec729a64..d325c055 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -101,3 +101,6 @@ export const SHORTCUT = { pickElement: ['mod', 'shift', 'C'], editorDevTools: ['mod', 'alt', 'I'], } satisfies Record; + +/** The longest URL the app takes from outside (IPC, a file): longer ones aren't a file a page loaded. */ +export const MAX_URL_CHARS = 8192; diff --git a/src/shared/overrides/constants.ts b/src/shared/overrides/constants.ts index cfe9695a..7637f131 100644 --- a/src/shared/overrides/constants.ts +++ b/src/shared/overrides/constants.ts @@ -33,3 +33,18 @@ export const DEFAULT_RESPONSE: Readonly = { status: 200, delay /** Where a GraphQL document names its operation: `query GetCart(…)`, `mutation ApplyCoupon`… */ export const OPERATION_IN_QUERY = /(?:^|[\s}])(?:query|mutation|subscription)\s+([_A-Za-z][_0-9A-Za-z]*)/; + +/** What an export of overrides says it is (SPEC §5, Sharing), so an import can tell it from any other JSON. */ +export const OVERRIDES_FILE_FORMAT = 'console-editor-overrides'; + +/** The version of the export written; an import refuses a newer one. */ +export const OVERRIDES_FILE_VERSION = 1; + +/** The most overrides one export may list. */ +export const MAX_IMPORTED_OVERRIDES = 500; + +/** A SHA-256 as the engine writes it: what an override's `originalHash` holds. */ +export const SHA256_HEX = /^[0-9a-f]{64}$/; + +/** The URLs an override is made from: web addresses. */ +export const SOURCE_URL = /^https?:\/\//i; diff --git a/src/shared/overrides/exportedOverrideSchema.ts b/src/shared/overrides/exportedOverrideSchema.ts new file mode 100644 index 00000000..f1daa71b --- /dev/null +++ b/src/shared/overrides/exportedOverrideSchema.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { MAX_URL_CHARS } from '../constants'; +import { urlMatcherSchema } from '../matcher'; +import { headerEditSchema } from '../rules'; +import { RESOURCE_KINDS, type ExportedOverride, type ResourceKind } from '../types'; +import { SHA256_HEX, SOURCE_URL } from './constants'; + +/** Why an entry was refused, when it isn't one at all. */ +const NOT_AN_OVERRIDE = 'Not an override'; + +/** + * An override as an export lists it (SPEC §5, Sharing). The shapes only: what a response override + * matches and answers is then checked as the store checks it (`validateRequestMatch`, + * `validateResponseSettings`). Unknown keys are dropped. + */ +export const exportedOverrideSchema = z.toZod()( + z.object( + { + kind: z.custom((value) => RESOURCE_KINDS.includes(value as ResourceKind), { error: 'Not a kind of file the app overrides' }), + sourceUrl: z.string().max(MAX_URL_CHARS).regex(SOURCE_URL, { error: 'An override is made from a web address (http or https)' }), + match: urlMatcherSchema, + enabled: z.boolean(), + originalHash: z.string().regex(SHA256_HEX).nullable(), + request: z.object({ method: z.string(), operation: z.string() }).optional(), + response: z.object({ status: z.number(), delayMs: z.number(), headers: z.array(headerEditSchema), send: z.boolean(), patch: z.boolean() }).optional(), + content: z.string(), + base: z.string().optional(), + }, + { error: NOT_AN_OVERRIDE }, + ), +); diff --git a/src/shared/overrides/index.ts b/src/shared/overrides/index.ts index 72e82bff..5500ba90 100644 --- a/src/shared/overrides/index.ts +++ b/src/shared/overrides/index.ts @@ -1,5 +1,7 @@ -export { ANY_METHOD, DEFAULT_REQUEST, DEFAULT_RESPONSE, GET_METHOD, METHOD, RESPONSE_KIND } from './constants'; +export { ANY_METHOD, DEFAULT_REQUEST, DEFAULT_RESPONSE, GET_METHOD, METHOD, OVERRIDES_FILE_FORMAT, OVERRIDES_FILE_VERSION, RESPONSE_KIND } from './constants'; +export { exportedOverrideSchema } from './exportedOverrideSchema'; export { graphqlOperation } from './graphqlOperation'; +export { overridesFileSchema } from './overridesFileSchema'; export { validateRequestMatch } from './validateRequestMatch'; export { validateResponseSettings } from './validateResponseSettings'; export { withResponseDefaults } from './withResponseDefaults'; diff --git a/src/shared/overrides/overridesFileSchema.ts b/src/shared/overrides/overridesFileSchema.ts new file mode 100644 index 00000000..c2331390 --- /dev/null +++ b/src/shared/overrides/overridesFileSchema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; +import type { OverridesFileEntries } from '../types'; +import { MAX_IMPORTED_OVERRIDES, OVERRIDES_FILE_FORMAT, OVERRIDES_FILE_VERSION } from './constants'; + +/** Why a file was refused, when it isn't an export at all. */ +const NOT_AN_EXPORT = "That file isn't an export of Console Editor's overrides"; + +/** + * An export of overrides as read (SPEC §5, Sharing): its format, a version this build reads, and its + * lists, each entry checked when imported (`exportedOverrideSchema`, `ruleInputSchema`). A missing list is empty. + */ +export const overridesFileSchema = z.toZod()( + z.object( + { + format: z.string({ error: NOT_AN_EXPORT }).refine((format): boolean => format === OVERRIDES_FILE_FORMAT, { error: NOT_AN_EXPORT }), + version: z.number({ error: NOT_AN_EXPORT }).max(OVERRIDES_FILE_VERSION, { error: 'A newer version of Console Editor made that file: update the app to import it' }), + overrides: z.array(z.unknown()).max(MAX_IMPORTED_OVERRIDES, { error: `That file lists over ${MAX_IMPORTED_OVERRIDES} overrides` }).default([]), + rules: z.array(z.unknown()).default([]), + }, + { error: NOT_AN_EXPORT }, + ), +); diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index f511d991..31c1d230 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -3,7 +3,8 @@ import type { ConsoleEntry, ConsoleFrame, ConsoleProperty } from './console'; import type { InspectorApi } from './inspector'; import type { WireEvent } from './events'; import type { NetworkApi } from './networkApi'; -import type { CreateOverrideInput, OverrideMeta, OverridePatch, OverridesExport, OverridesImport, OverrideWithContent } from './overrides'; +import type { CreateOverrideInput, OverrideMeta, OverridePatch, OverrideWithContent } from './overrides'; +import type { OverridesExport, OverridesImport } from './overridesFile'; import type { PageState, Rect } from './page'; import type { ResourceContent, ResourceEntry } from './resources'; import type { CreateRuleInput, Rule, RulePatch } from './rules'; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 431645c1..a4168ba7 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -46,8 +46,9 @@ export type { export { INSPECT_FRAMEWORKS, RENDER_KINDS, RENDER_REASONS, STATE_KINDS, STORE_LIBRARIES } from './inspector'; export type { MenuCommand } from './menu'; export type { HarImport, HttpHeader, NetworkBody, NetworkBodyGap, NetworkRequest, NetworkRequestDetail, NetworkRequestState, SocketDirection, SocketMessage, SocketMessages } from './network'; -export type { CreateOverrideInput, MatchType, Override, OverrideMeta, OverridePatch, OverridesExport, OverridesImport, OverrideWithContent, RequestMatch, ResponseSettings, UnpatchedReason, UrlMatcher } from './overrides'; +export type { CreateOverrideInput, MatchType, Override, OverrideMeta, OverridePatch, OverrideWithContent, RequestMatch, ResponseSettings, UnpatchedReason, UrlMatcher } from './overrides'; export { MATCH_TYPES } from './overrides'; +export type { ExportedOverride, ExportedRule, OverridesExport, OverridesFile, OverridesFileEntries, OverridesImport } from './overridesFile'; export type { PageState, Rect } from './page'; export type { FileKind, ResourceContent, ResourceEntry, ResourceKind } from './resources'; export { FILE_KINDS, RESOURCE_KINDS } from './resources'; diff --git a/src/shared/types/overrides.ts b/src/shared/types/overrides.ts index d011e3cc..0adc4164 100644 --- a/src/shared/types/overrides.ts +++ b/src/shared/types/overrides.ts @@ -95,23 +95,6 @@ export interface CreateOverrideInput { response?: ResponseSettings; } -/** What exporting the active workspace's overrides and rules wrote. */ -export interface OverridesExport { - path: string; - overrides: number; - rules: number; -} - -/** What importing an export of overrides and rules did to the active workspace. */ -export interface OverridesImport { - overrides: number; - rules: number; - /** Left out: the workspace already has an override answering the same requests, or a rule doing the same. */ - present: number; - /** Left out: not an override or rule this version can read (a hand edit, a newer version's). */ - unreadable: number; -} - export interface OverridePatch { content?: string; match?: UrlMatcher; diff --git a/src/shared/types/overridesFile.ts b/src/shared/types/overridesFile.ts new file mode 100644 index 00000000..836f3562 --- /dev/null +++ b/src/shared/types/overridesFile.ts @@ -0,0 +1,52 @@ +import type { RequestMatch, ResponseSettings, UrlMatcher } from './overrides'; +import type { ResourceKind } from './resources'; +import type { CreateRuleInput } from './rules'; + +/** + * An override as an export lists it (SPEC §5, Sharing): what recreates it, whether it was on, its + * content, and the text editing started from when that differs. + */ +export interface ExportedOverride { + kind: ResourceKind; + sourceUrl: string; + match: UrlMatcher; + enabled: boolean; + originalHash: string | null; + /** Response overrides only. */ + request?: RequestMatch; + /** Response overrides only. */ + response?: ResponseSettings; + content: string; + base?: string; +} + +/** A rule as an export lists it: without its id and times, which the importing store sets. */ +export type ExportedRule = CreateRuleInput & { enabled: boolean }; + +/** A workspace's overrides and rules, as one JSON file to import elsewhere (another workspace, a teammate's app). */ +export interface OverridesFile { + format: string; + version: number; + overrides: ExportedOverride[]; + rules: ExportedRule[]; +} + +/** An export as read: its entries are checked one by one when imported, so one that can't be read leaves out only itself. */ +export type OverridesFileEntries = Omit & { overrides: unknown[]; rules: unknown[] }; + +/** What exporting the active workspace's overrides and rules wrote. */ +export interface OverridesExport { + path: string; + overrides: number; + rules: number; +} + +/** What importing an export of overrides and rules did to the active workspace. */ +export interface OverridesImport { + overrides: number; + rules: number; + /** Left out: the workspace already has an override answering the same requests, or a rule doing the same. */ + present: number; + /** Left out: not an override or rule this version can read (a hand edit, a newer version's). */ + unreadable: number; +} diff --git a/test/unit/overridesFile.test.ts b/test/unit/overridesFile.test.ts index 9f57d95e..60218e62 100644 --- a/test/unit/overridesFile.test.ts +++ b/test/unit/overridesFile.test.ts @@ -6,10 +6,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { buildOverridesFile, importOverrides, importRules, overridesFileName, readOverridesFile, type OverridesFile } from '../../src/main/overridesFile'; +import { buildOverridesFile, importOverrides, importRules, overridesFileName, readOverridesFile } from '../../src/main/overridesFile'; import { OverrideStore } from '../../src/main/store/OverrideStore'; import { RuleStore } from '../../src/main/store/RuleStore'; -import type { OverridesImport } from '../../src/shared/types'; +import type { OverridesFile, OverridesImport } from '../../src/shared/types'; let dir: string; let store: OverrideStore; @@ -156,7 +156,7 @@ describe('reading a file', () => { }; it('takes an export, with a missing list read as empty', async () => { - expect(await readOverridesFile(await write({ format: 'console-editor-overrides', version: 1, overrides: [{ a: 1 }] }))).toEqual({ overrides: [{ a: 1 }], rules: [] }); + expect(await readOverridesFile(await write({ format: 'console-editor-overrides', version: 1, overrides: [{ a: 1 }] }))).toEqual({ format: 'console-editor-overrides', version: 1, overrides: [{ a: 1 }], rules: [] }); }); it('refuses a file that is not JSON, not an export, from a newer version, or too long', async () => { From e37384bb5cc41cc4036e2f39c77ba83deaa4ea3a Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Sat, 26 Sep 2026 21:08:11 +0000 Subject: [PATCH 4/4] Keep imported rules in the order they were exported in Rules apply oldest first, and two with the same createdAt are ordered by their random ids. An import creates its rules within one millisecond, so they could come out in either order (CI caught it in overridesFile.test). A rule made in the same millisecond as another of its workspace now goes one past the newest instead. --- docs/SPEC.md | 2 +- src/main/store/RuleStore/addRule.ts | 15 ++++++++++----- test/unit/overridesFile.test.ts | 5 ++++- test/unit/ruleStore.test.ts | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 82ca2dd2..63ba831a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -348,7 +348,7 @@ A version 1 `session.json` (one page and its tabs) becomes the first workspace, rules: (CreateRuleInput & { enabled })[] } // no ids or times: the importing store sets them ``` -An import reads a file of at most 200 MB with `overridesFileSchema` (`src/shared/overrides`): the format, a `version` this build reads (a newer one is refused, with a word to update), at most 500 overrides. It then checks each entry as the app's own input: an override's shapes with `exportedOverrideSchema` (a known kind, an http(s) source URL, a usable matcher, a SHA-256 or null), then what a response override matches and answers as the store does (`responseFieldsOf`); a rule with `ruleInputSchema`. An entry that fails is left out and counted. So is one the workspace already has, which keeps its own: an override answering the same requests (kind, matcher and, for a response override, method and GraphQL operation), or a rule doing the same (`sameRuleInput`). The rest are created in order, turned off when they were off, and `overrides-changed` and `rules-changed` follow, even when a later one fails (a full workspace refuses a rule). The renderer says what was added and left out, and reloads the page if that setting is on. +An import reads a file of at most 200 MB with `overridesFileSchema` (`src/shared/overrides`): the format, a `version` this build reads (a newer one is refused, with a word to update), at most 500 overrides. It then checks each entry as the app's own input: an override's shapes with `exportedOverrideSchema` (a known kind, an http(s) source URL, a usable matcher, a SHA-256 or null), then what a response override matches and answers as the store does (`responseFieldsOf`); a rule with `ruleInputSchema`. An entry that fails is left out and counted. So is one the workspace already has, which keeps its own: an override answering the same requests (kind, matcher and, for a response override, method and GraphQL operation), or a rule doing the same (`sameRuleInput`). The rest are created in order, turned off when they were off (a rule made in the same millisecond as another of its workspace goes past the newest, so rules, which apply oldest first, keep the order they were made in), and `overrides-changed` and `rules-changed` follow, even when a later one fails (a full workspace refuses a rule). The renderer says what was added and left out, and reloads the page if that setting is on. **Rules on disk.** `RuleStore` writes a change to disk before it reaches memory (and so the engine); a failed write leaves the rules as they were. Entries this build can't read (a newer version's action or request type, a hand edit) are neither listed nor applied, and are written back as they were. A `rules.json` that isn't JSON of the right shape is moved to `rules.json.broken` and the app starts with no rules; one that can't be read, or moved aside, is left alone and every change is refused, so it is never written over. Either way the window says so once it opens. diff --git a/src/main/store/RuleStore/addRule.ts b/src/main/store/RuleStore/addRule.ts index bb6721fa..0502f2ba 100644 --- a/src/main/store/RuleStore/addRule.ts +++ b/src/main/store/RuleStore/addRule.ts @@ -3,13 +3,18 @@ import type { CreateRuleInput } from '../../../shared/types'; import type { RuleState, StoredRule } from '../types'; import { newRuleId } from './newRuleId'; -/** Adds an enabled rule to `workspaceId` in `state`; refused once the workspace holds MAX_RULES. */ +/** + * Adds an enabled rule to `workspaceId` in `state`, never in the same millisecond as another of its + * rules; refused once the workspace holds MAX_RULES. + */ export function addRule(state: RuleState, workspaceId: string, input: CreateRuleInput): StoredRule { - if ([...state.rules.values()].filter((r) => r.workspaceId === workspaceId).length >= MAX_RULES) { - throw new Error(`A workspace holds at most ${MAX_RULES} rules`); - } + const own = [...state.rules.values()].filter((r) => r.workspaceId === workspaceId); + if (own.length >= MAX_RULES) throw new Error(`A workspace holds at most ${MAX_RULES} rules`); const id = newRuleId(state); - const now = Date.now(); + // Two rules made in one millisecond (an import) would tie, and a tie goes by id, at random: the later one goes + // past the newest instead, so rules, which apply oldest first, keep the order they were made in. + const clock = Date.now(); + const now = own.some((r) => r.createdAt === clock) ? Math.max(...own.map((r) => r.createdAt)) + 1 : clock; const rule: StoredRule = { workspaceId, id, ...input, enabled: true, createdAt: now, updatedAt: now }; state.rules.set(id, rule); return rule; diff --git a/test/unit/overridesFile.test.ts b/test/unit/overridesFile.test.ts index 60218e62..b409b2fe 100644 --- a/test/unit/overridesFile.test.ts +++ b/test/unit/overridesFile.test.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildOverridesFile, importOverrides, importRules, overridesFileName, readOverridesFile } from '../../src/main/overridesFile'; import { OverrideStore } from '../../src/main/store/OverrideStore'; import { RuleStore } from '../../src/main/store/RuleStore'; @@ -47,6 +47,7 @@ beforeEach(async () => { }); afterEach(async () => { + vi.restoreAllMocks(); await rm(dir, { recursive: true, force: true }); }); @@ -97,6 +98,8 @@ describe('import', () => { store.setWorkspace('ws2'); rules.setWorkspace('ws2'); + // All in one millisecond, as a fast import is: the rules still apply in the order they were exported in. + vi.spyOn(Date, 'now').mockReturnValue(5_000); expect(await importFile(file)).toEqual({ overrides: 3, rules: 2, present: 0, unreadable: 0 }); const [first, second, third] = store.list(); expect(first).toMatchObject({ kind: 'Script', content: 'mine();', enabled: true, originalHash: HASH }); diff --git a/test/unit/ruleStore.test.ts b/test/unit/ruleStore.test.ts index ca4bc977..cdf4a532 100644 --- a/test/unit/ruleStore.test.ts +++ b/test/unit/ruleStore.test.ts @@ -190,6 +190,21 @@ describe('RuleStore', () => { expect((await readIndex()).rules).toEqual([{ ...rule, enabled: false, updatedAt: expect.any(Number) }, added, unknownAction, unknownType, malformed, duplicate]); }); + it('keeps the order rules were made in, even within one millisecond', async () => { + const store = await open(); + vi.spyOn(Date, 'now').mockReturnValue(1_000); + // Ids that would sort the other way round (queued one at a time: each write's temp file takes random bytes too). + queuedIds.push('ffffffff'); + const first = await store.create(block('https://a.com/first.js')); + queuedIds.push('00000000'); + const second = await store.create(removeCsp); + expect(second.createdAt).toBeGreaterThan(first.createdAt); + expect(store.forRenderer().map((r) => r.id)).toEqual(['ffffffff', '00000000']); + // Another workspace's rules don't push its times on. + store.setWorkspace('bbbbbbbb'); + expect((await store.create(block())).createdAt).toBe(1_000); + }); + it('gives new rules ids that no rule and no unreadable entry has', async () => { const store = await open(); queuedIds.push('00000001');