Files
read-frog/vitest.setup.ts
T
MengXi 19df7c2389 fix(translate): keep bilingual translations beside tall floats instead of stranding them below (#2047)
A block translation renders as `display: inline-block` so its decoration hugs
the text. That makes it an atomic inline: when a float leaves the line too
narrow, the browser drops the whole box below the float instead of wrapping
text beside it. Against a tall float the translation is stranded that far
below its own paragraph, stretching the paragraph into a page-tall blank gap.

#1188 added a `data-read-frog-float-wrap` override for this, but decided it by
scanning the paragraph's DOM siblings for a floated element. A float only has
to share a block formatting context to intrude — it does not have to be near
the paragraph in the tree. On ja.wikipedia the body is
`div.mw-parser-output > section > section > p` while the infobox floats out of
the outer section's sibling, so the sibling scan never saw it and every
paragraph below the lead section was left displaced. Measured on the 毛沢東
article: the 生い立ち translation landed 5643px below its paragraph.

Decide by measuring the rendered result instead. A Range spanning everything
before the wrapper gives the source text's last-line bottom; if the translated
node sits more than `margin-top + line-height` below it, the box was displaced.
That is structure-agnostic, so ancestor and cousin floats are covered too, and
it costs two rect reads instead of a per-sibling subtree scan.

`flowSource` existed only to locate the paragraph for the old scan, so its
plumbing is removed.

Verified on ja.wikipedia 毛沢東 (bilingual, zh): worst gap across all
translated paragraphs 5643px -> 14px, zero stranded. Line boxes confirm the
mechanism — 859px (full column, ignoring the float) -> 515px (shortened beside
the infobox). Instrumented rAF shows all 14 float-wrapped nodes are decided
before the first paint, so no displaced frame is ever shown.

jsdom implements no layout and omits `Range.prototype.getBoundingClientRect`
entirely, so vitest.setup.ts stubs it (guarded — that file also loads for
node-environment tests).

Co-authored-by: ananaBMaster <68643891+ananaBMaster@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:40:43 -07:00

154 lines
5.0 KiB
TypeScript

import { vi } from "vitest"
import "@testing-library/jest-dom"
// Keep test output quiet by default. Individual tests can still spy on these
// methods when they need to assert logging behavior.
// eslint-disable-next-line no-console
console.log = () => {}
// eslint-disable-next-line no-console
console.info = () => {}
console.warn = () => {}
console.error = () => {}
class MemoryStorage implements Storage {
#store = new Map<string, string>()
get length() {
return this.#store.size
}
clear() {
this.#store.clear()
}
getItem(key: string) {
return this.#store.get(key) ?? null
}
key(index: number) {
return [...this.#store.keys()][index] ?? null
}
removeItem(key: string) {
this.#store.delete(key)
}
setItem(key: string, value: string) {
this.#store.set(key, value)
}
}
// Node 22 exposes built-in Web Storage. In worker processes without a configured
// backing file, reading it emits `--localstorage-file` warnings. Replace it with
// an in-memory test double before app modules import Jotai utils.
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: new MemoryStorage(),
})
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
value: new MemoryStorage(),
})
// Mock the runtime i18n facade so tests resolve keys deterministically (returning the
// dot-key, matching the pre-migration behaviour that test assertions rely on) without
// initializing i18next or touching browser.i18n.
vi.mock("@/utils/i18n", () => ({
i18n: {
t: (key: string) => key,
},
initI18n: async () => {},
setUiLanguage: async () => {},
}))
// LocaleBoundary is a separate module from the mocked facade above and pulls in i18next +
// the bundled YAML resources (which vitest has no plugin for). Stub it to a passthrough so
// no test loads i18next or the .yml files; runtime language switching is not under test here.
vi.mock("@/utils/i18n/locale-boundary", () => ({
LocaleBoundary: ({ children }: { children: unknown }) => children,
}))
// Iconify's <Icon> fetches icon data from api.iconify.design on mount and schedules
// retry timers when that fetch stalls (common in CI). Those Node timers outlive the
// test file's jsdom environment and crash React with "window is not defined" as an
// unhandled error attributed to whichever file runs next. Render an inert placeholder
// instead; no test exercises real icon loading. iconify-internal-api.test.ts opts back
// in via vi.unmock to keep its _api canary pointed at the real package.
vi.mock("@iconify/react", async () => {
const { createElement } = await import("react")
return {
Icon: ({ className, icon }: { className?: string; icon: string }) =>
createElement("span", { "aria-hidden": true, className, "data-icon": icon }),
_api: {
setFetch: () => {},
},
}
})
// Mock the fakeBrowser's i18n.getMessage method which is not implemented in fake-browser
// This is used when WxtVitest plugin replaces browser imports with fake-browser
vi.mock("wxt/testing/fake-browser", async () => {
const actual = await vi.importActual<any>("wxt/testing/fake-browser")
Object.assign(actual.fakeBrowser.i18n, {
getMessage: (key: string) => key.replaceAll("_", "."),
})
Object.assign(actual.fakeBrowser.identity, {
getRedirectURL: () => "https://mock-redirect-url.chromiumapp.org/",
})
Object.assign(actual.fakeBrowser.runtime, {
getManifest: () => ({
manifest_version: 3,
name: "Read Frog",
version: "1.0.0",
description: "Test manifest",
}),
})
return actual
})
// jsdom implements no layout, so it omits Range.getBoundingClientRect entirely
// (Element.getBoundingClientRect it does stub, returning zeros). Every browser
// ships it. Match jsdom's own convention with a zero rect so layout probes
// short-circuit instead of throwing; tests that exercise them spy on this.
// (Guarded: this setup file also runs for node-environment test files.)
if (typeof Range !== "undefined" && typeof Range.prototype.getBoundingClientRect !== "function") {
Range.prototype.getBoundingClientRect = function () {
return {
top: 0,
left: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}
}
}
// JSDom + Vitest don't play well with each other. Long story short - default
// TextEncoder produces Uint8Array objects that are _different_ from the global
// Uint8Array objects, so some functions that compare their types explode.
// https://github.com/vitest-dev/vitest/issues/4043#issuecomment-1905172846
class ESBuildAndJSDOMCompatibleTextEncoder extends TextEncoder {
override encode(input: string) {
if (typeof input !== "string") {
throw new TypeError("`input` must be a string")
}
const decodedURI = decodeURIComponent(encodeURIComponent(input))
const arr = new Uint8Array(decodedURI.length)
const chars = decodedURI.split("")
for (let i = 0; i < chars.length; i++) {
arr[i] = decodedURI[i]!.charCodeAt(0)
}
return arr
}
}
globalThis.TextEncoder = ESBuildAndJSDOMCompatibleTextEncoder