diff --git a/.env.sample b/.env.sample index bce33191f8..f498554192 100644 --- a/.env.sample +++ b/.env.sample @@ -1,2 +1,2 @@ export NX_SELF_HOSTED_REMOTE_CACHE_SERVER=https://cache.nickthesick.com -export NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN=g8@ucL8em4*Z9TKXDY9OEX@!upf^Nz9 \ No newline at end of file +export NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN= diff --git a/.github/workflows/emulator-tests.yml b/.github/workflows/emulator-tests.yml new file mode 100644 index 0000000000..f1dbba8883 --- /dev/null +++ b/.github/workflows/emulator-tests.yml @@ -0,0 +1,112 @@ +name: Emulator tests + +# The OS-emulator layer of the device suite (tests/device/): real Chrome and +# real Gboard on an Android emulator, and real iOS Safari on a simulator via +# Appium/XCUITest — driving flows no browser emulation can, including pressing +# the on-screen keyboard's IME action key. Free minutes, no credentials, so it +# runs as normal CI. See tests/device/README.md. +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +concurrency: + group: emulator-tests-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + android-emulator: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0 + with: + node-version-file: ".node-version" + cache: true + + - name: Install dependencies + run: vp install + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Start playground dev server + run: | + vp run dev & + for _ in $(seq 1 120); do + if curl -sf http://127.0.0.1:5173/ > /dev/null; then exit 0; fi + sleep 2 + done + echo "playground dev server never came up" >&2 + exit 1 + + - name: Run device suite on the emulator + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + with: + api-level: 35 + arch: x86_64 + target: google_apis + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -no-audio -no-boot-anim + disable-animations: true + script: DEVICE_FILTER=local-android vp run test:device + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: emulator-test-screenshots + path: tests/device/.artifacts/ + if-no-files-found: ignore + + ios-simulator: + runs-on: macos-15 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0 + with: + node-version-file: ".node-version" + cache: true + + - name: Install dependencies + run: vp install + + - name: Start playground dev server + run: | + vp run dev & + for _ in $(seq 1 120); do + if curl -sf http://127.0.0.1:5173/ > /dev/null; then exit 0; fi + sleep 2 + done + echo "playground dev server never came up" >&2 + exit 1 + + - name: Run device suite on the simulator + run: DEVICE_FILTER=local-ios vp run test:device + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: simulator-test-screenshots + path: tests/device/.artifacts/ + if-no-files-found: ignore diff --git a/package.json b/package.json index 0323f0ce02..0a1cf2a882 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "prestart": "vp run build", "start": "vp run --filter @blocknote/example-editor preview", "test": "vp run --filter \"@blocknote/*\" --filter \"docs\" test", + "test:device": "vp -C tests exec vitest run --config device/vitest.config.mts", "format": "vp fmt", "prepare": "vp config" }, diff --git a/packages/ariakit/src/input/Form.tsx b/packages/ariakit/src/input/Form.tsx index bf964aee66..819bf4f3c7 100644 --- a/packages/ariakit/src/input/Form.tsx +++ b/packages/ariakit/src/input/Form.tsx @@ -1,12 +1,36 @@ import { FormProvider as AriakitFormProvider } from "@ariakit/react"; import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; +import { ComponentProps, useDictionary } from "@blocknote/react"; export const Form = (props: ComponentProps["Generic"]["Form"]["Root"]) => { - const { children, ...rest } = props; + const { children, onSubmit, omitSubmitButton, ...rest } = props; + const dict = useDictionary(); assertEmpty(rest); - return {children}; + return ( + +
{ + // These forms have no action — a real submission would navigate. + event.preventDefault(); + onSubmit?.(); + }} + > + {children} + {/* + Gives the form a submit button, which is what makes Enter submit it at + all once a caller renders more than one field (see the `onSubmit` + contract in `ComponentsContext`). Visually hidden rather than absent, + so assistive technology still has a labelled control to activate. + */} + {!omitSubmitButton && ( + + )} +
+
+ ); }; diff --git a/packages/ariakit/src/input/TextInput.tsx b/packages/ariakit/src/input/TextInput.tsx index 555961faf0..35b02b92d0 100644 --- a/packages/ariakit/src/input/TextInput.tsx +++ b/packages/ariakit/src/input/TextInput.tsx @@ -4,8 +4,8 @@ import { } from "@ariakit/react"; import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { ComponentProps, useMergeRefs } from "@blocknote/react"; +import { forwardRef, useEffect, useRef } from "react"; export const TextInput = forwardRef< HTMLInputElement, @@ -23,7 +23,6 @@ export const TextInput = forwardRef< disabled, onKeyDown, onChange, - onSubmit, autoComplete, "aria-activedescendant": ariaActivedescendant, rightSection, @@ -32,6 +31,19 @@ export const TextInput = forwardRef< assertEmpty(rest); + // Focus with `preventScroll`, rather than the native `autofocus`: these + // inputs live in popovers that floating-ui positions *after* mount, so the + // browser's scroll-into-view runs while the popover is still at its + // pre-positioned spot and yanks the page (on mobile, right out from under + // the block being edited). + const inputRef = useRef(null); + const setRefs = useMergeRefs([inputRef, ref]); + useEffect(() => { + if (autoFocus) { + inputRef.current?.focus({ preventScroll: true }); + } + }, [autoFocus]); + return ( <> {props.label && {label}} @@ -43,15 +55,13 @@ export const TextInput = forwardRef< className || "", variant === "large" ? "bn-ak-input-large" : "", )} - ref={ref} + ref={setRefs} name={name} value={value} - autoFocus={autoFocus} placeholder={placeholder} disabled={disabled} onKeyDown={onKeyDown} onChange={onChange} - onSubmit={onSubmit} autoComplete={autoComplete} aria-activedescendant={ariaActivedescendant} /> diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css index 59974a6d60..6212efe74c 100644 --- a/packages/ariakit/src/style.css +++ b/packages/ariakit/src/style.css @@ -433,3 +433,23 @@ .bn-ariakit .bn-thread.selected .bn-ak-expand-sections-prompt { color: var(--bn-colors-selected-text); } + +/* The submit button `Form.Root` renders so that Enter reaches the form + * regardless of how many fields a popover has. It carries no visual design of + * its own - the popovers commit on Enter - but it stays in the accessibility + * tree with a real label, so screen readers and voice control have a submit + * control to operate. It is out of the tab order: keeping a control nobody can + * see as a tab stop would strand sighted keyboard users on invisible focus, + * and Enter already submits for them. + */ +.bn-form-submit { + border: 0; + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} diff --git a/packages/core/src/editor/managers/StyleManager.ts b/packages/core/src/editor/managers/StyleManager.ts index e412160e4a..6e802a4c17 100644 --- a/packages/core/src/editor/managers/StyleManager.ts +++ b/packages/core/src/editor/managers/StyleManager.ts @@ -183,7 +183,26 @@ export class StyleManager< */ public getSelectedLinkUrl() { return this.editor.transact((tr) => { - return this.getLinkMarkAtPos(tr.selection.from)?.href; + const { from, to, empty } = tr.selection; + if (empty) { + return this.getLinkMarkAtPos(from)?.href; + } + // For a non-empty selection, probing a single boundary position is + // fragile twice over: `marks()` excludes a link at its left edge, and + // browsers disagree by a position on where a selection over a link + // starts. Scan the selected range for the first link mark instead. + let href: string | undefined; + tr.doc.nodesBetween(from, to, (node) => { + if (href !== undefined) { + return false; + } + const linkMark = node.marks.find((mark) => mark.type.name === "link"); + if (linkMark) { + href = linkMark.attrs.href; + } + return href === undefined; + }); + return href; }); } diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..58f4675ffd 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1,6 +1,7 @@ import { Extension } from "@tiptap/core"; import { Fragment, Node } from "prosemirror-model"; -import { TextSelection } from "prosemirror-state"; +import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; import { getBottomNestedBlockInfo, @@ -22,15 +23,98 @@ import { getBlockInfoFromSelection, } from "../../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { isAndroid } from "../../../util/browser.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js"; +/** + * Runs the keymap chain for an Enter that never reached it (see the + * `blockNoteAndroidEnter` plugin below): flushes pending DOM observations + * first, then dispatches a synthesized Enter keydown through + * `handleKeyDown`. + */ +function dispatchSynthesizedEnter(view: EditorView, shiftKey: boolean): void { + ( + view as EditorView & { + domObserver: { forceFlush(): void }; + } + ).domObserver.forceFlush(); + view.someProp("handleKeyDown", (handler) => + handler( + view, + new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + shiftKey, + }), + ), + ); +} + export const KeyboardShortcutsExtension = Extension.create<{ editor: BlockNoteEditor; tabBehavior: "prefer-navigate-ui" | "prefer-indent"; }>({ priority: 50, + addProseMirrorPlugins() { + return [ + // On Android, Enter never reaches the keymap: the IME delivers it as a + // `beforeinput` (the keydown is keyCode 229), and prosemirror-view + // additionally ignores Enter keydowns on Android Chrome. ProseMirror's + // fallback — parsing the browser's native DOM split and synthesizing an + // Enter key event — fails to recognize the split in BlockNote's nested + // block DOM and corrupts the document instead (Enter inserting a space, + // doing nothing, or breaking tables — TypeCellOS/BlockNote#3001). + // Intercepting the `beforeinput` and running the keymap chain directly + // bypasses the fragile DOM diffing entirely. + new Plugin({ + key: new PluginKey("blockNoteAndroidEnter"), + props: { + // Runs the keymap chain for an Enter that prosemirror-view's + // Android keydown bail skipped, with the parity that bail also + // skips: force-flushing pending DOM observations (including + // selection changes) before running key handlers — without it the + // synthesized Enter can run against a stale selection (e.g. a + // just-made cross-block selection that hasn't synced yet). + handleKeyPress: (view, event) => { + // A keypress for Enter only happens off a hardware/synthetic + // keyboard (the IME path is keyCode 229 + `beforeinput`, no + // keypress — handled below). prosemirror-view's own keypress + // handler would cancel the browser default for cross-block + // selections without doing anything (its cross-parent branch + // calls preventDefault but skips newline characters), turning + // Enter into a silent no-op — so take over before it runs. + if (!isAndroid() || view.composing || event.key !== "Enter") { + return false; + } + dispatchSynthesizedEnter(view, event.shiftKey); + return true; + }, + handleDOMEvents: { + beforeinput: (view, event) => { + if (!isAndroid() || view.composing) { + return false; + } + if ( + event.inputType !== "insertParagraph" && + event.inputType !== "insertLineBreak" + ) { + return false; + } + event.preventDefault(); + dispatchSynthesizedEnter( + view, + event.inputType === "insertLineBreak", + ); + return true; + }, + }, + }, + }), + ]; + }, + // TODO: The shortcuts need a refactor. Do we want to use a command priority // design as there is now, or clump the logic into a single function? addKeyboardShortcuts() { diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index 094671d920..b503d01eb9 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -406,5 +406,6 @@ export const ar: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "إرسال", }, }; diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index bf77a36a01..29b9eaee64 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -440,5 +440,6 @@ export const de: Dictionary = { }, generic: { ctrl_shortcut: "Strg", + form_submit: "Absenden", }, }; diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index e5386f3020..76f636bd75 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -421,5 +421,6 @@ export const en = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Submit", }, }; diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index 743a1be05c..a878b27efd 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -419,5 +419,6 @@ export const es: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Enviar", }, }; diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts index 6b2783ab68..81d1d442bc 100644 --- a/packages/core/src/i18n/locales/fa.ts +++ b/packages/core/src/i18n/locales/fa.ts @@ -390,5 +390,6 @@ export const fa = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "ارسال", }, }; diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index ad605db24a..5f2f00559c 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -467,5 +467,6 @@ export const fr: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Envoyer", }, }; diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts index 4662a94202..e62f1afcb5 100644 --- a/packages/core/src/i18n/locales/he.ts +++ b/packages/core/src/i18n/locales/he.ts @@ -421,5 +421,6 @@ export const he: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "שליחה", }, }; diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts index 03eb016eed..649ef6c621 100644 --- a/packages/core/src/i18n/locales/hr.ts +++ b/packages/core/src/i18n/locales/hr.ts @@ -435,5 +435,6 @@ export const hr: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Pošalji", }, }; diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index 913b2324b0..f5fee52314 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -435,5 +435,6 @@ export const is: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Senda", }, }; diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts index 44be22c1bd..b6d76420e0 100644 --- a/packages/core/src/i18n/locales/it.ts +++ b/packages/core/src/i18n/locales/it.ts @@ -443,5 +443,6 @@ export const it: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Invia", }, }; diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index ead1f2fb30..a1bc799d42 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -461,5 +461,6 @@ export const ja: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "送信", }, }; diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index 2981ff1c36..15cf0cc0fb 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -434,5 +434,6 @@ export const ko: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "제출", }, }; diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index da599e017c..0be0755e38 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -422,5 +422,6 @@ export const nl: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Verzenden", }, }; diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts index 72efc096ed..1242b9f6a2 100644 --- a/packages/core/src/i18n/locales/no.ts +++ b/packages/core/src/i18n/locales/no.ts @@ -439,5 +439,6 @@ export const no: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Send inn", }, }; diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index d00039633c..fc4ff44055 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -412,5 +412,6 @@ export const pl: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Wyślij", }, }; diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index fe719ce023..72caf58af3 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -414,5 +414,6 @@ export const pt: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Enviar", }, }; diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index a4a7987dfc..26faa60bbf 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -465,5 +465,6 @@ export const ru: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Отправить", }, }; diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts index 4e73dc7eca..7aff94394b 100644 --- a/packages/core/src/i18n/locales/sk.ts +++ b/packages/core/src/i18n/locales/sk.ts @@ -419,5 +419,6 @@ export const sk = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Odoslať", }, }; diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts index e9d379ac0b..ce9aee6a8e 100644 --- a/packages/core/src/i18n/locales/uk.ts +++ b/packages/core/src/i18n/locales/uk.ts @@ -445,5 +445,6 @@ export const uk: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Надіслати", }, }; diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts index 13aee55a73..984f9a844b 100644 --- a/packages/core/src/i18n/locales/uz.ts +++ b/packages/core/src/i18n/locales/uz.ts @@ -455,5 +455,6 @@ export const uz: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Yuborish", }, }; diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index 8733fbf0ba..48295ebff7 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -420,5 +420,6 @@ export const vi: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "Gửi", }, }; diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts index 5ac37a80c7..9be4dc9fc0 100644 --- a/packages/core/src/i18n/locales/zh-tw.ts +++ b/packages/core/src/i18n/locales/zh-tw.ts @@ -462,5 +462,6 @@ export const zhTW: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "提交", }, }; diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index 3f4c90bb56..78498d0e68 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -462,5 +462,6 @@ export const zh: Dictionary = { }, generic: { ctrl_shortcut: "Ctrl", + form_submit: "提交", }, }; diff --git a/packages/core/src/util/browser.ts b/packages/core/src/util/browser.ts index d070115c2a..d8961d526d 100644 --- a/packages/core/src/util/browser.ts +++ b/packages/core/src/util/browser.ts @@ -29,6 +29,9 @@ export function mergeCSSClasses(...classes: (string | false | undefined)[]) { export const isSafari = () => /^((?!chrome|android).)*safari/i.test(navigator.userAgent); +export const isAndroid = () => + typeof navigator !== "undefined" && /android/i.test(navigator.userAgent); + // Cached lazily on first call in a browser environment. Touch capability // doesn't change during a session, so there's no need to re-run `matchMedia` on // every call. We only cache once `navigator`/`window` are available, so a diff --git a/packages/mantine/src/blocknoteStyles.css b/packages/mantine/src/blocknoteStyles.css index beb3c8182f..28974e2a23 100644 --- a/packages/mantine/src/blocknoteStyles.css +++ b/packages/mantine/src/blocknoteStyles.css @@ -257,6 +257,19 @@ on touch devices (e.g. the mobile formatting toolbar). */ font-size: 12px; } +/* On touch devices, enlarge the form-popover inputs (e.g. the link popover's + URL field). The 16px font-size is load-bearing: iOS Safari auto-zooms the + page when focusing an input with a smaller computed font-size, and that zoom + perturbs the visual viewport the mobile toolbar positions itself from. The + taller min-height also gives a comfortable tap target. */ +@media (pointer: coarse) { + .bn-form-popover .mantine-TextInput-input, + .bn-form-popover .mantine-FileInput-input { + font-size: 16px; + min-height: 40px; + } +} + .bn-form-popover .mantine-FileInput-input:hover { background-color: var(--bn-colors-hovered-background); } @@ -806,3 +819,23 @@ we just don't display it in CSS instead. */ .bn-mantine .bn-badge .mantine-Chip-iconWrapper { display: none; } + +/* The submit button `Form.Root` renders so that Enter reaches the form + * regardless of how many fields a popover has. It carries no visual design of + * its own - the popovers commit on Enter - but it stays in the accessibility + * tree with a real label, so screen readers and voice control have a submit + * control to operate. It is out of the tab order: keeping a control nobody can + * see as a tab stop would strand sighted keyboard users on invisible focus, + * and Enter already submits for them. + */ +.bn-form-submit { + border: 0; + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} diff --git a/packages/mantine/src/components.tsx b/packages/mantine/src/components.tsx index 6c85286e7b..f39ec593fa 100644 --- a/packages/mantine/src/components.tsx +++ b/packages/mantine/src/components.tsx @@ -3,6 +3,7 @@ import { Badge, BadgeGroup } from "./badge/Badge.js"; import { Card, CardSection, ExpandSectionsPrompt } from "./comments/Card.js"; import { Comment } from "./comments/Comment.js"; import { Editor } from "./comments/Editor.js"; +import { Form } from "./form/Form.js"; import { TextInput } from "./form/TextInput.js"; import { Menu, @@ -89,7 +90,7 @@ export const components: Components = { Group: BadgeGroup, }, Form: { - Root: (props) =>
{props.children}
, + Root: Form, TextInput: TextInput, }, Menu: { diff --git a/packages/mantine/src/form/Form.tsx b/packages/mantine/src/form/Form.tsx new file mode 100644 index 0000000000..f0cc1e7d0e --- /dev/null +++ b/packages/mantine/src/form/Form.tsx @@ -0,0 +1,32 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps, useDictionary } from "@blocknote/react"; + +export const Form = (props: ComponentProps["Generic"]["Form"]["Root"]) => { + const { children, onSubmit, omitSubmitButton, ...rest } = props; + const dict = useDictionary(); + + assertEmpty(rest); + + return ( +
{ + // These forms have no action — a real submission would navigate. + event.preventDefault(); + onSubmit?.(); + }} + > + {children} + {/* + Gives the form a submit button, which is what makes Enter submit it at + all once a caller renders more than one field (see the `onSubmit` + contract in `ComponentsContext`). Visually hidden rather than absent, + so assistive technology still has a labelled control to activate. + */} + {!omitSubmitButton && ( + + )} +
+ ); +}; diff --git a/packages/mantine/src/form/TextInput.tsx b/packages/mantine/src/form/TextInput.tsx index c1630fa17f..4d2e2bcb7f 100644 --- a/packages/mantine/src/form/TextInput.tsx +++ b/packages/mantine/src/form/TextInput.tsx @@ -1,8 +1,8 @@ import { TextInput as MantineTextInput } from "@mantine/core"; import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { ComponentProps, useMergeRefs } from "@blocknote/react"; +import { forwardRef, useEffect, useRef } from "react"; export const TextInput = forwardRef< HTMLInputElement, @@ -20,7 +20,6 @@ export const TextInput = forwardRef< disabled, onKeyDown, onChange, - onSubmit, autoComplete, "aria-activedescendant": ariaActivedescendant, rightSection, @@ -29,6 +28,19 @@ export const TextInput = forwardRef< assertEmpty(rest); + // Focus with `preventScroll`, rather than the native `autofocus`: these + // inputs live in popovers that floating-ui positions *after* mount, so the + // browser's scroll-into-view runs while the popover is still at its + // pre-positioned spot and yanks the page (on mobile, right out from under + // the block being edited). + const inputRef = useRef(null); + const setRefs = useMergeRefs([inputRef, ref]); + useEffect(() => { + if (autoFocus) { + inputRef.current?.focus({ preventScroll: true }); + } + }, [autoFocus]); + return ( diff --git a/packages/mantine/src/popover/Popover.tsx b/packages/mantine/src/popover/Popover.tsx index 9a10c4ce44..35a19590cf 100644 --- a/packages/mantine/src/popover/Popover.tsx +++ b/packages/mantine/src/popover/Popover.tsx @@ -13,6 +13,12 @@ export const Popover = ( ) => { const { open, onOpenChange, position, portalRoot, children, ...rest } = props; + // A `portalRoot` is only passed by the mobile toolbar, which renders its + // popovers into its own container — so it doubles as "this popover belongs + // to the mobile toolbar", which is what the two behaviours below actually + // depend on. Named here so the reason isn't hidden behind an unrelated prop. + const isMobileToolbarPopover = !!portalRoot; + assertEmpty(rest); return ( @@ -22,7 +28,13 @@ export const Popover = ( portalProps={portalRoot ? { target: portalRoot } : undefined} // Do not move focus to the dropdown on mobile, as it blurs the editor's // contentEditable and dismisses the on-screen keyboard. - trapFocus={portalRoot ? false : undefined} + trapFocus={isMobileToolbarPopover ? false : undefined} + // Keep the dropdown visible through virtual-keyboard viewport resizes on + // mobile: hideDetached (default true) reacts to the resize by setting + // display:none on the dropdown, which blurs its focused input and + // dismisses the on-screen keyboard (the input then unmounts with the + // toolbar, so the whole UI collapses). + hideDetached={isMobileToolbarPopover ? false : undefined} opened={open} onChange={onOpenChange} position={position} diff --git a/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx b/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx index 9c824ba8bf..0462bc89a4 100644 --- a/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx +++ b/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx @@ -7,7 +7,7 @@ import { StyleSchema, filenameFromURL, } from "@blocknote/core"; -import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; +import { ChangeEvent, useCallback, useState } from "react"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; @@ -37,25 +37,7 @@ export const EmbedTab = < [], ); - const handleURLEnter = useCallback( - (event: KeyboardEvent) => { - if (event.key === "Enter" && !event.nativeEvent.isComposing) { - event.preventDefault(); - if (!editor.getBlock(props.blockId)) { - return; - } - editor.updateBlock(props.blockId, { - props: { - name: filenameFromURL(currentURL), - url: currentURL, - } as any, - }); - } - }, - [editor, props.blockId, currentURL], - ); - - const handleURLClick = useCallback(() => { + const embedURL = useCallback(() => { if (!editor.getBlock(props.blockId)) { return; } @@ -73,17 +55,26 @@ export const EmbedTab = < return ( - + {/* + The embed button below is this form's submit control, so `Form.Root` + must not add its own — a screen reader would announce two separate + actions for the one thing this panel does. It stays outside the + `
` on purpose: the skins disagree on whether their panel button + defaults to `type="submit"`, so inside one it would fire `onClick` + *and* submit, embedding twice. + */} + + + {dict.file_panel.embed.embed_button[block.type] || diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index 26ce7e04a5..ef2b7cbab8 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -162,6 +162,9 @@ export const CreateLinkButton = () => { text={state.text} range={state.range} showTextField={false} + // (No explicit popover close here: any editor-state change — like + // submitting the link — already closes it via the setShowPopover + // effect above.) setToolbarOpen={(open) => formattingToolbar.store.setState(open)} /> diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index bd72ea451c..1065546c53 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -5,7 +5,7 @@ import { InlineContentSchema, StyleSchema, } from "@blocknote/core"; -import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; +import { ChangeEvent, useCallback, useState } from "react"; import { RiInputField } from "react-icons/ri"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; @@ -88,16 +88,6 @@ export const FileCaptionButton = () => { [block, editor], ); - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.key === "Enter" && !event.nativeEvent.isComposing) { - event.preventDefault(); - setPopoverOpen(false); - } - }, - [setPopoverOpen], - ); - if (block === undefined) { return null; } @@ -127,14 +117,13 @@ export const FileCaptionButton = () => { className={"bn-popover-content bn-form-popover"} variant={"form-popover"} > - + setPopoverOpen(false)}> } value={block.props.caption} autoFocus={true} placeholder={dict.formatting_toolbar.file_caption.input_placeholder} - onKeyDown={handleKeyDown} onChange={handleChange} /> diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx index b13bb45a88..0138947c24 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx @@ -5,7 +5,7 @@ import { InlineContentSchema, StyleSchema, } from "@blocknote/core"; -import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; +import { ChangeEvent, useCallback, useState } from "react"; import { RiFontFamily } from "react-icons/ri"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; @@ -88,16 +88,6 @@ export const FileRenameButton = () => { [block, editor], ); - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.key === "Enter" && !event.nativeEvent.isComposing) { - event.preventDefault(); - setPopoverOpen(false); - } - }, - [setPopoverOpen], - ); - if (block === undefined) { return null; } @@ -133,7 +123,7 @@ export const FileRenameButton = () => { className={"bn-popover-content bn-form-popover"} variant={"form-popover"} > - + setPopoverOpen(false)}> } @@ -144,7 +134,6 @@ export const FileRenameButton = () => { block.type ] || dict.formatting_toolbar.file_rename.input_placeholder["file"] } - onKeyDown={handleKeyDown} onChange={handleChange} /> diff --git a/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx b/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx index 1d82a6e7cc..147404d2b8 100644 --- a/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx +++ b/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx @@ -3,13 +3,7 @@ import { LinkToolbarExtension, VALID_LINK_PROTOCOLS, } from "@blocknote/core/extensions"; -import { - ChangeEvent, - KeyboardEvent, - useCallback, - useEffect, - useState, -} from "react"; +import { ChangeEvent, useCallback, useEffect, useState } from "react"; import { RiLink, RiText } from "react-icons/ri"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { useExtension } from "../../hooks/useExtension.js"; @@ -50,18 +44,6 @@ export const EditLinkMenuItems = ( setCurrentText(text); }, [text, url]); - const handleEnter = useCallback( - (event: KeyboardEvent) => { - if (event.key === "Enter" && !event.nativeEvent.isComposing) { - event.preventDefault(); - editLink(validateUrl(currentUrl), currentText, props.range.from); - props.setToolbarOpen?.(false); - props.setToolbarPositionFrozen?.(false); - } - }, - [editLink, currentUrl, currentText, props], - ); - const handleUrlChange = useCallback( (event: ChangeEvent) => setCurrentUrl(event.currentTarget.value), @@ -81,7 +63,7 @@ export const EditLinkMenuItems = ( }, [editLink, currentUrl, currentText, props]); return ( - + {/* // TODO: add labels? */} {showTextField !== false && ( } placeholder={dict.link_toolbar.form.title_placeholder} value={currentText} - onKeyDown={handleEnter} onChange={handleTextChange} - onSubmit={handleSubmit} /> )} diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 5d71bc58dc..571cea6f72 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -103,7 +103,7 @@ export type ComponentProps = { value: string; placeholder: string; onChange: (event: ChangeEvent) => void; - onKeyDown: (event: KeyboardEvent) => void; + onKeyDown?: (event: KeyboardEvent) => void; }; }; LinkToolbar: { @@ -304,6 +304,29 @@ export type ComponentProps = { Form: { Root: { children?: ReactNode; + /** + * Called on the form's `submit` event, which is how the browser + * reports Enter-to-submit — including when a mobile IME's action key + * triggers it. Implementations must render a real `` and + * `preventDefault`, or Enter is left with no submission path at all + * on platforms that don't dispatch a key event for it. + * + * The form context is also what makes Android's IME offer a + * submitting action at all: without it, it advances focus to the next + * element on the page instead (verified on a device). + */ + onSubmit?: () => void; + /** + * Suppresses the hidden submit button `Form.Root` otherwise renders, + * for callers that provide their own submission affordance and would + * otherwise expose two submit controls to assistive technology. + * + * Note what the hidden button is for: it is what makes Enter submit a + * form with more than one field at all. A caller that omits it takes + * on that constraint - the form must have exactly one field, or Enter + * reaches nothing. + */ + omitSubmitButton?: boolean; }; TextInput: { className?: string; @@ -316,9 +339,8 @@ export type ComponentProps = { placeholder?: string; disabled?: boolean; value: string; - onKeyDown: (event: KeyboardEvent) => void; + onKeyDown?: (event: KeyboardEvent) => void; onChange: (event: ChangeEvent) => void; - onSubmit?: () => void; autoComplete?: HTMLInputAutoCompleteAttribute; "aria-activedescendant"?: string; ref?: ForwardedRef; diff --git a/packages/react/src/util/mergeRefs.ts b/packages/react/src/util/mergeRefs.ts index 5137d0c030..7696ee2e8c 100644 --- a/packages/react/src/util/mergeRefs.ts +++ b/packages/react/src/util/mergeRefs.ts @@ -1,3 +1,5 @@ +import { useMemo } from "react"; + // https://github.com/gregberge/react-merge-refs/blob/main/src/index.tsx export function mergeRefs( refs: Array< @@ -14,3 +16,25 @@ export function mergeRefs( }); }; } + +/** + * {@link mergeRefs}, memoized on the refs themselves. + * + * `mergeRefs` returns a new callback on every call, and React detaches and + * reattaches a ref whose identity changed - calling it with `null` and then + * the element again on every render. Callers that keep their own ref + * alongside a forwarded one want the stable version, so this is the one to + * reach for from a component. + * + * Mirrors `react-merge-refs`' own `useMergeRefs`: the refs array is spread + * into the dependency list, which assumes a caller passes the same number of + * refs on every render - true of every use here, and of the upstream hook. + */ +export function useMergeRefs( + refs: Array< + React.MutableRefObject | React.LegacyRef | undefined | null + >, +): React.RefCallback { + // eslint-disable-next-line react-hooks/exhaustive-deps -- see above + return useMemo(() => mergeRefs(refs), refs); +} diff --git a/packages/shadcn/src/form/Form.tsx b/packages/shadcn/src/form/Form.tsx index 0ad9930b0e..f0cc1e7d0e 100644 --- a/packages/shadcn/src/form/Form.tsx +++ b/packages/shadcn/src/form/Form.tsx @@ -1,10 +1,32 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; +import { ComponentProps, useDictionary } from "@blocknote/react"; export const Form = (props: ComponentProps["Generic"]["Form"]["Root"]) => { - const { children, ...rest } = props; + const { children, onSubmit, omitSubmitButton, ...rest } = props; + const dict = useDictionary(); assertEmpty(rest); - return <>{children}; + return ( + { + // These forms have no action — a real submission would navigate. + event.preventDefault(); + onSubmit?.(); + }} + > + {children} + {/* + Gives the form a submit button, which is what makes Enter submit it at + all once a caller renders more than one field (see the `onSubmit` + contract in `ComponentsContext`). Visually hidden rather than absent, + so assistive technology still has a labelled control to activate. + */} + {!omitSubmitButton && ( + + )} + + ); }; diff --git a/packages/shadcn/src/form/TextInput.tsx b/packages/shadcn/src/form/TextInput.tsx index 675e7409fa..4527984db3 100644 --- a/packages/shadcn/src/form/TextInput.tsx +++ b/packages/shadcn/src/form/TextInput.tsx @@ -1,6 +1,6 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { ComponentProps, useMergeRefs } from "@blocknote/react"; +import { forwardRef, useEffect, useRef } from "react"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; import { cn } from "../lib/utils.js"; @@ -21,7 +21,6 @@ export const TextInput = forwardRef< disabled, onKeyDown, onChange, - onSubmit, autoComplete: _autoComplete, "aria-activedescendant": ariaActivedescendant, rightSection, // TODO: add rightSection @@ -30,6 +29,19 @@ export const TextInput = forwardRef< assertEmpty(rest); + // Focus with `preventScroll`, rather than the native `autofocus`: these + // inputs live in popovers that floating-ui positions *after* mount, so the + // browser's scroll-into-view runs while the popover is still at its + // pre-positioned spot and yanks the page (on mobile, right out from under + // the block being edited). + const inputRef = useRef(null); + const setRefs = useMergeRefs([inputRef, ref]); + useEffect(() => { + if (autoFocus) { + inputRef.current?.focus({ preventScroll: true }); + } + }, [autoFocus]); + const ShadCNComponents = useShadCNComponentsContext()!; return ( @@ -51,14 +63,12 @@ export const TextInput = forwardRef< className={cn(className, "h-auto border-none p-0")} id={label} name={name} - autoFocus={autoFocus} placeholder={placeholder} disabled={disabled} value={value} onKeyDown={onKeyDown} onChange={onChange} - onSubmit={onSubmit} - ref={ref} + ref={setRefs} aria-activedescendant={ariaActivedescendant} /> diff --git a/packages/shadcn/src/style.css b/packages/shadcn/src/style.css index b675e6d513..e9a11db477 100644 --- a/packages/shadcn/src/style.css +++ b/packages/shadcn/src/style.css @@ -73,3 +73,23 @@ color: var(--bn-colors-highlights-red-background); font-weight: bold; } + +/* The submit button `Form.Root` renders so that Enter reaches the form + * regardless of how many fields a popover has. It carries no visual design of + * its own - the popovers commit on Enter - but it stays in the accessibility + * tree with a real label, so screen readers and voice control have a submit + * control to operate. It is out of the tab order: keeping a control nobody can + * see as a tab stop would strand sighted keyboard users on invisible focus, + * and Enter already submits for them. + */ +.bn-form-submit { + border: 0; + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} diff --git a/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx b/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx index 7f68224498..515fae7d1c 100644 --- a/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx +++ b/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx @@ -38,16 +38,6 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => { const [internalPromptText, setInternalPromptText] = useState(""); const promptTextToUse = promptText || internalPromptText; - const handleEnter = useCallback( - async (event: KeyboardEvent) => { - if (event.key === "Enter" && !event.nativeEvent.isComposing) { - // console.log("ENTER", currentEditingPrompt); - onManualPromptSubmit(promptTextToUse); - } - }, - [promptTextToUse, onManualPromptSubmit], - ); - const handleChange = useCallback( (event: ChangeEvent) => { const newValue = event.currentTarget.value; @@ -75,21 +65,38 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => { ? `bn-suggestion-menu-item-${selectedIndex}` : undefined; + /** + * What Enter does here depends on whether the menu is showing anything: + * with suggestions it picks the highlighted one, and without it submits + * whatever was typed as a prompt. + * + * Both cases are decided in {@link submit}, so that the form's `submit` + * event - which is the only signal a mobile IME's action key produces - + * makes the same choice a key press does. + */ + const submit = useCallback(() => { + if (items.length > 0) { + items[selectedIndex]?.onItemClick(); + } else { + onManualPromptSubmit(promptTextToUse); + } + }, [items, selectedIndex, onManualPromptSubmit, promptTextToUse]); + const handleKeyDown = useCallback( (event: KeyboardEvent) => { // TODO: handle backspace to close - if (event.key === "Enter" && !event.nativeEvent.isComposing) { - if (items.length > 0) { - handler(event); - } else { - // TODO: check focus? - void handleEnter(event); - } - } else { - handler(event); + if ( + event.key === "Enter" && + !event.nativeEvent.isComposing && + items.length === 0 + ) { + // `handler` swallows Enter unconditionally, so with nothing to pick it + // has to be left alone for the event to reach the form. + return; } + handler(event); }, - [handleEnter, handler, items.length], + [handler, items.length], ); // Resets index when items change @@ -114,7 +121,7 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => { return (
- + =24.0.0, npm: '>=10'} + + '@appium/base-plugin@3.3.4': + resolution: {integrity: sha512-QL08kRFC6IXcqYHu/ugyllW5Kg3tGx0+iDBtB2hxvyE5VpUMBhS763EPH20WuTbqplkD360XXdlSmgEVhJubEA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + '@appium/css-locator-to-native@1.0.6': + resolution: {integrity: sha512-65UfoooziCETtDWZZ7Tb+MC8YEjJK5iKsGks4Cn/rJAwWFLjlRkW/pdK0Kr1Lf25Mo/JnB1bk7PEes/clvdMhA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + '@appium/docutils@3.0.0': + resolution: {integrity: sha512-R+q7jvSJm+vjV+8+FRNy5lIx6GieRna1rOOGnKmlxkTBUNi9q4VeCPfkT9x1CPAqgco+W2ZugelpM8oAtJFRnw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + hasBin: true + + '@appium/logger@2.0.11': + resolution: {integrity: sha512-0TIxQy09XMOmaUYA4E4v4jZP7zsO3CAuUZQQAbAQdfGGlLcPVFx+fePJdOU+WaqarduYZM0DfXgqfp8lnW2r7w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + '@appium/schema@1.3.0': + resolution: {integrity: sha512-A/1zs8jUr9q/0Ft3dXSvWQN7JMo/bIcFv5o34fWMRtxZwtHsbl44t5PP5nirZOLZfD8G4oFvm0NSyvjs+sqAzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + '@appium/strongbox@1.1.3': + resolution: {integrity: sha512-w0e/0ffwVHILPzujdFmopC+F+r4yNmhob5z3k1EqUgGcA+bpQvujuWhK15beDctAh2gMupwpWc7SpMw0xuqbsA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + '@appium/strongbox@2.0.0': + resolution: {integrity: sha512-ZA1tvF0JkXcG24QQoaYtAvGUsh+Wwa6Qdgwr8cmtxBggRrWfupgZDmHJF3rQmNi1cFosvEFo6ILMq879xXX7DQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + '@appium/support@7.2.7': + resolution: {integrity: sha512-6TbmICepPT+GKM6Wj/Gdp2T2dHKXO/MLZWW3OY9YT2sbnQNKTXtQvjspLwQHhxndkG74jFW1Cu5KKBAjkZecXA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + '@appium/types@1.7.0': + resolution: {integrity: sha512-V8BC8mpdOcD9MW6V8dkBWIeaPBxJaRek+H2mRZcxq8D2yUuWQLgiD4jJaRMp6Aa3Mrp5G87fvQerAhC56b7stg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + peerDependencies: + '@appium/logger': ^2.0.0 + '@ariakit/core@0.4.18': resolution: {integrity: sha512-9urEa+GbZTSyredq3B/3thQjTcSZSUC68XctwCkJNH/xNfKN5O+VThiem2rcJxpsGw8sRUQenhagZi0yB4foyg==} @@ -6870,6 +6931,9 @@ packages: '@base2/pretty-print-object@1.0.2': resolution: {integrity: sha512-rBha0UDfV7EmBRjWrGG7Cpwxg8WomPlo0q+R2so47ZFf9wy4YKJzLuHcVa0UGFjdcLZj/4F/1FNC46GIQhe7sA==} + '@bazel/runfiles@6.5.0': + resolution: {integrity: sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==} + '@better-auth/core@1.4.22': resolution: {integrity: sha512-l20Ia10lI9iGL+bkjggamQP9lQuiAeB/EYfEx5EQ4AcPrLojG6Doc0UDw5VZM66VXcMGs3bgC8P7WiaJv4Walg==} peerDependencies: @@ -6907,6 +6971,10 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -6943,6 +7011,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -10224,6 +10295,12 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sidvind/better-ajv-errors@5.0.0': + resolution: {integrity: sha512-FeI/V2KGtOaDX+r0akidCGYy79lVR4YnAqk1GFgZFuHADErCAEmtZL4+IdCAcDXHqfZsII3fs9DrfC1pIR+19w==} + engines: {node: ^20.19 || ^22.12 || >= 24.0} + peerDependencies: + ajv: ^7.0.0 || ^8.0.0 + '@smithy/chunked-blob-reader-native@4.2.3': resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} engines: {node: '>=18.0.0'} @@ -10436,6 +10513,9 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -10898,6 +10978,9 @@ packages: '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} + '@types/selenium-webdriver@4.35.6': + resolution: {integrity: sha512-8nfyMRi4VvkY9QrQGyY/zkleAhnjnmE8YtdEeoCrWe3izp1P9vo9f5VTNRYF0up+l+kn+VuZah+je+bLddNV+g==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -10907,6 +10990,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -11601,6 +11687,10 @@ packages: resolution: {integrity: sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + abs-svg-path@0.1.1: resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} @@ -11618,6 +11708,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -11706,9 +11800,52 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + appium-ios-device@3.1.21: + resolution: {integrity: sha512-jufABr3k6fBGMzBlbzU4R2J8JfLvC5HYWscKEu0ntXz2YmYc+q8/iqXCpmp3rpqR/jGK5Ibqdrt0WkIpnQHQ3g==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + appium-ios-remotexpc@5.17.1: + resolution: {integrity: sha512-6jpLWcbLpnLqNf14IlHza+lEZVjbylsdVNwPqtTZxooWrGI6Ufjd5B7Mzf6NoxsY28iY0vR7BJ3JDLz4LXJXxQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + appium-ios-simulator@9.1.2: + resolution: {integrity: sha512-IVZKABOIvY8NZhYmNHVFc56MQhpoK1UXRSuzXrYW2jFu7jkb6n2szcfDefU4JS4rFOJOaA4LqOAQ9mcUUv+PSg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + appium-ios-tuntap@2.0.1: + resolution: {integrity: sha512-OXRf4Shd6GObfswJpaN1b4/IETILpTEviV53TLQEn+v0ny/bBdE++YKTJhrV43Lp+RQ5F5Xix592Ath5RkuJ2Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + appium-remote-debugger@17.4.0: + resolution: {integrity: sha512-aIlzJmxlhD05A8Bz0rw95ObnaYv5gvwQBHj+oGrGh7RoESD2X965qpI9IhRPKWsNeJNudqjk51zAAsvAbbUciA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + appium-webdriveragent@16.11.4: + resolution: {integrity: sha512-YQLwHGcie5aYdEaYExd+5SEP5RfKGTWn65UKgnOU8NgM/DI1/WtOvGHlFbkMmKaHkk6ulXoIICKoYVN6tC9SsQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + appium-xcode@7.1.0: + resolution: {integrity: sha512-sgmzY4WjXvjYBK6CZM5UzZ169/qthUPk7LxTMuqu1JE75MbP8SazrWRWO/R7DQLQfnvHset9oQ6a+5IL6XrDrg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + appium-xcuitest-driver@12.8.2: + resolution: {integrity: sha512-3DVL3S9RKZPDGzoB8R/qO94f6tmmr7XOwXiCQAWzEd0rr95YzbVWZfJDgG8UhtQadx0ghN/2I5Cjjg44Z7V4VA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + peerDependencies: + appium: ^3.0.0-rc.2 + + appium@3.7.0: + resolution: {integrity: sha512-2AWajtPbjYGTJmVqpavGctO7zK9M7Q74L3vK9lFKPs6qr3N3/xy3STHnkXJk/wgaZ+BDgeKD68zQQ5sAP6A/cA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + hasBin: true + arch@2.2.0: resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + archiver@8.0.0: + resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==} + engines: {node: '>=18'} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -11718,6 +11855,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.0: + resolution: {integrity: sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==} + args-tokenizer@0.3.0: resolution: {integrity: sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q==} @@ -11767,6 +11907,19 @@ packages: async-limiter@1.0.1: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + + async@2.6.4: + resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asyncbox@6.4.2: + resolution: {integrity: sha512-CXEnvX5i4UtAdu3Egtqn5At9C1O3bIqtS3EAQGz8bA5H11cvu9eO17IxOWiM8Q8DUFk9sD251s9vsAXFKXXFgg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -11787,6 +11940,20 @@ packages: axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -11804,6 +11971,43 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.8.1: + resolution: {integrity: sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.4: + resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.5.2: + resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==} + base64-js@0.0.8: resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} engines: {node: '>= 0.4'} @@ -11820,6 +12024,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + better-auth@1.4.22: resolution: {integrity: sha512-CXQ7ZLDkf/I9iaVTNuejJ7FlWal50hRPIv1n0lqMipvthEoMx+2RQyNXUvzGRjltSe5d9rcZPI3IxdtS1A5+YA==} peerDependencies: @@ -11897,6 +12105,10 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -11906,6 +12118,13 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -11913,6 +12132,13 @@ packages: resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} engines: {node: '>=14.16'} + bplist-creator@0.1.1: + resolution: {integrity: sha512-Ese7052fdWrxp/vqSJkydgx/1MdBnNOCV2XVfbmdGWD2H6EYza+Q4pyYSuVSnCUD22hfI/BFI4jHaC3NLXLlJQ==} + + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} @@ -11934,6 +12160,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -12069,6 +12299,10 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -12092,6 +12326,14 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + clone@2.1.2: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} engines: {node: '>=0.8'} @@ -12116,6 +12358,10 @@ packages: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -12127,6 +12373,10 @@ packages: resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} engines: {node: '>=18'} + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -12138,6 +12388,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -12152,6 +12406,10 @@ packages: commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compress-commons@7.0.1: + resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==} + engines: {node: '>=18'} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -12186,6 +12444,18 @@ packages: resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-gitmoji@0.1.5: resolution: {integrity: sha512-4wqOafJdk2tqZC++cjcbGcaJ13BZ3kwldf06PTiAQRAB76Z1KJwZNL1SaRZMi2w1FM9RYTgZ6QErS8NUl/GBmQ==} @@ -12195,6 +12465,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -12223,6 +12497,15 @@ packages: countries-list@3.3.0: resolution: {integrity: sha512-XRUjS+dcZuNh/fg3+mka3bXgcg4TbQZ1gaK5IJqO6qulerBANl1bmrd20P2dgmPkBpP+5FnejiSF1gd7bgAg+g==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@7.0.1: + resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==} + engines: {node: '>=18'} + cropperjs@1.5.7: resolution: {integrity: sha512-sGj+G/ofKh+f6A4BtXLJwtcKJgMUsXYVUubfTo9grERiDGXncttefmue/fyQFvn8wfdyoD1KhDRYLfjkJFl0yw==} @@ -12230,6 +12513,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-selector-parser@3.3.0: + resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -12439,6 +12725,14 @@ packages: supports-color: optional: true + debug@3.1.0: + resolution: {integrity: sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -12488,6 +12782,9 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + deferred-leveldown@5.3.0: resolution: {integrity: sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==} engines: {node: '>=6'} @@ -12515,6 +12812,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -12529,12 +12830,19 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} dfa@1.2.0: resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + direction@1.0.4: resolution: {integrity: sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==} hasBin: true @@ -12585,9 +12893,15 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.331: resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} @@ -12606,6 +12920,13 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding-down@6.3.0: resolution: {integrity: sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==} engines: {node: '>=6'} @@ -12638,6 +12959,10 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + env-paths@4.0.0: + resolution: {integrity: sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==} + engines: {node: '>=20'} + errno@0.1.8: resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} hasBin: true @@ -12702,6 +13027,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -12763,6 +13091,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -12830,12 +13159,23 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + event-source-plus@0.1.15: resolution: {integrity: sha512-kt3z/UwDbZxHttynwmXlqTf1qknWqPgswsbvSok1ob6SveMts4BqRXow6aiwB55xTY1XvSXuhn+IvYQErWLyKA==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -12859,6 +13199,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -12876,6 +13220,9 @@ packages: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -12895,6 +13242,10 @@ packages: resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} hasBin: true + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -12904,6 +13255,9 @@ packages: picomatch: optional: true + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -12914,6 +13268,10 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} @@ -12928,6 +13286,9 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -12952,9 +13313,17 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -12972,6 +13341,14 @@ packages: react-dom: optional: true + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + frimousse@0.2.0: resolution: {integrity: sha512-viSrsVQWKR4Q7xzC0lkx3Wu9i1+IHrth0QXn0nlIIJXpltwUnjkGXSTuoW7WHI5aJ4z49WR8E/pyQizFjlNtTA==} peerDependencies: @@ -12990,6 +13367,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + ftp-response-parser@1.0.1: + resolution: {integrity: sha512-++Ahlo2hs/IC7UVQzjcSAfeUpCwTTzs4uvG5XfGnsinIFkWUYF4xWwPd5qZuK8MJrmUIxFMuHcfqaosCDjvIWw==} + engines: {node: '>=0.8.0'} + fumadocs-core@16.5.0: resolution: {integrity: sha512-uK57jRjCyuCBuBg+mCeeuPUxryUrHJc8J7Eefc4Q+XqPbS3SwHaSfkKzeHRSErOqGP7XvS2l/oM7NCD1eJM7ug==} peerDependencies: @@ -13212,6 +13593,9 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + handle-thing@2.0.1: + resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -13242,6 +13626,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-to-estree@3.1.3: resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} @@ -13267,6 +13655,13 @@ packages: resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} engines: {node: '>=16.9.0'} + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + + hpack.js@2.1.6: + resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} + hsl-to-hex@1.0.0: resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==} @@ -13293,6 +13688,16 @@ packages: htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-deceiver@1.2.7: + resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -13308,6 +13713,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -13379,6 +13788,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -13472,6 +13885,10 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -13494,6 +13911,9 @@ packages: is-node-process@1.2.0: resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number-like@1.0.8: + resolution: {integrity: sha512-6rZi3ezCyFcn5L71ywzz2bS5b2Igl1En3eTlZlvKjpz1n3IZLAYMbKYAIQgFmEu0GENg92ziU/faEOA/aixjbA==} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -13513,6 +13933,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -13520,6 +13943,10 @@ packages: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} + is-safe-filename@0.1.1: + resolution: {integrity: sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==} + engines: {node: '>=20'} + is-set@2.0.3: resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} engines: {node: '>= 0.4'} @@ -13535,6 +13962,10 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -13547,6 +13978,10 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + is-unicode-supported@1.3.0: resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} engines: {node: '>=12'} @@ -13578,6 +14013,9 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -13587,6 +14025,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -13629,6 +14071,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + js2xmlparser2@0.2.0: + resolution: {integrity: sha512-SzFGc1hQqzpDcalKmrM5gobSMGRSRg2lgaZrHGIfowrmd8+uaI+PWW62jcCGIqI+b4wdyYK0VKMhvVtJfkD0cg==} + jsdom@29.0.2: resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -13643,6 +14088,10 @@ packages: engines: {node: '>=6'} hasBin: true + jsftp@2.1.3: + resolution: {integrity: sha512-r79EVB8jaNAZbq8hvanL8e8JGu2ZNr2bXdHC4ZdQhRImpSPpnWwm5DYVzQ5QxJmtGtKhNNuvqGgbNaFl604fEQ==} + engines: {node: '>=6'} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -13693,10 +14142,21 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + klaw@4.1.0: + resolution: {integrity: sha512-1zGZ9MF9H22UnkpVeuaGKOjfA2t6WrfdrJmGjy16ykcjnKQDmHVX+KI477rpbGevz/5FD4MC3xf1oxylBgcaQw==} + engines: {node: '>=14.14.0'} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + kysely@0.28.15: resolution: {integrity: sha512-r2clcf7HLWvDXaVUEvQymXJY4i3bSOIV3xsL/Upy3ZfSv5HeKsk9tsqbBptLvth5qHEIhxeHTA2jNLyQABkLBA==} engines: {node: '>=20.0.0'} @@ -13707,6 +14167,10 @@ packages: layout-base@2.0.1: resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + leac@0.6.0: resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} @@ -13916,6 +14380,10 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + linebreak@1.1.0: resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} @@ -13930,6 +14398,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lockfile@1.0.4: + resolution: {integrity: sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} @@ -13940,12 +14411,19 @@ packages: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isfinite@3.3.2: + resolution: {integrity: sha512-7FGG40uhC8Mm633uKW1r58aElFlBlxCrg9JfSi3P6aYiWmfiWF0PgMd86ZUsxE5GwWPdHoS2+48bwTh2VPkIQA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -13954,6 +14432,10 @@ packages: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -13968,6 +14450,10 @@ packages: resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -14092,15 +14578,31 @@ packages: media-engine@1.0.3: resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} mermaid@11.16.0: resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + method-override@3.0.0: + resolution: {integrity: sha512-IJ2NNN/mSl9w3kzWB92rcdHpz+HjkxhDJWNDBqSlas+zQdP8wBiJzITPg08M/k2uVvMow7Sk41atndNtt/PHSA==} + engines: {node: '>= 0.10'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + mhchemparser@4.2.1: resolution: {integrity: sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==} @@ -14278,6 +14780,10 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + morgan@1.11.0: + resolution: {integrity: sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==} + engines: {node: '>= 0.8.0'} + motion-dom@12.38.0: resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} @@ -14419,6 +14925,14 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + + node-devicectl@2.1.0: + resolution: {integrity: sha512-qa0+aR3a6HYmGZfqMDK1laYWxSAX/1UxtffZe+Ix/4jbyFAJLTVh9kOLYDeQSouVf6vIsEl8Xzc1WwZyS65P9g==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -14439,13 +14953,25 @@ packages: resolution: {integrity: sha512-dSq1xmcPDKPZ2EED2S6zw/b9NKsqzXRE6dVr8TVQnI3FJOTteUMuqF3Qqs6LZg+mLGYJWqQzMbIjMtJqTv87nQ==} hasBin: true + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + node-simctl@9.1.1: + resolution: {integrity: sha512-R3idBf67XKFa5eAax2S0pmyDzRQVHn77XwJhivpi8dp75mOYDTijzPWsH50couuA9dPkDBZPKSeTyt6vmg5EUw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + nodemailer@7.0.13: resolution: {integrity: sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==} engines: {node: '>=6.0.0'} + normalize-package-data@8.0.0: + resolution: {integrity: sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==} + engines: {node: ^20.17.0 || >=22.9.0} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -14502,6 +15028,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -14511,6 +15040,10 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + on-headers@1.1.0: resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} engines: {node: '>= 0.8'} @@ -14518,6 +15051,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -14544,6 +15080,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + ora@8.2.0: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} @@ -14635,6 +15175,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-listing@1.1.3: + resolution: {integrity: sha512-a1p1i+9Qyc8pJNwdrSvW1g5TPxRH0sywVi6OzVvYHRo6xwF9bDWBxtH0KkxeOOvhUE8vAMtiSfsYQFOuK901eA==} + engines: {node: '>=0.6.21'} + parse-svg-path@0.1.2: resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} @@ -14647,6 +15191,10 @@ packages: parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -14702,6 +15250,9 @@ packages: peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -14754,11 +15305,24 @@ packages: engines: {node: '>=18'} hasBin: true + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + playwright@1.60.0: resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true + plist@4.0.0: + resolution: {integrity: sha512-4dOqNo0Y2NpfSf9q4+zr4bh7pzNWeckIam34Z0KYJhg8qtNNfh59VbD+Yna5SjwcxawVvLKx5w5FtuCijpEF4Q==} + engines: {node: '>=18'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + png-js@2.0.0: resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==} @@ -14772,6 +15336,10 @@ packages: points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + portscanner@2.2.0: + resolution: {integrity: sha512-IFroCz/59Lqa2uBvzK3bKDbDDIEaAY8XJ1jFxcLWTqosrsc32//P4VuSB2vZXoHiHqOmx8B5L5hnKOxL/7FlPw==} + engines: {node: '>=0.4', npm: '>=1.0.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -14840,6 +15408,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -14934,6 +15506,10 @@ packages: prosemirror-view@1.42.2: resolution: {integrity: sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -14951,6 +15527,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -14974,6 +15554,14 @@ packages: resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} engines: {node: '>= 0.6'} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} @@ -15124,6 +15712,9 @@ packages: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} + readable-stream@1.1.14: + resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -15131,6 +15722,14 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@3.0.0: + resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} + engines: {node: '>=18'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -15249,6 +15848,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -15267,6 +15870,11 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -15295,6 +15903,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -15323,9 +15935,16 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -15357,6 +15976,13 @@ packages: selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + select-hose@2.0.0: + resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} + + selenium-webdriver@4.48.0: + resolution: {integrity: sha512-rKM9uXFRWcF9aThrZQDNQH2/9Et/WvMZbg3/x1rnSYWoXiwJuShYeH0IAli8Cuw+c3lEV0UWPfUz88H+fvW9Hg==} + engines: {node: '>= 22.0.0'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -15371,9 +15997,21 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-favicon@2.5.1: + resolution: {integrity: sha512-JndLBslCLA/ebr7rS3d+/EKkzTsTi1jI2T9l+vHfAaGJ7A7NhtDpSZ0lx81HCNWnnE0yHncG+SSnVf9IMxOwXQ==} + engines: {node: '>= 0.8.0'} + serve-handler@6.1.7: resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + serve@14.2.6: resolution: {integrity: sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==} engines: {node: '>= 14'} @@ -15397,6 +16035,9 @@ packages: setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.3: resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} engines: {node: '>=20.9.0'} @@ -15414,6 +16055,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + shell-quote@1.8.3: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} @@ -15438,6 +16083,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -15514,6 +16163,25 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + spdy-transport@3.0.0: + resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==} + + spdy@4.0.2: + resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==} + engines: {node: '>=6.0.0'} + speech-rule-engine@4.1.4: resolution: {integrity: sha512-i/VCLG1fvRc95pMHRqG4aQNscv+9aIsqA2oI7ZQS51sTdUcDHYX6cpT8/tqZ+enjs1tKVwbRBWgxut9SWn+f9g==} hasBin: true @@ -15525,6 +16193,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -15553,6 +16224,16 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-buffers@2.2.0: + resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} + engines: {node: '>= 0.10.0'} + + stream-combiner@0.2.2: + resolution: {integrity: sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==} + + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -15568,6 +16249,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -15580,6 +16265,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -15704,6 +16392,16 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar-stream@3.2.1: + resolution: {integrity: sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==} + + teen_process@4.2.1: + resolution: {integrity: sha512-jtLcBR01HF2z2FGhfvuMpbr+y8wnVQumrh947dLz5Iodv2iZGIJg79TVYK9oznXb5gkQznxdwgonxGQLhmWeWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10'} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + terser-webpack-plugin@5.5.0: resolution: {integrity: sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==} engines: {node: '>= 10.13.0'} @@ -15725,10 +16423,19 @@ packages: engines: {node: '>=10'} hasBin: true + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + throttleit@2.1.0: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -15775,6 +16482,14 @@ packages: resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} hasBin: true + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -15797,9 +16512,16 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-dedent@2.3.0: resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} engines: {node: '>=6.10'} @@ -15856,6 +16578,14 @@ packages: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -15940,6 +16670,14 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unorm@1.6.0: + resolution: {integrity: sha512-b2/KCUlYZUeA7JFUuRJZPUtr4gZvBh7tavtv4fvk4+KV9pfGiR6CQAQAWl49ZpR3ts2dk4FYkP7EIgDJoiOLDA==} + engines: {node: '>= 0.4.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unplugin-utils@0.3.1: resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} engines: {node: '>=20.19.0'} @@ -15984,6 +16722,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -15991,6 +16732,10 @@ packages: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -16004,6 +16749,9 @@ packages: typescript: optional: true + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -16168,6 +16916,12 @@ packages: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} + wbuf@1.7.3: + resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-streams-polyfill@4.2.0: resolution: {integrity: sha512-0rYDzGOh9EZpig92umN5g5D/9A1Kff7k0/mzPSSCY8jEQeYkgRMoY7LhbXtUCWzLCMX0TUE9aoHkjFNB7D9pfA==} engines: {node: '>= 8'} @@ -16228,6 +16982,11 @@ packages: engines: {node: '>= 8'} hasBin: true + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -16243,6 +17002,14 @@ packages: wildcard@1.1.2: resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==} + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -16259,6 +17026,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -16297,6 +17068,18 @@ packages: utf-8-validate: optional: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -16324,6 +17107,10 @@ packages: xml@1.0.1: resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -16388,10 +17175,22 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@18.1.0: + resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + yjs@13.6.30: resolution: {integrity: sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -16421,6 +17220,10 @@ packages: yuku-parser@0.5.48: resolution: {integrity: sha512-OWBfhrpgK9+/4+IXG9oT8Bao4AhViQA7vdyNNH7EUg8dQYgwa70XtIBWTpCEme1P1ECyoDNYkn0wT63f8XRcVA==} + zip-stream@7.0.5: + resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} + engines: {node: '>=18'} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -16503,6 +17306,130 @@ snapshots: package-manager-detector: 1.7.0 tinyexec: 1.2.4 + '@appium/base-driver@10.8.0(@appium/logger@2.0.11)(@types/node@25.6.0)': + dependencies: + '@appium/support': 7.2.7(@types/node@25.6.0) + '@appium/types': 1.7.0(@appium/logger@2.0.11) + async-lock: 1.4.1 + asyncbox: 6.4.2 + axios: 1.19.0 + body-parser: 2.3.0 + express: 5.2.1 + fastest-levenshtein: 1.0.16 + http-status-codes: 2.3.0 + lru-cache: 11.5.2 + method-override: 3.0.0 + morgan: 1.11.0 + path-to-regexp: 8.4.2 + serve-favicon: 2.5.1 + type-fest: 5.8.0 + optionalDependencies: + spdy: 4.0.2 + transitivePeerDependencies: + - '@appium/logger' + - '@types/node' + - bare-abort-controller + - bare-buffer + - debug + - react-native-b4a + - supports-color + + '@appium/base-plugin@3.3.4(@appium/logger@2.0.11)(@types/node@25.6.0)': + dependencies: + '@appium/base-driver': 10.8.0(@appium/logger@2.0.11)(@types/node@25.6.0) + '@appium/support': 7.2.7(@types/node@25.6.0) + '@appium/types': 1.7.0(@appium/logger@2.0.11) + transitivePeerDependencies: + - '@appium/logger' + - '@types/node' + - bare-abort-controller + - bare-buffer + - debug + - react-native-b4a + - supports-color + + '@appium/css-locator-to-native@1.0.6': + dependencies: + css-selector-parser: 3.3.0 + + '@appium/docutils@3.0.0(@types/node@25.6.0)': + dependencies: + '@appium/support': 7.2.7(@types/node@25.6.0) + consola: 3.4.2 + diff: 9.0.0 + lilconfig: 3.1.3 + normalize-package-data: 8.0.0 + teen_process: 4.2.1 + type-fest: 5.8.0 + yaml: 2.9.0 + yargs: 18.1.0 + yargs-parser: 22.0.0 + transitivePeerDependencies: + - '@types/node' + - bare-abort-controller + - bare-buffer + - debug + - react-native-b4a + - supports-color + + '@appium/logger@2.0.11': + dependencies: + lru-cache: 11.5.2 + + '@appium/schema@1.3.0': + dependencies: + json-schema: 0.4.0 + + '@appium/strongbox@1.1.3': + dependencies: + env-paths: 4.0.0 + + '@appium/strongbox@2.0.0': + dependencies: + env-paths: 4.0.0 + + '@appium/support@7.2.7(@types/node@25.6.0)': + dependencies: + '@appium/logger': 2.0.11 + '@appium/types': 1.7.0(@appium/logger@2.0.11) + archiver: 8.0.0 + asyncbox: 6.4.2 + axios: 1.19.0 + bluebird: 3.7.2 + bplist-creator: 0.1.1 + bplist-parser: 0.3.2 + form-data: 4.0.6 + glob: 13.0.6 + jsftp: 2.1.3 + klaw: 4.1.0 + lockfile: 1.0.4 + normalize-package-data: 8.0.0 + plist: 4.0.0 + pluralize: 8.0.0 + sanitize-filename: 1.6.4 + semver: 7.8.5 + shell-quote: 1.10.0 + teen_process: 4.2.1 + type-fest: 5.8.0 + uuid: 14.0.2 + which: 6.0.1 + yauzl: 3.4.0 + optionalDependencies: + sharp: 0.35.3(@types/node@25.6.0) + transitivePeerDependencies: + - '@types/node' + - bare-abort-controller + - bare-buffer + - debug + - react-native-b4a + - supports-color + + '@appium/types@1.7.0(@appium/logger@2.0.11)': + dependencies: + '@appium/logger': 2.0.11 + '@appium/schema': 1.3.0 + type-fest: 5.8.0 + '@ariakit/core@0.4.18': {} '@ariakit/react-core@0.4.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': @@ -17146,6 +18073,8 @@ snapshots: '@base2/pretty-print-object@1.0.2': {} + '@bazel/runfiles@6.5.0': {} + '@better-auth/core@1.4.22(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0)': dependencies: '@better-auth/utils': 0.3.0 @@ -17179,6 +18108,8 @@ snapshots: '@chevrotain/types@11.1.2': {} + '@colors/colors@1.6.0': {} + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -17203,6 +18134,12 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + '@date-fns/tz@1.4.1': {} '@emnapi/core@1.9.2': @@ -20306,6 +21243,11 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sidvind/better-ajv-errors@5.0.0(ajv@8.20.0)': + dependencies: + ajv: 8.20.0 + kleur: 4.1.5 + '@smithy/chunked-blob-reader-native@4.2.3': dependencies: '@smithy/util-base64': 4.3.2 @@ -20638,6 +21580,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + '@socket.io/component-emitter@3.1.2': {} '@stablelib/base64@1.0.1': {} @@ -21124,6 +22071,11 @@ snapshots: '@types/retry@0.12.2': {} + '@types/selenium-webdriver@4.35.6': + dependencies: + '@types/node': 25.6.0 + '@types/ws': 8.18.1 + '@types/statuses@2.0.6': {} '@types/tedious@4.0.14': @@ -21132,6 +22084,8 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/triple-beam@1.3.5': {} + '@types/trusted-types@2.0.7': optional: true @@ -21736,6 +22690,10 @@ snapshots: '@zip.js/zip.js@2.8.26': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + abs-svg-path@0.1.1: {} abstract-leveldown@6.2.3: @@ -21761,6 +22719,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -21841,8 +22804,218 @@ snapshots: ansis@4.2.0: {} + appium-ios-device@3.1.21(@types/node@25.6.0): + dependencies: + '@appium/support': 7.2.7(@types/node@25.6.0) + asyncbox: 6.4.2 + axios: 1.20.0 + bplist-creator: 0.1.1 + bplist-parser: 0.3.2 + semver: 7.8.5 + transitivePeerDependencies: + - '@types/node' + - bare-abort-controller + - bare-buffer + - debug + - react-native-b4a + - supports-color + + appium-ios-remotexpc@5.17.1(@types/node@25.6.0): + dependencies: + '@appium/strongbox': 1.1.3 + '@appium/support': 7.2.7(@types/node@25.6.0) + '@xmldom/xmldom': 0.9.10 + appium-ios-tuntap: 2.0.1(@types/node@25.6.0) + async-lock: 1.4.1 + axios: 1.20.0 + commander: 14.0.3 + minimatch: 10.2.5 + node-devicectl: 2.1.0 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - '@types/node' + - bare-abort-controller + - bare-buffer + - debug + - react-native-b4a + - supports-color + optional: true + + appium-ios-simulator@9.1.2(@types/node@25.6.0): + dependencies: + '@appium/support': 7.2.7(@types/node@25.6.0) + '@xmldom/xmldom': 0.9.10 + appium-xcode: 7.1.0(@types/node@25.6.0) + async-lock: 1.4.1 + asyncbox: 6.4.2 + node-simctl: 9.1.1 + semver: 7.8.5 + teen_process: 4.2.1 + transitivePeerDependencies: + - '@types/node' + - bare-abort-controller + - bare-buffer + - debug + - react-native-b4a + - supports-color + + appium-ios-tuntap@2.0.1(@types/node@25.6.0): + dependencies: + '@appium/support': 7.2.7(@types/node@25.6.0) + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + transitivePeerDependencies: + - '@types/node' + - bare-abort-controller + - bare-buffer + - debug + - react-native-b4a + - supports-color + optional: true + + appium-remote-debugger@17.4.0(@appium/logger@2.0.11)(@types/node@25.6.0): + dependencies: + '@appium/base-driver': 10.8.0(@appium/logger@2.0.11)(@types/node@25.6.0) + '@appium/support': 7.2.7(@types/node@25.6.0) + appium-ios-device: 3.1.21(@types/node@25.6.0) + async-lock: 1.4.1 + asyncbox: 6.4.2 + teen_process: 4.2.1 + optionalDependencies: + appium-ios-remotexpc: 5.17.1(@types/node@25.6.0) + transitivePeerDependencies: + - '@appium/logger' + - '@types/node' + - bare-abort-controller + - bare-buffer + - debug + - react-native-b4a + - supports-color + + appium-webdriveragent@16.11.4(@appium/logger@2.0.11)(@types/node@25.6.0): + dependencies: + '@appium/base-driver': 10.8.0(@appium/logger@2.0.11)(@types/node@25.6.0) + '@appium/strongbox': 2.0.0 + '@appium/support': 7.2.7(@types/node@25.6.0) + appium-ios-simulator: 9.1.2(@types/node@25.6.0) + async-lock: 1.4.1 + asyncbox: 6.4.2 + axios: 1.20.0 + teen_process: 4.2.1 + transitivePeerDependencies: + - '@appium/logger' + - '@types/node' + - bare-abort-controller + - bare-buffer + - debug + - react-native-b4a + - supports-color + + appium-xcode@7.1.0(@types/node@25.6.0): + dependencies: + '@appium/support': 7.2.7(@types/node@25.6.0) + asyncbox: 6.4.2 + semver: 7.8.5 + teen_process: 4.2.1 + transitivePeerDependencies: + - '@types/node' + - bare-abort-controller + - bare-buffer + - debug + - react-native-b4a + - supports-color + + appium-xcuitest-driver@12.8.2(@appium/logger@2.0.11)(@types/node@25.6.0)(appium@3.7.0(@types/node@25.6.0)): + dependencies: + '@appium/css-locator-to-native': 1.0.6 + '@appium/strongbox': 1.1.3 + '@colors/colors': 1.6.0 + appium: 3.7.0(@types/node@25.6.0) + appium-ios-device: 3.1.21(@types/node@25.6.0) + appium-ios-simulator: 9.1.2(@types/node@25.6.0) + appium-remote-debugger: 17.4.0(@appium/logger@2.0.11)(@types/node@25.6.0) + appium-webdriveragent: 16.11.4(@appium/logger@2.0.11)(@types/node@25.6.0) + appium-xcode: 7.1.0(@types/node@25.6.0) + async-lock: 1.4.1 + asyncbox: 6.4.2 + axios: 1.20.0 + commander: 14.0.3 + dayjs: 1.11.21 + js2xmlparser2: 0.2.0 + lru-cache: 11.2.7 + node-devicectl: 2.1.0 + node-simctl: 9.1.1 + portscanner: 2.2.0 + semver: 7.8.5 + teen_process: 4.2.1 + winston: 3.19.0 + ws: 8.21.3 + optionalDependencies: + appium-ios-remotexpc: 5.17.1(@types/node@25.6.0) + sharp: 0.35.3(@types/node@25.6.0) + transitivePeerDependencies: + - '@appium/logger' + - '@types/node' + - bare-abort-controller + - bare-buffer + - bufferutil + - debug + - react-native-b4a + - supports-color + - utf-8-validate + + appium@3.7.0(@types/node@25.6.0): + dependencies: + '@appium/base-driver': 10.8.0(@appium/logger@2.0.11)(@types/node@25.6.0) + '@appium/base-plugin': 3.3.4(@appium/logger@2.0.11)(@types/node@25.6.0) + '@appium/docutils': 3.0.0(@types/node@25.6.0) + '@appium/logger': 2.0.11 + '@appium/schema': 1.3.0 + '@appium/support': 7.2.7(@types/node@25.6.0) + '@appium/types': 1.7.0(@appium/logger@2.0.11) + '@sidvind/better-ajv-errors': 5.0.0(ajv@8.20.0) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + argparse: 3.0.0 + asyncbox: 6.4.2 + axios: 1.19.0 + lilconfig: 3.1.3 + lru-cache: 11.5.2 + ora: 5.4.1 + semver: 7.8.5 + teen_process: 4.2.1 + type-fest: 5.8.0 + winston: 3.19.0 + ws: 8.21.3 + yaml: 2.9.0 + transitivePeerDependencies: + - '@types/node' + - bare-abort-controller + - bare-buffer + - bufferutil + - debug + - react-native-b4a + - supports-color + - utf-8-validate + arch@2.2.0: {} + archiver@8.0.0: + dependencies: + async: 3.2.6 + buffer-crc32: 1.0.0 + is-stream: 4.0.1 + lazystream: 1.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + readdir-glob: 3.0.0 + tar-stream: 3.2.1 + zip-stream: 7.0.5 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + arg@5.0.2: {} argparse@1.0.10: @@ -21851,6 +23024,8 @@ snapshots: argparse@2.0.1: {} + argparse@3.0.0: {} + args-tokenizer@0.3.0: {} aria-hidden@1.2.6: @@ -21920,6 +23095,18 @@ snapshots: async-limiter@1.0.1: optional: true + async-lock@1.4.1: {} + + async@2.6.4: + dependencies: + lodash: 4.18.1 + + async@3.2.6: {} + + asyncbox@6.4.2: + dependencies: + p-limit: 7.3.0 + asynckit@0.4.0: {} atomically@2.1.1: @@ -21949,6 +23136,28 @@ snapshots: transitivePeerDependencies: - debug + axios@1.19.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + axios@1.20.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + b4a@1.8.1: {} + babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.29.2 @@ -21965,6 +23174,35 @@ snapshots: balanced-match@4.0.4: {} + bare-events@2.9.2: {} + + bare-fs@4.8.1: + dependencies: + bare-events: 2.9.2 + bare-path: 3.1.1 + bare-stream: 2.13.4(bare-events@2.9.2) + bare-url: 2.5.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.4(bare-events@2.9.2): + dependencies: + b4a: 1.8.1 + streamx: 2.28.1 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.5.2: + dependencies: + bare-path: 3.1.1 + base64-js@0.0.8: {} base64-js@1.5.1: {} @@ -21973,6 +23211,10 @@ snapshots: baseline-browser-mapping@2.10.17: {} + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + better-auth@1.4.22(better-sqlite3@12.8.0)(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@25.6.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.10): dependencies: '@better-auth/core': 1.4.22(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) @@ -22013,6 +23255,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + big-integer@1.6.52: {} + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -22025,6 +23269,22 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bluebird@3.7.2: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bowser@2.14.1: {} boxen@7.0.0: @@ -22038,9 +23298,17 @@ snapshots: widest-line: 4.0.1 wrap-ansi: 8.1.0 - brace-expansion@1.1.13: + bplist-creator@0.1.1: dependencies: - balanced-match: 1.0.2 + stream-buffers: 2.2.0 + + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 concat-map: 0.0.1 brace-expansion@2.0.3: @@ -22067,6 +23335,8 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-crc32@1.0.0: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -22214,6 +23484,10 @@ snapshots: cli-boxes@3.0.0: {} + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -22236,6 +23510,14 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clone@1.0.4: {} + clone@2.1.2: {} clsx@2.1.1: {} @@ -22260,6 +23542,10 @@ snapshots: dependencies: color-name: 1.1.4 + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + color-name@1.1.4: {} color-name@2.1.0: {} @@ -22268,6 +23554,11 @@ snapshots: dependencies: color-name: 2.1.0 + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -22276,6 +23567,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@7.2.0: {} @@ -22284,6 +23577,14 @@ snapshots: commondir@1.0.1: {} + compress-commons@7.0.1: + dependencies: + crc-32: 1.2.2 + crc32-stream: 7.0.1 + is-stream: 4.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -22332,12 +23633,20 @@ snapshots: content-disposition@0.5.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + convert-gitmoji@0.1.5: {} convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + cookie@0.7.2: {} cookie@1.1.1: {} @@ -22367,6 +23676,13 @@ snapshots: countries-list@3.3.0: {} + crc-32@1.2.2: {} + + crc32-stream@7.0.1: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + cropperjs@1.5.7: {} cross-spawn@7.0.6: @@ -22375,6 +23691,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-selector-parser@3.3.0: {} + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -22609,6 +23927,10 @@ snapshots: dependencies: ms: 2.0.0 + debug@3.1.0: + dependencies: + ms: 2.0.0 + debug@3.2.7: dependencies: ms: 2.1.3 @@ -22642,6 +23964,10 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 + defaults@1.0.4: + dependencies: + clone: 1.0.4 + deferred-leveldown@5.3.0: dependencies: abstract-leveldown: 6.2.3 @@ -22670,6 +23996,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -22678,12 +24006,17 @@ snapshots: detect-node-es@1.1.0: {} + detect-node@2.1.0: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 dfa@1.2.0: {} + diff@9.0.0: {} + direction@1.0.4: {} doctrine@2.1.0: @@ -22742,8 +24075,12 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer@0.1.2: {} + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + electron-to-chromium@1.5.331: {} emoji-mart@5.6.0: {} @@ -22756,6 +24093,10 @@ snapshots: emoji-regex@9.2.2: {} + enabled@2.0.0: {} + + encodeurl@2.0.0: {} + encoding-down@6.3.0: dependencies: abstract-leveldown: 6.3.0 @@ -22798,6 +24139,10 @@ snapshots: env-paths@3.0.0: {} + env-paths@4.0.0: + dependencies: + is-safe-filename: 0.1.1 + errno@0.1.8: dependencies: prr: 1.0.1 @@ -22969,6 +24314,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -23135,12 +24482,22 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + event-source-plus@0.1.15: dependencies: ofetch: 1.5.1 + event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} eventsource-parser@3.0.6: {} @@ -23163,6 +24520,39 @@ snapshots: expect-type@1.3.0: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} extend-shallow@2.0.1: @@ -23175,6 +24565,8 @@ snapshots: fast-equals@5.4.0: {} + fast-fifo@1.3.2: {} + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -23193,10 +24585,14 @@ snapshots: path-expression-matcher: 1.2.0 strnum: 2.2.2 + fastest-levenshtein@1.0.16: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + fecha@4.2.3: {} + fflate@0.8.3: {} file-entry-cache@8.0.0: @@ -23205,6 +24601,17 @@ snapshots: file-uri-to-path@1.0.0: {} + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-root@1.1.0: {} find-up@5.0.0: @@ -23219,6 +24626,8 @@ snapshots: flatted@3.4.2: {} + fn.name@1.1.0: {} + follow-redirects@1.16.0: {} fontkit@2.0.4: @@ -23250,8 +24659,18 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + forwarded-parse@2.1.2: {} + forwarded@0.2.0: {} + fraction.js@4.3.7: {} framer-motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): @@ -23264,6 +24683,10 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + fresh@0.5.2: {} + + fresh@2.0.0: {} + frimousse@0.2.0(react@19.2.5): dependencies: react: 19.2.5 @@ -23276,6 +24699,10 @@ snapshots: fsevents@2.3.3: optional: true + ftp-response-parser@1.0.1: + dependencies: + readable-stream: 1.1.14 + fumadocs-core@16.5.0(@types/react@19.2.14)(lucide-react@0.562.0(react@19.2.5))(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@25.6.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6): dependencies: '@formatjs/intl-localematcher': 0.8.2 @@ -23488,6 +24915,9 @@ snapshots: hachure-fill@0.5.2: {} + handle-thing@2.0.1: + optional: true + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -23515,6 +24945,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.8 @@ -23586,6 +25020,18 @@ snapshots: hono@4.12.14: {} + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.2.7 + + hpack.js@2.1.6: + dependencies: + inherits: 2.0.4 + obuf: 1.1.2 + readable-stream: 2.3.8 + wbuf: 1.7.3 + optional: true + hsl-to-hex@1.0.0: dependencies: hsl-to-rgb-for-reals: 1.1.1 @@ -23619,6 +25065,19 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + http-deceiver@1.2.7: + optional: true + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-status-codes@2.3.0: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -23634,6 +25093,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -23695,6 +25158,8 @@ snapshots: internmap@2.0.3: {} + ipaddr.js@1.9.1: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -23782,6 +25247,8 @@ snapshots: dependencies: is-docker: 3.0.0 + is-interactive@1.0.0: {} + is-interactive@2.0.0: {} is-map@2.0.3: {} @@ -23794,6 +25261,10 @@ snapshots: is-node-process@1.2.0: {} + is-number-like@1.0.8: + dependencies: + lodash.isfinite: 3.3.2 + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -23807,6 +25278,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.8 @@ -23818,6 +25291,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + is-safe-filename@0.1.1: {} + is-set@2.0.3: {} is-shallow-equal@1.0.1: {} @@ -23828,6 +25303,8 @@ snapshots: is-stream@2.0.1: {} + is-stream@4.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -23843,6 +25320,8 @@ snapshots: dependencies: which-typed-array: 1.1.20 + is-unicode-supported@0.1.0: {} + is-unicode-supported@1.3.0: {} is-unicode-supported@2.1.0: {} @@ -23868,12 +25347,16 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@0.0.1: {} + isarray@1.0.0: {} isarray@2.0.5: {} isexe@2.0.0: {} + isexe@4.0.0: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -23921,6 +25404,8 @@ snapshots: dependencies: argparse: 2.0.1 + js2xmlparser2@0.2.0: {} + jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0): dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -23933,7 +25418,7 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.7 + lru-cache: 11.5.2 parse5: 8.0.0 saxes: 6.0.0 symbol-tree: 3.2.4 @@ -23951,6 +25436,17 @@ snapshots: jsesc@3.1.0: {} + jsftp@2.1.3: + dependencies: + debug: 3.2.7 + ftp-response-parser: 1.0.1 + once: 1.4.0 + parse-listing: 1.1.3 + stream-combiner: 0.2.2 + unorm: 1.6.0 + transitivePeerDependencies: + - supports-color + json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} @@ -23992,14 +25488,24 @@ snapshots: kind-of@6.0.3: {} + klaw@4.1.0: {} + kleur@3.0.3: {} + kleur@4.1.5: {} + + kuler@2.0.0: {} + kysely@0.28.15: {} layout-base@1.0.2: {} layout-base@2.0.1: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + leac@0.6.0: {} level-codec@9.0.2: @@ -24173,6 +25679,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + lilconfig@3.1.3: {} + linebreak@1.1.0: dependencies: base64-js: 0.0.8 @@ -24186,16 +25694,27 @@ snapshots: dependencies: p-locate: 5.0.0 + lockfile@1.0.4: + dependencies: + signal-exit: 3.0.7 + lodash-es@4.18.1: {} lodash.debounce@4.0.8: {} lodash.isequal@4.5.0: {} + lodash.isfinite@3.3.2: {} + lodash.merge@4.6.2: {} lodash@4.18.1: {} + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + log-symbols@6.0.0: dependencies: chalk: 5.6.2 @@ -24206,6 +25725,15 @@ snapshots: is-unicode-supported: 2.1.0 yoctocolors: 2.1.2 + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -24216,6 +25744,8 @@ snapshots: lru-cache@11.2.7: {} + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -24435,8 +25965,12 @@ snapshots: media-engine@1.0.3: {} + media-typer@1.1.1: {} + memoize-one@6.0.0: {} + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} mermaid@11.16.0: @@ -24463,6 +25997,17 @@ snapshots: ts-dedent: 2.3.0 uuid: 14.0.1 + method-override@3.0.0: + dependencies: + debug: 3.1.0 + methods: 1.1.2 + parseurl: 1.3.3 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + methods@1.1.2: {} + mhchemparser@4.2.1: {} micromark-core-commonmark@2.0.3: @@ -24781,6 +26326,16 @@ snapshots: module-details-from-path@1.0.4: {} + morgan@1.11.0: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.4.1 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + motion-dom@12.38.0: dependencies: motion-utils: 12.36.0 @@ -24914,6 +26469,14 @@ snapshots: node-addon-api@7.1.1: optional: true + node-addon-api@8.9.2: + optional: true + + node-devicectl@2.1.0: + dependencies: + '@appium/logger': 2.0.11 + teen_process: 4.2.1 + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -24930,10 +26493,28 @@ snapshots: node-gyp-build@4.1.1: optional: true + node-gyp-build@4.8.4: + optional: true + node-releases@2.0.37: {} + node-simctl@9.1.1: + dependencies: + '@appium/logger': 2.0.11 + asyncbox: 6.4.2 + rimraf: 6.1.3 + semver: 7.8.5 + teen_process: 4.2.1 + which: 6.0.1 + nodemailer@7.0.13: {} + normalize-package-data@8.0.0: + dependencies: + hosted-git-info: 9.0.3 + semver: 7.8.5 + validate-npm-package-license: 3.0.4 + normalize-path@3.0.0: {} normalize-range@0.1.2: {} @@ -24998,6 +26579,9 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obuf@1.1.2: + optional: true + obug@2.1.1: {} ofetch@1.5.1: @@ -25008,12 +26592,20 @@ snapshots: ohash@2.0.11: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + on-headers@1.1.0: {} once@1.4.0: dependencies: wrappy: 1.0.2 + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -25055,6 +26647,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + ora@8.2.0: dependencies: chalk: 5.6.2 @@ -25189,6 +26793,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-listing@1.1.3: {} + parse-svg-path@0.1.2: {} parse5@7.3.0: @@ -25204,6 +26810,8 @@ snapshots: leac: 0.6.0 peberminta: 0.9.0 + parseurl@1.3.3: {} + path-browserify@1.0.1: {} path-data-parser@0.1.0: {} @@ -25242,6 +26850,8 @@ snapshots: peberminta@0.9.0: {} + pend@1.2.0: {} + perfect-debounce@2.1.0: {} pg-cloudflare@1.3.0: @@ -25291,12 +26901,21 @@ snapshots: playwright-core@1.60.0: {} + playwright-core@1.62.1: {} + playwright@1.60.0: dependencies: playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 + plist@4.0.0: + dependencies: + '@xmldom/xmldom': 0.9.10 + xmlbuilder: 15.1.1 + + pluralize@8.0.0: {} + png-js@2.0.0: dependencies: fflate: 0.8.3 @@ -25310,6 +26929,11 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 + portscanner@2.2.0: + dependencies: + async: 2.6.4 + is-number-like: 1.0.8 + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.1: @@ -25374,6 +26998,8 @@ snapshots: process-nextick-args@2.0.1: {} + process@0.11.10: {} + progress@2.0.3: {} prompts@2.4.2: @@ -25472,6 +27098,11 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} proxy-from-env@2.1.0: {} @@ -25486,6 +27117,11 @@ snapshots: punycode@2.3.1: {} + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quansync@1.0.0: {} queue@6.0.2: @@ -25557,6 +27193,15 @@ snapshots: range-parser@1.2.0: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + rc9@3.0.1: dependencies: defu: 6.1.6 @@ -25716,6 +27361,13 @@ snapshots: react@19.2.5: {} + readable-stream@1.1.14: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -25732,6 +27384,18 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@3.0.0: + dependencies: + minimatch: 10.2.5 + readdirp@4.1.2: {} readdirp@5.0.0: {} @@ -25923,6 +27587,11 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -25938,6 +27607,11 @@ snapshots: dependencies: glob: 10.5.0 + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + robust-predicates@3.0.3: {} rolldown@1.0.0-rc.15: @@ -26007,6 +27681,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-applescript@7.1.0: {} rw@1.3.3: {} @@ -26038,8 +27722,14 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + sax@1.6.0: {} saxes@6.0.0: @@ -26072,12 +27762,49 @@ snapshots: dependencies: parseley: 0.12.1 + select-hose@2.0.0: + optional: true + + selenium-webdriver@4.48.0: + dependencies: + '@bazel/runfiles': 6.5.0 + jszip: 3.10.1 + tmp: 0.2.7 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + semver@6.3.1: {} semver@7.7.4: {} semver@7.8.5: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-favicon@2.5.1: + dependencies: + etag: 1.8.1 + fresh: 0.5.2 + ms: 2.1.3 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + serve-handler@6.1.7: dependencies: bytes: 3.0.0 @@ -26088,6 +27815,15 @@ snapshots: path-to-regexp: 3.3.0 range-parser: 1.2.0 + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + serve@14.2.6: dependencies: '@zeit/schemas': 2.36.0 @@ -26130,6 +27866,8 @@ snapshots: setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} + sharp@0.35.3(@types/node@25.6.0): dependencies: '@img/colour': 1.1.0 @@ -26170,6 +27908,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.10.0: {} + shell-quote@1.8.3: {} shiki@4.4.3: @@ -26211,6 +27951,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -26307,6 +28055,43 @@ snapshots: space-separated-tokens@2.0.2: {} + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + spdy-transport@3.0.0: + dependencies: + debug: 4.4.3 + detect-node: 2.1.0 + hpack.js: 2.1.6 + obuf: 1.1.2 + readable-stream: 3.6.2 + wbuf: 1.7.3 + transitivePeerDependencies: + - supports-color + optional: true + + spdy@4.0.2: + dependencies: + debug: 4.4.3 + handle-thing: 2.0.1 + http-deceiver: 1.2.7 + select-hose: 2.0.0 + spdy-transport: 3.0.0 + transitivePeerDependencies: + - supports-color + optional: true + speech-rule-engine@4.1.4: dependencies: '@xmldom/xmldom': 0.9.10 @@ -26317,6 +28102,8 @@ snapshots: sprintf-js@1.0.3: {} + stack-trace@0.0.10: {} + stackback@0.0.2: {} stacktrace-parser@0.1.11: @@ -26341,6 +28128,22 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-buffers@2.2.0: {} + + stream-combiner@0.2.2: + dependencies: + duplexer: 0.1.2 + through: 2.3.8 + + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + strict-event-emitter@0.5.1: {} string-width@4.2.3: @@ -26361,6 +28164,11 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.9 @@ -26384,6 +28192,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@0.10.31: {} + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -26491,6 +28301,28 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tar-stream@3.2.1: + dependencies: + b4a: 1.8.1 + bare-fs: 4.8.1 + fast-fifo: 1.3.2 + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teen_process@4.2.1: + dependencies: + shell-quote: 1.8.3 + + teex@1.0.1: + dependencies: + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + terser-webpack-plugin@5.5.0(esbuild@0.27.5)(webpack@5.105.4(esbuild@0.27.5)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -26508,8 +28340,18 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + text-hex@1.0.0: {} + throttleit@2.1.0: {} + through@2.3.8: {} + tiny-inflate@1.0.3: {} tiny-invariant@1.3.1: {} @@ -26547,6 +28389,10 @@ snapshots: dependencies: tldts-core: 7.0.27 + tmp@0.2.7: {} + + toidentifier@1.0.1: {} + totalist@3.0.1: {} tough-cookie@6.0.1: @@ -26563,8 +28409,14 @@ snapshots: trim-lines@3.0.1: {} + triple-beam@1.4.1: {} + trough@2.2.0: {} + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + ts-dedent@2.3.0: {} ts-morph@27.0.2: @@ -26624,6 +28476,16 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -26764,6 +28626,10 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unorm@1.6.0: {} + + unpipe@1.0.0: {} + unplugin-utils@0.3.1: dependencies: pathe: 2.0.3 @@ -26805,16 +28671,25 @@ snapshots: dependencies: react: 19.2.5 + utf8-byte-length@1.0.5: {} + util-deprecate@1.0.2: {} uuid@14.0.1: {} + uuid@14.0.2: {} + uuid@9.0.1: {} valibot@1.3.1(typescript@7.0.2): optionalDependencies: typescript: 7.0.2 + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + vary@1.1.2: {} vfile-message@4.0.3: @@ -27028,6 +28903,15 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + wbuf@1.7.3: + dependencies: + minimalistic-assert: 1.0.1 + optional: true + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + web-streams-polyfill@4.2.0: {} webidl-conversions@3.0.1: {} @@ -27130,6 +29014,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@6.0.1: + dependencies: + isexe: 4.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -27143,6 +29031,26 @@ snapshots: wildcard@1.1.2: {} + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: @@ -27163,6 +29071,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@6.2.4: @@ -27174,6 +29088,8 @@ snapshots: ws@8.20.0: {} + ws@8.21.3: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 @@ -27197,6 +29113,8 @@ snapshots: xml@1.0.1: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} xtend@4.0.2: {} @@ -27258,6 +29176,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -27268,6 +29188,19 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.1.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.2.2 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + yjs@13.6.30: dependencies: lib0: 1.0.0-rc.22 @@ -27314,6 +29247,12 @@ snapshots: '@yuku-parser/binding-win32-arm64': 0.5.48 '@yuku-parser/binding-win32-x64': 0.5.48 + zip-stream@7.0.5: + dependencies: + compress-commons: 7.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + zod@4.3.6: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c48f3d7dbe..7d9a2cc788 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,6 +63,12 @@ allowBuilds: esbuild: true msw: true unrs-resolver: true + # Appium (device suite, iOS backend) works with its postinstall skipped — + # verified by running the server and XCUITest sessions from an install that + # ignored these. tuntap is a native module for real-device tunneling; the + # suite only drives simulators. + appium: false + appium-ios-tuntap: false canvas: false sharp: false workerd: false diff --git a/tests/device/.gitignore b/tests/device/.gitignore new file mode 100644 index 0000000000..80d3f2a3b8 --- /dev/null +++ b/tests/device/.gitignore @@ -0,0 +1,2 @@ +.cache/ +.artifacts/ diff --git a/tests/device/README.md b/tests/device/README.md new file mode 100644 index 0000000000..116d4235bb --- /dev/null +++ b/tests/device/README.md @@ -0,0 +1,153 @@ +# Device tests + +End-to-end tests against **real mobile OSes and browsers** — the behavior no +browser emulation reaches: the on-screen keyboard opening and resizing the +viewport, the IME's key handling (soft Enter is delivered as keyCode 229 + +`beforeinput` or a follow-up keydown, depending on the keyboard build — the +[#3001](https://github.com/TypeCellOS/BlockNote/issues/3001) bug class), +Safari/Chrome-on-device focus semantics, and the IME's own action key. + +Tests are written once against a session interface (`lib/session.ts`) and run +on whatever **targets** the machine can drive (`devices.ts` probes +availability): + +| Target | What it is | Unique reach | +| --------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `local-android` | Android emulator: real Chrome + real Gboard, via Playwright's `_android` (page) + `adb shell` (OS input) | The only automated channel to the **on-screen keyboard itself** — the suite presses the IME's real action/Enter key | +| `local-ios` | iOS simulator: the actual iOS build + actual Safari, via Appium/XCUITest (WebDriverAgent) | Real iOS Safari without hardware; headless-capable (XCUITest owns the HID stack) | + +Both targets are free and credential-less, so the suite runs as normal per-PR +CI (the `emulator-tests` workflow). What they cannot cover is OEM keyboards +(e.g. Samsung Keyboard) — that stays on the manual release checklist below. A +BrowserStack real-hardware backend existed behind the same session interface +(PR #3034 has it) and can be revived if hardware-only coverage becomes worth +paying for again. + +## How this relates to the e2e mobile tests + +`tests/src/end-to-end/mobile/` (the Playwright-emulated android instance) +tests **editor behavior under mobile conditions** — form semantics, toolbar +logic, CDP-emulated IME composition — in seconds, and is where the bulk of +mobile coverage belongs. This suite tests **the OS integration itself**: the +things that layer must fake — the real keyboard appearing and resizing the +viewport, real IME key delivery, the IME action key, real Safari focus and +chrome behavior. + +Decision rules: + +- A new mobile test **defaults to `end-to-end/mobile/`**. It goes here only + when the behavior depends on something emulation fakes (a keyboard, an + IME, OS focus rules). +- Where a test here can have an emulated counterpart, it should (the device + link flow pairs with `linkSubmit.test.tsx`): the fast layer catches + regressions, this layer proves the fake matches reality. +- Tests here assert that _flows work through real input_ — never + editor-logic details, which stay in the layers below. Keep this suite + thin; it costs minutes per target. + +## Running + +```bash +# 1. Serve the playground (any of the dev servers works): +pnpm run dev + +# 2. Boot what you want to test against (any subset): +# - Android: any emulator (an API 35 AVD with Google APIs recommended) +# - iOS: nothing to do — the setup boots a simulator itself +# (requires an even-numbered Node for Appium; .node-version qualifies) + +# 3. Run the suite — it runs every reachable target, or narrow it: +pnpm run test:device +DEVICE_FILTER=local-android pnpm run test:device +``` + +Environment knobs (plain environment variables): + +| Variable | Purpose | +| ------------------------------- | ----------------------------------------------------------------------------------- | +| `DEVICE_TEST_TARGET` | App server origin, default `http://127.0.0.1:5173`. | +| `DEVICE_FILTER` | Substring of a target id from `devices.ts`, e.g. `DEVICE_FILTER=ios`. | +| `SOFT_ENTER_X` / `SOFT_ENTER_Y` | Absolute screen coordinates for the keyboard's Enter key, when tuning a new device. | + +Targets whose toolchain isn't present (no adb device, not on macOS) simply +don't run, so `test:device` is safe to invoke anywhere. Screenshots land in +`.artifacts/`. + +## What only this layer can test + +Android's IME decides for itself which action its Enter key performs. Being +inside a real `
` is what makes it offer a submitting action rather than +"Next" — which advances focus and dispatches no key event at all, so a popover +listening for Enter never hears anything. That was the original create-link +bug, and it is why `Form.Root` renders a `` with a submit button. + +No protocol-level channel can press that key — a W3C Enter is always a real +Enter key event, never the IME's own choice. The Android emulator target can: +`adb shell input tap` presses the on-screen action key itself. The +IME-action-key test in `formattingToolbar.device.test.ts` is exactly that flow +as a regression test (the action key submits the link popover; focus stays in +the editor), so the former manual checklist item is now CI. Likewise the +soft-Enter test in `editing.device.test.ts` presses the on-screen Enter in the +editor and asserts the true IME delivery route (keydown 229) was taken. + +What remains manual, before a release, on a physical phone (ideally one with +an OEM keyboard, e.g. Samsung Keyboard): + +- Create a link from an editor that is **not** the last one on the page. The + keyboard's action key must submit it, rather than jumping focus to the next + editor. (`end-to-end/form/` and `end-to-end/mobile/linkSubmit.test.tsx` cover + the half of this that is testable — that submission works with no key event + at all.) + +## Architecture + +``` +devices.ts target matrix + availability (add targets here) +lib/session.ts the session interface every backend implements +lib/localAndroid.ts Android emulator (playwright _android + adb shell input) +lib/localIos.ts iOS simulator (selenium-webdriver -> local Appium/XCUITest) +lib/tunnel.ts global setup: app server, simulator+Appium +lib/gestures.ts platform input layer — ALL fidelity quirks live here +lib/editorPage.ts BlockNote page helpers (blocks, toolbar, popovers) +*.device.test.ts suites (one session per target per file) +``` + +The layering rule: **tests speak in editor concepts, `editorPage` speaks in +gestures, and only `gestures` and the backends know platform quirks.** When a +target misbehaves, the fix belongs in `gestures.ts` (offsets, ladders) or the +backend, not in tests. + +### Platform facts encoded in the gesture layer + +- **iOS Safari ignores synthetic input for focus/keyboard purposes** — element + clicks and even trusted injected W3C touch events never open the keyboard. + Only the Appium native tap (`mobile: tap`, screen points) does. This holds + for every stack tried: safaridriver (whose sessions additionally trip + Safari's "stop the current automated test session?" guardrail when real HID + is injected alongside, e.g. via idb) and Appium's web-context clicks + (`nativeWebTap` included). Hence iOS runs Appium/XCUITest with tap ladders. +- **iOS screen points = CSS position + Safari top chrome**: ~100pt with the + keyboard closed, ~45–50pt with it open. Do _not_ subtract + `visualViewport.offsetTop` from `getBoundingClientRect()` values. +- A mis-aimed iOS tap near the keyboard hits the accessory bar ("Done" + dismisses the keyboard and collapses the editing session), hence the + offset ladders with verify-and-recover. +- **Android** is well-behaved: element taps and typing go through + `adb shell input` — genuine OS events, including on the on-screen keyboard + itself. Page coordinates are converted with a one-time calibration tap + (`lib/localAndroid.ts`), so browser-chrome offsets never have to be guessed. +- Programmatic DOM selections intermittently collapse on iOS; helpers + re-apply the range on every poll. + +## Adding coverage + +- **A new target**: add an entry to `DEVICE_TARGETS` in `devices.ts` with a + backend implementing `lib/session.ts`. If the soft-Enter test can't find + the key, tune `RETURN_KEY_RATIOS` in `gestures.ts` (or pin `SOFT_ENTER_X/Y` + while measuring from a screenshot). +- **A new flow**: add helpers to `editorPage.ts` and a `*.device.test.ts` + file. Keep one session per target per file, created in `beforeAll` — + sessions are the expensive resource. +- **A reported device bug**: reproduce it as a failing test first; the + soft-Enter test in `editing.device.test.ts` shows the pattern, including + classifying the observed misbehavior so the failure message names the bug. diff --git a/tests/device/devices.ts b/tests/device/devices.ts new file mode 100644 index 0000000000..6a43dd5f3b --- /dev/null +++ b/tests/device/devices.ts @@ -0,0 +1,56 @@ +import { + LocalAndroidSession, + localAndroidAvailable, +} from "./lib/localAndroid.js"; +import { LocalIosSession, localIosAvailable } from "./lib/localIos.js"; +import type { DeviceSession, Platform, TargetKind } from "./lib/session.js"; + +export type DeviceTarget = { + /** Stable id, used in test names and `DEVICE_FILTER` matching. */ + id: string; + platform: Platform; + kind: TargetKind; + /** Whether this machine/environment can drive the target right now. */ + available: () => Promise; + createSession: () => Promise; +}; + +/** + * All targets. Both are the per-PR layer — free, deterministic, and with + * input channels no cloud service has (the on-screen keyboard itself). + * Real-hardware coverage (OEM keyboards like Samsung Keyboard) is a manual + * release-checklist item; a BrowserStack backend existed behind the same + * session interface (PR #3034 has it) and can be revived if it ever earns + * its keep again. + */ +export const DEVICE_TARGETS: DeviceTarget[] = [ + { + id: "local-android-emulator", + platform: "android", + kind: "local-android", + available: localAndroidAvailable, + createSession: () => LocalAndroidSession.create(), + }, + { + id: "local-ios-simulator", + platform: "ios", + kind: "local-ios", + available: localIosAvailable, + createSession: () => LocalIosSession.create(), + }, +]; + +/** + * Targets selected for this run: reachable ones, narrowed by + * `DEVICE_FILTER=`. Unreachable targets are skipped so the + * suite runs whatever a machine can drive — CI's Android job sees only the + * emulator, the macOS job only the simulator, a laptop with both both. + */ +export async function activeDevices(): Promise { + const filter = process.env.DEVICE_FILTER; + const candidates = filter + ? DEVICE_TARGETS.filter((d) => d.id.includes(filter)) + : DEVICE_TARGETS; + const flags = await Promise.all(candidates.map((d) => d.available())); + return candidates.filter((_, i) => flags[i]); +} diff --git a/tests/device/editing.device.test.ts b/tests/device/editing.device.test.ts new file mode 100644 index 0000000000..c2d315c23b --- /dev/null +++ b/tests/device/editing.device.test.ts @@ -0,0 +1,115 @@ +import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test"; + +import { activeDevices } from "./devices.js"; +import { pressSoftKeyboardEnter, typeText } from "./lib/gestures.js"; +import { + docState, + EDITOR, + openExample, + startEditing, +} from "./lib/editorPage.js"; +import type { DeviceSession } from "./lib/session.js"; + +/** + * Basic text-editing behavior on real devices. These flows go through the + * actual IME wherever it matters: soft-keyboard Enter on Android is delivered + * as keyCode 229 + `beforeinput`, a path that synthetic key events cannot + * exercise and that has broken in the wild (TypeCellOS/BlockNote#3001 — Enter + * inserting a space or doing nothing instead of creating a block). + */ +for (const device of await activeDevices()) { + describe(`basic editing on ${device.id}`, () => { + let session: DeviceSession; + + beforeAll(async () => { + session = await device.createSession(); + await openExample(session, "/ui-components/mobile-formatting-toolbar"); + }); + + afterAll(async () => { + if (session) { + await session.screenshot(`editing-final`); + await session.close(); + } + }); + + test("typing lands in the document", async () => { + await startEditing(session); + const before = await docState(session); + + await typeText(session, EDITOR, "bndevicetyping"); + + const after = await session.waitFor<{ ok: boolean; text: string }>( + "typed text present", + `const editor = document.querySelector(${JSON.stringify(EDITOR)}); + return { ok: editor.textContent.includes("bndevicetyping"), text: editor.textContent.slice(0, 120) };`, + ); + expect(after.ok).toBe(true); + // Typing must not have destroyed surrounding content. + expect((await docState(session)).blockCount).toBeGreaterThanOrEqual( + before.blockCount, + ); + }); + + test("soft-keyboard Enter creates a new block (#3001)", async () => { + await startEditing(session); + const before = await docState(session); + + // Record how the Enter reaches the page, to prove the route as well as + // the effect: on Android the on-screen key must arrive as the IME + // sequence (keydown 229 + beforeinput insertParagraph), which is the + // exact path #3001 broke and no key event can produce. + await session.exec( + `window.__route = []; + const editor = document.querySelector(${JSON.stringify(EDITOR)}); + editor.addEventListener("keydown", (e) => window.__route.push("keydown:" + e.keyCode), { capture: true }); + editor.addEventListener("beforeinput", (e) => window.__route.push("beforeinput:" + e.inputType), { capture: true });`, + ); + + // "Any observable document mutation" stops the key-position ladder; + // what the mutation *was* is classified below. + await pressSoftKeyboardEnter( + session, + `const editor = document.querySelector(${JSON.stringify(EDITOR)}); + const blocks = editor.querySelectorAll('[data-node-type="blockContainer"]').length; + return { ok: blocks !== ${before.blockCount} || editor.textContent !== ${JSON.stringify(before.text)} };`, + ); + + const after = await docState(session); + await session.screenshot("after-soft-enter"); + + // Classify the IME's effect so a failure names the bug it found: + // - block count +1 -> correct + // - text grew by a space -> the #3001 signature + // - text shrank -> the ladder hit backspace; key ratios need + // tuning for this device (see gestures.ts) + const gainedSpace = + after.blockCount === before.blockCount && + after.text.length === before.text.length + 1 && + after.text.includes(" "); + expect( + after.blockCount, + gainedSpace + ? "soft Enter inserted a space instead of a new block (TypeCellOS/BlockNote#3001)" + : `soft Enter did not create a block (text before: ${JSON.stringify(before.text.slice(0, 60))}, after: ${JSON.stringify(after.text.slice(0, 60))})`, + ).toBe(before.blockCount + 1); + + if (device.kind === "local-android") { + // The backend taps the IME's actual on-screen key, so the page must + // have seen an IME-mediated delivery — a keydown 229 — and not just + // a synthesized key event (which would arrive as a bare keydown 13, + // exactly what `adb input keyevent` produces). Which variant follows + // the 229 differs by keyboard build: phone Gboard emits + // `beforeinput: insertParagraph` (the route the beforeinput + // interception handles), this emulator's AOSP LatinIME emits a real + // keydown 13 (the route the keypress interception handles). Both are + // genuine IME routes; both must create the block. + const route = await session.exec(`return window.__route;`); + expect( + route.some((entry) => entry === "keydown:229"), + `expected an IME-mediated delivery (keydown 229), saw: ${route.join(", ")}`, + ).toBe(true); + } + }); + }); +} diff --git a/tests/device/formattingToolbar.device.test.ts b/tests/device/formattingToolbar.device.test.ts new file mode 100644 index 0000000000..361a614b6a --- /dev/null +++ b/tests/device/formattingToolbar.device.test.ts @@ -0,0 +1,185 @@ +import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test"; + +import { activeDevices } from "./devices.js"; +import { tapElement } from "./lib/gestures.js"; +import { + docState, + MOBILE_TOOLBAR, + openExample, + startEditing, + viewportHeight, +} from "./lib/editorPage.js"; +import { + LINK_POPOVER, + openLinkPopover, + selectFirstWord, + typeAndSubmit, +} from "./linkPopover.js"; +import type { DeviceSession } from "./lib/session.js"; + +const KEYBOARD_MIN_HEIGHT = 150; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +for (const device of await activeDevices()) { + describe(`mobile formatting toolbar on ${device.id}`, () => { + let session: DeviceSession; + let baselineHeight: number; + + beforeAll(async () => { + session = await device.createSession(); + await openExample(session, "/ui-components/mobile-formatting-toolbar"); + baselineHeight = await viewportHeight(session); + }); + + afterAll(async () => { + if (session) { + await session.screenshot(`formatting-toolbar-final`); + await session.close(); + } + }); + + test("tapping the editor opens the keyboard and shows the mobile toolbar", async () => { + await startEditing(session); + + // The toolbar only renders while `useVirtualKeyboard` sees the + // keyboard, so its presence + the viewport drop prove the real + // on-screen keyboard opened. + expect(await viewportHeight(session)).toBeLessThan( + baselineHeight - KEYBOARD_MIN_HEIGHT, + ); + }); + + test("toolbar buttons apply reliably", async () => { + await startEditing(session); + await selectFirstWord(session); + // Three bold toggles; every tap must register (covers the reported + // "buttons sometimes don't work", which traced back to a lingering + // popover overlaying the toolbar). + for (const expected of [true, false, true]) { + await tapElement(session, `${MOBILE_TOOLBAR} [data-test="bold"]`, { + keyboard: "open", + verify: `return { ok: ${expected} === !!document.querySelector('.bn-editor strong') };`, + }); + } + }); + + test("link popover holds focus through the IME and creates a link", async () => { + // Captured before the popover opens: iOS Safari auto-zooms the page + // when an input with a computed font-size under 16px takes focus, and + // that zoom perturbs the visual viewport the mobile toolbar positions + // itself from. The `pointer: coarse` rule in blocknoteStyles.css + // prevents it; this pins the behaviour rather than the rule. + const scaleBefore = await session.exec( + `return window.visualViewport ? window.visualViewport.scale : 1;`, + ); + + await openLinkPopover(session); + + // Focusing an input makes the IME reconfigure (on Android this + // resizes the viewport), which historically hid the popover and + // collapsed the keyboard/toolbar (the Mantine `hideDetached` bug). + // The input must still hold focus once that settles. + await sleep(2_500); + const survival = await session.exec<{ + focused: boolean; + popover: boolean; + toolbar: boolean; + }>(` + const active = document.activeElement; + return { + focused: !!(active && active.tagName === 'INPUT' && active.getAttribute('name') === 'url'), + popover: !!document.querySelector(${JSON.stringify(LINK_POPOVER)}), + toolbar: !!document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)}), + };`); + await session.screenshot("link-popover-open"); + + // Focusing the URL input must not have zoomed the page. + const scaleAfter = await session.exec( + `return window.visualViewport ? window.visualViewport.scale : 1;`, + ); + expect( + scaleAfter, + `focusing the URL input zoomed the page (${scaleBefore} -> ${scaleAfter}); ` + + `check the pointer:coarse font-size rule for .bn-form-popover inputs`, + ).toBeLessThanOrEqual(scaleBefore + 0.01); + + expect(survival).toEqual({ + focused: true, + popover: true, + toolbar: true, + }); + + await typeAndSubmit( + session, + `${LINK_POPOVER} input`, + "example.com", + `return { + ok: !!document.querySelector('.bn-editor a[href="https://example.com"]') + && !document.querySelector(${JSON.stringify(LINK_POPOVER)}), + link: !!document.querySelector('.bn-editor a[href="https://example.com"]'), + popoverGone: !document.querySelector(${JSON.stringify(LINK_POPOVER)}), + };`, + ); + + expect((await docState(session)).links).toContain("https://example.com"); + // Submitting must not dismiss the keyboard — but Appium's typing can + // itself hide the keyboard as an automation side effect (observed on + // Android), which the product can't distinguish from the user closing + // it. So only assert the toolbar survived while the keyboard is + // actually still up; the emulation suite covers this invariant + // deterministically. + if ( + (await viewportHeight(session)) < + baselineHeight - KEYBOARD_MIN_HEIGHT + ) { + expect( + await session.exec( + `return !!document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)});`, + ), + ).toBe(true); + } + }); + + // The flow that used to be a manual release-checklist item: Android's + // IME decides what its action key does — with a lone text field outside + // a it picks "Next" (advance focus, no key event at all), the + // original create-link bug. Only a backend that can press the on-screen + // keyboard can test the IME's actual choice. + test.skipIf(device.kind !== "local-android")( + "the IME action key submits the link popover", + async () => { + // Fresh document — the earlier tests linked the first word, and a + // linked selection opens the *edit* popover (pre-filled URL) instead + // of the create popover this flow is about. + await openExample(session, "/ui-components/mobile-formatting-toolbar"); + await startEditing(session); + await openLinkPopover(session); + + await session.elementValue(`${LINK_POPOVER} input`, "example.com"); + + if (!session.pressImeActionKey) { + throw new Error("this target must expose the IME action key"); + } + await session.pressImeActionKey( + `return { + ok: !!document.querySelector('.bn-editor a[href="https://example.com"]') + && !document.querySelector(${JSON.stringify(LINK_POPOVER)}), + link: !!document.querySelector('.bn-editor a[href="https://example.com"]'), + popoverGone: !document.querySelector(${JSON.stringify(LINK_POPOVER)}), + };`, + ); + + // The action must not have advanced focus out of the editor — that + // was the original bug's symptom (focus jumping to the next editor). + const state = await session.exec<{ inFirstEditor: boolean }>( + `const editors = [...document.querySelectorAll(".bn-editor")]; + return { inFirstEditor: editors[0].contains(document.activeElement) };`, + ); + expect(state.inFirstEditor).toBe(true); + }, + ); + }); +} diff --git a/tests/device/lib/artifacts.ts b/tests/device/lib/artifacts.ts new file mode 100644 index 0000000000..afbae04f79 --- /dev/null +++ b/tests/device/lib/artifacts.ts @@ -0,0 +1,15 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const ARTIFACTS_DIR = join(import.meta.dirname, "..", ".artifacts"); + +/** Writes a base64 (or binary) PNG under tests/device/.artifacts. */ +export function saveScreenshot(name: string, png: string | Buffer): string { + mkdirSync(ARTIFACTS_DIR, { recursive: true }); + const file = join(ARTIFACTS_DIR, `${name}.png`); + writeFileSync( + file, + typeof png === "string" ? Buffer.from(png, "base64") : png, + ); + return file; +} diff --git a/tests/device/lib/editorPage.ts b/tests/device/lib/editorPage.ts new file mode 100644 index 0000000000..04cdd944ed --- /dev/null +++ b/tests/device/lib/editorPage.ts @@ -0,0 +1,93 @@ +/** + * BlockNote page helpers for device tests: everything here speaks in editor + * concepts (blocks, toolbar, popovers) and hides the gesture mechanics. + * + * The pages under test are the playground examples, served by the host-side + * app server (see lib/tunnel.ts). + */ +import { tapElement } from "./gestures.js"; +import type { DeviceSession } from "./session.js"; + +/** + * Where the *device* loads the app from: the same port the host-side target + * serves on. The emulator reaches it via `adb reverse`, the simulator via the + * shared host network — both as plain `127.0.0.1`. + */ +function deviceOrigin(): string { + const target = process.env.DEVICE_TEST_TARGET ?? "http://127.0.0.1:5173"; + const port = new URL(target).port || "80"; + return `http://127.0.0.1:${port}`; +} + +export const EDITOR = ".bn-editor"; +export const PARAGRAPH = ".bn-editor .bn-inline-content"; +export const MOBILE_TOOLBAR = ".bn-mobile-formatting-toolbar"; +export const BLOCK = '.bn-editor [data-node-type="blockContainer"]'; + +export async function openExample( + session: DeviceSession, + route: string, +): Promise { + // Cold dev-server transforms can stall a first load; one reload recovers + // it. + for (let attempt = 0; attempt < 2; attempt++) { + await session.navigate(`${deviceOrigin()}${route}`); + try { + await session.waitFor( + "editor rendered", + `return { ok: !!document.querySelector(${JSON.stringify(PARAGRAPH)}) };`, + 60_000, + ); + return; + } catch (error) { + if (attempt === 1) { + throw error; + } + } + } +} + +export type DocState = { + blockCount: number; + text: string; + links: string[]; +}; + +/** Snapshot of the first editor's document, for before/after assertions. */ +export async function docState(session: DeviceSession): Promise { + return await session.exec(` + const editor = document.querySelector(${JSON.stringify(EDITOR)}); + return { + blockCount: editor.querySelectorAll('[data-node-type="blockContainer"]').length, + text: editor.textContent, + links: [...editor.querySelectorAll('a[href]')].map((a) => a.getAttribute('href')), + };`); +} + +/** Viewport height; a drop of >150 CSS px from baseline = keyboard open. */ +export async function viewportHeight(session: DeviceSession): Promise { + return await session.exec( + `return Math.round(visualViewport.height);`, + ); +} + +/** + * Taps into the editor so the on-screen keyboard opens and the mobile toolbar + * appears. Safe to call when already editing. + */ +export async function startEditing(session: DeviceSession): Promise { + const already = await session.exec( + `return !!document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)});`, + ); + if (already) { + return; + } + await session.exec( + `document.querySelector(${JSON.stringify(PARAGRAPH)}).scrollIntoView({ block: 'center' });`, + ); + await tapElement(session, PARAGRAPH, { + keyboard: "closed", + verify: `return { ok: !!document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)}) };`, + verifyTimeoutMs: 15_000, + }); +} diff --git a/tests/device/lib/gestures.ts b/tests/device/lib/gestures.ts new file mode 100644 index 0000000000..594c386f73 --- /dev/null +++ b/tests/device/lib/gestures.ts @@ -0,0 +1,172 @@ +/** + * Platform input layer: every quirk of delivering *genuine* user input on real + * devices lives here, so tests and page helpers stay declarative. + * + * The hard-won iOS facts this module encodes: + * - Safari ignores WebDriver element clicks (synthetic events) and even + * trusted injected W3C touch events for focus/keyboard purposes. Only the + * Appium native-layer tap works. + * - Native taps take screen points = CSS position plus Safari's top chrome, + * which is ~100pt with the keyboard closed (URL bar visible) and ~45-50pt + * with it open (chrome minimized). `getBoundingClientRect()` values are + * already visually correct — do NOT subtract `visualViewport.offsetTop`. + * - A tap that lands ~50pt below a target near the keyboard hits the keyboard + * accessory bar (its "Done" button dismisses the keyboard and collapses the + * whole editing state), so mis-taps must be assumed and recovered from. + */ +import type { DeviceSession } from "./session.js"; + +/** Candidate Safari top-chrome offsets (screen pt), most likely first. */ +const IOS_CHROME_OFFSETS = { + keyboardClosed: [100, 90, 110, 80], + keyboardOpen: [50, 45, 55, 100], +} as const; + +export type KeyboardState = "open" | "closed"; + +/** + * Taps an element. Android uses a plain element click (reliable there); iOS + * walks the chrome-offset ladder with a native tap per candidate, using + * `verify` (a page script returning `{ ok: boolean }`) to detect a hit. + * On iOS a `verify` script is required — without one a mis-aimed tap cannot + * be detected. + */ +export async function tapElement( + session: DeviceSession, + css: string, + options: { + keyboard: KeyboardState; + verify: string; + verifyTimeoutMs?: number; + }, +): Promise { + // Android taps reliably through elementClick (a genuine OS tap in the + // local backend). iOS needs the native-tap chrome-offset ladder: + // web-layer clicks are synthetic there and never move focus or open the + // keyboard. + if (session.platform !== "ios") { + await session.elementClick(css); + await session.waitFor( + `tap on ${css}`, + options.verify, + options.verifyTimeoutMs ?? 10_000, + ); + return; + } + + const offsets = + IOS_CHROME_OFFSETS[ + options.keyboard === "open" ? "keyboardOpen" : "keyboardClosed" + ]; + for (const offset of offsets) { + const point = await session.exec<{ x: number; y: number }>( + `const b = document.querySelector(arguments[0]).getBoundingClientRect(); + return { x: b.x + Math.min(40, b.width / 2), y: b.y + b.height / 2 };`, + [css], + ); + await session.nativeTap(point.x, point.y + offset); + try { + await session.waitFor( + `tap on ${css} (chrome offset ${offset})`, + options.verify, + options.verifyTimeoutMs ?? 6_000, + ); + return; + } catch { + // Mis-aimed; the caller's flow may need to recover editing state, which + // `verify` scripts typically encode. Try the next offset. + } + } + throw new Error(`No chrome offset produced a verified tap on ${css}`); +} + +/** + * Position of the iOS keyboard's return key, as fractions of the full screen + * (measured on iPhone 16e; return stays bottom-right across iPhones). Android + * doesn't need coordinates — its backend locates the key itself + * (`pressImeActionKey`). Override per-run with SOFT_ENTER_X / SOFT_ENTER_Y + * when adding an exotic device. + */ +const RETURN_KEY_RATIOS = { + ios: [ + { x: 0.88, y: 0.88 }, + { x: 0.9, y: 0.91 }, + { x: 0.88, y: 0.85 }, + ], +}; + +/** + * Presses Enter/return on the *on-screen keyboard* with a native tap. + * + * This is deliberately not a WebDriver key event: soft-keyboard Enter goes + * through the IME (keyCode 229 + `beforeinput` on Android), which is exactly + * the path that breaks in bugs like TypeCellOS/BlockNote#3001 while synthetic + * key events keep working. `verify` receives the page state after each tap + * attempt; return `{ ok: true }` once the expected mutation is observed. + * + * The keyboard must be open when calling this. + */ +export async function pressSoftKeyboardEnter( + session: DeviceSession, + verify: string, +): Promise { + if (session.platform === "android") { + if (!session.pressImeActionKey) { + throw new Error( + `pressSoftKeyboardEnter: the ${session.kind} backend cannot reach the on-screen keyboard`, + ); + } + // The real thing: tap the IME's on-screen Enter key. In the editor's + // contenteditable this is the true IME delivery (keydown 229 followed by + // either `beforeinput: insertParagraph` or a real keydown, depending on + // the keyboard build) — exactly where #3001-class bugs live. No + // key-event channel can produce that sequence. + await session.pressImeActionKey(verify); + return; + } + const override = + process.env.SOFT_ENTER_X && process.env.SOFT_ENTER_Y + ? [ + { + x: Number(process.env.SOFT_ENTER_X), + y: Number(process.env.SOFT_ENTER_Y), + }, + ] + : undefined; + const candidates = override ?? RETURN_KEY_RATIOS.ios; + + // iOS native taps take screen points (CSS px scale). + const metrics = await session.exec<{ width: number; height: number }>( + `return { width: screen.width, height: screen.height };`, + ); + + let lastError: Error | undefined; + for (const ratio of candidates) { + await session.nativeTap(metrics.width * ratio.x, metrics.height * ratio.y); + try { + await session.waitFor("soft Enter effect", verify, 5_000); + return; + } catch (error) { + lastError = error as Error; + } + } + throw new Error( + `Soft Enter was not observed to take effect: ${lastError?.message}`, + ); +} + +/** + * Types plain text into the editor's contenteditable. Android's value endpoint + * handles contenteditables; iOS Safari's does not, but protocol key events do. + */ +export async function typeText( + session: DeviceSession, + editorCss: string, + text: string, +): Promise { + if (session.platform === "android") { + await session.elementValue(editorCss, text); + } else { + await session.typeKeys(text); + } +} diff --git a/tests/device/lib/localAndroid.ts b/tests/device/lib/localAndroid.ts new file mode 100644 index 0000000000..281913bbd5 --- /dev/null +++ b/tests/device/lib/localAndroid.ts @@ -0,0 +1,301 @@ +/** + * A local Android emulator via Playwright's (experimental, first-party) + * Android support: real Chrome driven as a Playwright page over CDP, plus the + * native input layer (`device.input`, `device.shell`) that reaches outside + * the page — including the on-screen keyboard, which no cloud channel can + * press. That native reach is what makes the IME action key testable here. + * + * Element taps deliberately go through `adb shell input` (OS-level, exactly + * what a finger does) rather than Playwright's CDP-injected touches. Page + * coordinates are converted to screen coordinates using a one-time calibration + * tap, so the browser-chrome offset never has to be guessed. + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { AndroidDevice, BrowserContext, Page } from "playwright-core"; +import { _android } from "playwright-core"; + +import { type DeviceSession, waitForOk } from "./session.js"; +import { saveScreenshot } from "./artifacts.js"; + +const execFileAsync = promisify(execFile); + +/** The dev-server port the emulator reaches via `adb reverse`. */ +function targetPort(): string { + const target = process.env.DEVICE_TEST_TARGET ?? "http://127.0.0.1:5173"; + return new URL(target).port || "80"; +} + +/** True when adb can see a running emulator/device. */ +export async function localAndroidAvailable(): Promise { + try { + const { stdout } = await execFileAsync("adb", ["get-state"], { + timeout: 5_000, + }); + return stdout.trim() === "device"; + } catch { + return false; + } +} + +export class LocalAndroidSession implements DeviceSession { + readonly kind = "local-android"; + readonly platform = "android"; + + private constructor( + private readonly device: AndroidDevice, + private readonly context: BrowserContext, + private readonly page: Page, + public readonly sessionId: string, + private readonly screen: { width: number; height: number }, + ) {} + + /** + * CSS-to-screen mapping, measured lazily on the first OS tap. It cannot be + * measured on Chrome's initial page: pages without a viewport meta render + * in the 980px virtual viewport, so both the scale and the observed touch + * position would describe the wrong coordinate space. By the first tap the + * tests have navigated to an app page (`width=device-width`), where the + * mapping is stable. + */ + private mapping: { + scale: number; + origin: { x: number; y: number }; + } | null = null; + + static async create(): Promise { + const [device] = await _android.devices(); + if (!device) { + throw new Error( + "No Android device visible to adb. Boot an emulator first " + + "(see tests/device/README.md).", + ); + } + const port = targetPort(); + await execFileAsync("adb", ["reverse", `tcp:${port}`, `tcp:${port}`]); + + // Chrome 124+ opens a native "notifications make things easier" modal on + // first run, which swallows every tap until dismissed. Granting the + // permission up front means the promo never appears. + await device + .shell( + "pm grant com.android.chrome android.permission.POST_NOTIFICATIONS", + ) + .catch(() => { + // Older images have no such permission. + }); + + const context = await device.launchBrowser(); + // launchBrowser reuses Chrome's profile, so tabs accumulate across runs — + // and physical taps land on the *foreground* tab, so driving any other + // page sends every OS tap to the wrong document. Keep exactly one page + // (`newPage` is not supported on Android) and make sure it is frontmost. + if (context.pages().length === 0) { + await context.waitForEvent("page", { timeout: 15_000 }); + } + const pages = context.pages(); + const page = pages[pages.length - 1]; + for (const stale of pages.slice(0, -1)) { + await stale.close().catch(() => {}); + } + await page.bringToFront(); + + const { width, height } = await sizeOf(device); + + return new LocalAndroidSession(device, context, page, device.serial(), { + width, + height, + }); + } + + /** + * Measures where the page's CSS origin sits on the physical screen by + * tapping a known screen point and reading where the page observed the + * touch. Removes all guessing about status-bar and browser-chrome heights. + */ + private async ensureCalibrated(): Promise<{ + scale: number; + origin: { x: number; y: number }; + }> { + if (this.mapping) { + return this.mapping; + } + await this.page.bringToFront(); + const scale = + this.screen.width / (await this.page.evaluate(() => window.innerWidth)); + const probe = this.page.evaluate( + () => + new Promise<{ x: number; y: number }>((resolve) => { + const handler = (event: TouchEvent) => { + resolve({ + x: event.touches[0].clientX, + y: event.touches[0].clientY, + }); + }; + window.addEventListener("touchstart", handler, { + once: true, + capture: true, + }); + }), + ); + const tapX = Math.round(this.screen.width / 2); + const tapY = Math.round(this.screen.height / 2); + await new Promise((resolve) => setTimeout(resolve, 300)); + await this.osTap(tapX, tapY); + const seen = await probe; + this.mapping = { + scale, + origin: { + x: tapX - Math.round(seen.x * scale), + y: tapY - Math.round(seen.y * scale), + }, + }; + return this.mapping; + } + + /** + * OS-level input via `adb shell input` — what a finger/keyboard does, with + * no companion APK (Playwright's `device.input` needs its Android driver + * installed; `shell` is plain adb). + */ + private async osTap(x: number, y: number): Promise { + await this.device.shell(`input tap ${x} ${y}`); + } + + private async osType(text: string): Promise { + // `input text` treats space specially; our flows type URLs (ASCII, no + // spaces), and anything else is escaped the way adb expects. + await this.device.shell(`input text ${text.replaceAll(" ", "%s")}`); + } + + private async toScreen( + cssX: number, + cssY: number, + ): Promise<{ x: number; y: number }> { + const { scale, origin } = await this.ensureCalibrated(); + return { + x: Math.round(cssX * scale + origin.x), + y: Math.round(cssY * scale + origin.y), + }; + } + + async navigate(url: string): Promise { + await this.page.goto(url, { timeout: 60_000 }); + } + + /** + * The suite's scripts follow WebDriver's `execute` contract — a function + * *body* that may use `arguments`. `new Function` gives them identical + * semantics under Playwright's evaluate. + */ + async exec(script: string, args: unknown[] = []): Promise { + return (await this.page.evaluate( + ([body, fnArgs]) => + // eslint-disable-next-line no-implied-eval -- WebDriver-contract scripts are function bodies; this is the adapter + new Function(body as string)(...(fnArgs as unknown[])), + [script, args] as const, + )) as T; + } + + waitFor( + label: string, + script: string, + timeoutMs?: number, + ): Promise { + return waitForOk(this, label, script, timeoutMs); + } + + /** OS-level tap on the element's center — what a finger does. */ + async elementClick(css: string): Promise { + const rect = await this.exec<{ x: number; y: number } | null>( + `const el = document.querySelector(arguments[0]); + if (!el) return null; + const b = el.getBoundingClientRect(); + return { x: b.x + b.width / 2, y: b.y + b.height / 2 };`, + [css], + ); + if (!rect) { + throw new Error(`elementClick: no element for ${css}`); + } + const { x, y } = await this.toScreen(rect.x, rect.y); + await this.osTap(x, y); + } + + /** + * Types via the OS input pipeline into the focused element. The element is + * OS-tapped first so focus (and the keyboard) come up the way they would + * for a user. + */ + async elementValue(css: string, text: string): Promise { + await this.elementClick(css); + await new Promise((resolve) => setTimeout(resolve, 800)); + await this.osType(text); + } + + async nativeTap(x: number, y: number): Promise { + await this.osTap(Math.round(x), Math.round(y)); + } + + async typeKeys(text: string): Promise { + if (text === "\uE007") { + // WebDriver's Enter keycode, delivered as a genuine OS key event. + await this.device.shell("input keyevent 66"); + return; + } + await this.osType(text); + } + + /** + * Presses the on-screen keyboard's IME action key (Gboard's arrow / + * checkmark, bottom-right). The key's exact position varies by keyboard + * build, so candidate positions are tried with `verify` between attempts — + * the same ladder pattern the BrowserStack iOS taps use. + */ + async pressImeActionKey(verify: string): Promise { + const { width, height } = this.screen; + const candidates = [ + { x: 0.918, y: 0.906 }, + { x: 0.92, y: 0.93 }, + { x: 0.9, y: 0.88 }, + ]; + let lastError: Error | undefined; + for (const ratio of candidates) { + await this.osTap( + Math.round(width * ratio.x), + Math.round(height * ratio.y), + ); + try { + await this.waitFor("IME action effect", verify, 5_000); + return; + } catch (error) { + lastError = error as Error; + } + } + throw new Error( + `The IME action key press was not observed to take effect: ${lastError?.message}`, + ); + } + + async screenshot(name: string): Promise { + return saveScreenshot( + `local-android-${name}`, + await this.device.screenshot(), + ); + } + + async close(): Promise { + await this.context.close().catch(() => {}); + await this.device.close().catch(() => {}); + } +} + +async function sizeOf( + device: AndroidDevice, +): Promise<{ width: number; height: number }> { + const out = (await device.shell("wm size")).toString(); + const match = out.match(/(\d+)x(\d+)/); + if (!match) { + throw new Error(`Could not read screen size from: ${out}`); + } + return { width: Number(match[1]), height: Number(match[2]) }; +} diff --git a/tests/device/lib/localIos.ts b/tests/device/lib/localIos.ts new file mode 100644 index 0000000000..0c66498697 --- /dev/null +++ b/tests/device/lib/localIos.ts @@ -0,0 +1,147 @@ +/** + * A local iOS simulator via Appium's XCUITest driver — the sanctioned + * full-fidelity automation stack for iOS (WebDriverAgent), driven with + * `selenium-webdriver` as a plain W3C client. The simulator runs the + * actual iOS build and the actual Safari, headless (XCUITest owns the HID + * stack, so the software keyboard appears without the Simulator GUI), and + * shares the host's network — `127.0.0.1` reaches the dev server, no tunnel. + * + * Findings that shaped this backend, the hard way: + * - Apple's safaridriver cannot do this: its input is synthetic at the WebKit + * layer, which never summons the software keyboard, and injecting real HID + * (idb) during its session trips Safari's "stop the current automated test + * session?" guardrail. + * - Appium's web-context element clicks are synthetic too (nativeWebTap + * included, on current iOS). Real interaction goes through `mobile: tap` at + * screen points — which is why the gesture layer keeps chrome-offset + * ladders for iOS. + */ +import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { Builder, By, type WebDriver } from "selenium-webdriver"; + +import { type DeviceSession, waitForOk } from "./session.js"; +import { saveScreenshot } from "./artifacts.js"; + +const execFileAsync = promisify(execFile); + +export const APPIUM_PORT = 47632; + +/** Where the suite setup records the booted simulator for the workers. */ +export const SIM_UDID_FILE = join( + import.meta.dirname, + "..", + ".artifacts", + ".booted-simulator", +); + +/** True on macOS with the simulator toolchain present. */ +export async function localIosAvailable(): Promise { + if (process.platform !== "darwin") { + return false; + } + try { + await execFileAsync("xcrun", ["simctl", "help"], { timeout: 10_000 }); + return true; + } catch { + return false; + } +} + +export class LocalIosSession implements DeviceSession { + readonly kind = "local-ios"; + readonly platform = "ios"; + + private constructor( + private readonly driver: WebDriver, + public readonly sessionId: string, + ) {} + + static async create(): Promise { + // The suite's setup (lib/tunnel.ts) boots a simulator; discover it here + // rather than passing state across processes — vitest's global setup and + // its workers don't share an environment. + // Written by the suite setup (lib/tunnel.ts), which boots the device. + let udid: string; + try { + udid = readFileSync(SIM_UDID_FILE, "utf8").trim(); + } catch { + throw new Error( + "No simulator recorded — the device-suite setup should have booted " + + "one and written " + + SIM_UDID_FILE + + " (see lib/tunnel.ts).", + ); + } + const driver = await new Builder() + .usingServer(`http://127.0.0.1:${APPIUM_PORT}`) + .withCapabilities({ + platformName: "iOS", + browserName: "Safari", + "appium:automationName": "XCUITest", + "appium:udid": udid, + // WebDriverAgent's first build on a fresh machine takes minutes. + "appium:wdaLaunchTimeout": 240_000, + }) + .build(); + const sessionId = (await driver.getSession()).getId(); + return new LocalIosSession(driver, sessionId); + } + + async navigate(url: string): Promise { + await this.driver.get(url); + } + + async exec(script: string, args: unknown[] = []): Promise { + return (await this.driver.executeScript( + script, + ...(args as (string | number | boolean | object | null)[]), + )) as T; + } + + waitFor( + label: string, + script: string, + timeoutMs?: number, + ): Promise { + return waitForOk(this, label, script, timeoutMs); + } + + /** + * Synthetic at the WebKit layer — never moves focus or opens the keyboard + * on iOS. The gesture layer's ladders use `nativeTap` instead. + */ + async elementClick(css: string): Promise { + await this.driver.findElement(By.css(css)).click(); + } + + async elementValue(css: string, text: string): Promise { + await this.driver.findElement(By.css(css)).sendKeys(text); + } + + /** Real HID tap through WebDriverAgent. Screen points (CSS px scale). */ + async nativeTap(x: number, y: number): Promise { + await this.exec("mobile: tap", [{ x: Math.round(x), y: Math.round(y) }]); + } + + async typeKeys(text: string): Promise { + await this.driver.actions().sendKeys(text).perform(); + await this.driver + .actions() + .clear() + .catch(() => {}); + } + + async screenshot(name: string): Promise { + return saveScreenshot( + `local-ios-${name}`, + await this.driver.takeScreenshot(), + ); + } + + async close(): Promise { + await this.driver.quit().catch(() => {}); + } +} diff --git a/tests/device/lib/session.ts b/tests/device/lib/session.ts new file mode 100644 index 0000000000..d61974a541 --- /dev/null +++ b/tests/device/lib/session.ts @@ -0,0 +1,94 @@ +/** + * The transport-agnostic session contract every device/OS target implements. + * + * Two backends exist: + * - `localAndroid.ts` — a local Android emulator via Playwright's `_android` + * (page) + `adb shell input` (genuine OS events — including the on-screen + * keyboard itself, which no cloud channel can press) + * - `localIos.ts` — a local iOS simulator via Appium/XCUITest (the actual + * iOS build and Safari; WebDriverAgent owns the HID stack, so it works + * headless) + * + * Tests and page helpers speak only this interface; per-target quirks live in + * the backends and in `gestures.ts`. + */ + +export type Platform = "android" | "ios"; + +export type TargetKind = "local-android" | "local-ios"; + +export interface DeviceSession { + readonly platform: Platform; + readonly kind: TargetKind; + /** Backend session identifier, for artifacts and dashboards. */ + readonly sessionId: string; + + navigate(url: string): Promise; + + /** Runs a script in the page. The script body may use `arguments`. */ + exec(script: string, args?: unknown[]): Promise; + + /** + * Polls a page script until it returns `{ ok: true, ... }`. Returns the + * final result; throws with the last observed value on timeout so failures + * carry the page state they timed out on. + */ + waitFor( + label: string, + script: string, + timeoutMs?: number, + ): Promise; + + /** + * Element click through the backend's input pipeline. On iOS the resulting + * events are synthetic at the WebKit layer and never move focus or open + * the keyboard — the gesture layer's tap ladders apply there. + */ + elementClick(css: string): Promise; + + /** Types into an element via the backend's value/sendKeys channel. */ + elementValue(css: string, text: string): Promise; + + /** + * OS-level tap at screen coordinates. Reaches outside the page — the + * on-screen keyboard included. + */ + nativeTap(x: number, y: number): Promise; + + /** Protocol-level key events to the focused element (U+E007 = Enter). */ + typeKeys(text: string): Promise; + + /** + * Presses the on-screen keyboard's bottom-right key — the IME action key + * in a form field (Gboard's arrow / checkmark), Enter in an editor — where + * the backend can reach it. `verify` is a page script returning + * `{ ok: boolean }` observing the effect. + */ + pressImeActionKey?(verify: string): Promise; + + /** Saves a PNG screenshot under tests/device/.artifacts; returns the path. */ + screenshot(name: string): Promise; + + close(): Promise; +} + +/** Shared implementation of {@link DeviceSession.waitFor}. */ +export async function waitForOk( + session: Pick, + label: string, + script: string, + timeoutMs = 20_000, +): Promise { + const start = Date.now(); + let last: T | undefined; + while (Date.now() - start < timeoutMs) { + last = await session.exec(script); + if (last && last.ok) { + return last; + } + await new Promise((resolve) => setTimeout(resolve, 700)); + } + throw new Error( + `Timed out at "${label}": ${JSON.stringify(last).slice(0, 300)}`, + ); +} diff --git a/tests/device/lib/tunnel.ts b/tests/device/lib/tunnel.ts new file mode 100644 index 0000000000..a2740ee04c --- /dev/null +++ b/tests/device/lib/tunnel.ts @@ -0,0 +1,127 @@ +/** + * Vitest global setup for the device suite. Prepares whichever backends this + * run can use (see devices.ts): + * + * - **All targets** need the app server (the playground dev server, or + * whatever DEVICE_TEST_TARGET points at). + * - **Local iOS** needs a booted simulator (headless is fine — XCUITest owns + * the HID stack) and a running Appium server. + * - **Local Android** needs nothing here: the session itself sets up + * `adb reverse` when it connects to the already-running emulator. + * + * This file runs both locally and in CI — the same code paths, so a CI + * failure reproduces identically on a laptop. + */ +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { promisify } from "node:util"; + +import { activeDevices } from "../devices.js"; +import { APPIUM_PORT, SIM_UDID_FILE } from "./localIos.js"; + +const execFileAsync = promisify(execFile); + +function targetOrigin(): string { + return process.env.DEVICE_TEST_TARGET ?? "http://127.0.0.1:5173"; +} + +async function ensureAppServer(): Promise { + const res = await fetch(targetOrigin(), { redirect: "manual" }).catch( + () => undefined, + ); + if (!res) { + throw new Error( + `No app server at ${targetOrigin()}. Start the playground (\`pnpm run dev\`) ` + + `or point DEVICE_TEST_TARGET at a running server.`, + ); + } +} + +async function startLocalIos(): Promise<() => Promise> { + // Pick (and if needed boot) an iPhone simulator; sessions discover the + // booted device themselves (vitest's global setup and its workers don't + // share an environment). Headless is fine: XCUITest owns the HID stack, so + // the software keyboard appears without the Simulator GUI. + const { stdout } = await execFileAsync("xcrun", [ + "simctl", + "list", + "devices", + "available", + ]); + // Prefer a device that is already up; otherwise take the first iPhone. + // `bootstatus -b` boots if needed and returns promptly when already booted, + // so there are no state-string races ("Booted", "Shutting Down", ...) to + // pattern-match. + const already = stdout.match(/iPhone [^(]+\(([0-9A-F-]{36})\) \(Booted\)/); + const any = stdout.match(/iPhone [^(]+\(([0-9A-F-]{36})\)/); + const udid = already?.[1] ?? any?.[1]; + if (!udid) { + throw new Error("No available iPhone simulator found (xcrun simctl list)."); + } + await execFileAsync("xcrun", ["simctl", "bootstatus", udid, "-b"], { + timeout: 240_000, + }); + const bootedByUs = already ? undefined : udid; + // Hand the chosen device to the test workers through the filesystem — + // global setup and workers don't share an environment, and polling + // `simctl list` for a Booted device races on slow CI runners. + mkdirSync(dirname(SIM_UDID_FILE), { recursive: true }); + writeFileSync(SIM_UDID_FILE, udid); + + // Appium with the XCUITest driver (an npm devDependency, which Appium + // discovers). Note Appium requires an even-numbered Node (see + // .node-version); it refuses to start otherwise. + const server: ChildProcess = spawn( + "npx", + ["appium", "server", "-p", String(APPIUM_PORT)], + { stdio: "ignore", cwd: import.meta.dirname }, + ); + const deadline = Date.now() + 60_000; + for (;;) { + const ok = await fetch(`http://127.0.0.1:${APPIUM_PORT}/status`) + .then((res) => res.ok) + .catch(() => false); + if (ok) { + break; + } + if (Date.now() > deadline) { + server.kill(); + throw new Error( + "Appium did not start. It requires an even-numbered Node version " + + "(see .node-version) and the appium-xcuitest-driver devDependency.", + ); + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + return async () => { + server.kill(); + if (bootedByUs) { + await execFileAsync("xcrun", ["simctl", "shutdown", bootedByUs]).catch( + () => {}, + ); + } + }; +} + +export default async function setup(): Promise<(() => Promise) | void> { + const targets = await activeDevices(); + if (targets.length === 0) { + // Nothing this machine can drive; the suites self-skip. + return; + } + + await ensureAppServer(); + + const teardowns: (() => Promise)[] = []; + if (targets.some((t) => t.kind === "local-ios")) { + teardowns.push(await startLocalIos()); + } + + return async () => { + for (const teardown of teardowns.reverse()) { + await teardown(); + } + }; +} diff --git a/tests/device/linkPopover.ts b/tests/device/linkPopover.ts new file mode 100644 index 0000000000..8d621ff204 --- /dev/null +++ b/tests/device/linkPopover.ts @@ -0,0 +1,90 @@ +/** + * Helpers for the create-link flow on real devices — next to the tests that + * use them, since only the link tests speak these concepts. + */ +import { MOBILE_TOOLBAR, PARAGRAPH, startEditing } from "./lib/editorPage.js"; +import { pressSoftKeyboardEnter, tapElement } from "./lib/gestures.js"; +import type { DeviceSession } from "./lib/session.js"; + +export const LINK_BUTTON = `${MOBILE_TOOLBAR} [data-test="createLink"]`; +export const LINK_POPOVER = ".bn-form-popover"; + +/** + * Selects the first word of the first paragraph via a DOM range (ProseMirror + * syncs its selection from `selectionchange`, so no editor handle is needed). + * iOS intermittently collapses programmatic selections, so the wait re-applies + * the range on every poll until the toolbar's link button confirms the editor + * sees a non-empty selection. + */ +export async function selectFirstWord(session: DeviceSession): Promise { + const applyAndCheck = ` + if (getSelection().isCollapsed) { + const p = document.querySelector(${JSON.stringify(PARAGRAPH)}); + const textNode = [...p.childNodes].find((n) => n.nodeType === 3) || p.firstChild; + const range = document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, Math.min(7, textNode.textContent.length)); + const selection = getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + return { + ok: !getSelection().isCollapsed + && !!document.querySelector(${JSON.stringify(LINK_BUTTON)}), + };`; + await session.waitFor("selection + link button", applyAndCheck, 25_000); +} + +/** + * Opens the create-link popover from the mobile toolbar and waits for its URL + * input to hold focus. A mis-aimed tap (iOS chrome-offset guessing) can hit + * the keyboard's accessory bar and collapse the whole editing state, so each + * attempt rebuilds editing + selection from scratch before tapping. + */ +export async function openLinkPopover(session: DeviceSession): Promise { + let lastError: Error | undefined; + for (let attempt = 0; attempt < 4; attempt++) { + await startEditing(session); + await selectFirstWord(session); + await session.exec(` + const toolbar = document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)}); + toolbar.querySelectorAll('*').forEach((el) => { + if (el.scrollWidth > el.clientWidth + 5) el.scrollLeft = el.scrollWidth; + });`); + try { + await tapElement(session, LINK_BUTTON, { + keyboard: "open", + verify: ` + const active = document.activeElement; + return { + ok: !!document.querySelector(${JSON.stringify(LINK_POPOVER)}) + && active && active.tagName === 'INPUT' + && active.getAttribute('name') === 'url', + };`, + }); + return; + } catch (error) { + lastError = error as Error; + } + } + throw new Error(`Could not open the link popover: ${lastError?.message}`); +} + +/** + * Types into a popover field and submits it by pressing the on-screen + * keyboard's Enter/action key — the real user gesture on both platforms (see + * `pressSoftKeyboardEnter`), driving the real submission path: key press -> + * implicit form submission -> the popover's `submit` handling. + * + * `verify` is a page script returning `{ ok: boolean }` observing the + * submission's effect — the tap ladders need it to know a tap landed. + */ +export async function typeAndSubmit( + session: DeviceSession, + css: string, + text: string, + verify: string, +): Promise { + await session.elementValue(css, text); + await pressSoftKeyboardEnter(session, verify); +} diff --git a/tests/device/vitest.config.mts b/tests/device/vitest.config.mts new file mode 100644 index 0000000000..8a4cf8bc7f --- /dev/null +++ b/tests/device/vitest.config.mts @@ -0,0 +1,27 @@ +import { defineConfig } from "vite-plus"; + +/** + * Device suite (local Android emulator + iOS simulator). Not part of the + * workspace projects on purpose: it needs a booted emulator/simulator, so it + * only runs via `pnpm run test:device` (locally or from the emulator-tests + * workflow). Configured through plain environment variables (`DEVICE_FILTER`, + * `DEVICE_TEST_TARGET`). + */ +export default defineConfig({ + root: import.meta.dirname, + test: { + include: ["**/*.device.test.ts"], + globalSetup: ["./lib/tunnel.ts"], + // Device sessions are slow to create and drive. + testTimeout: 240_000, + hookTimeout: 180_000, + teardownTimeout: 60_000, + // One retry absorbs genuine device flake (session allocation, emulator + // hiccups) without hiding real regressions. + retry: 1, + // Serial: OS taps land on the foreground app, so only one session can + // own the device's screen at a time. + fileParallelism: false, + passWithNoTests: true, + }, +}); diff --git a/tests/package.json b/tests/package.json index bef2956fcc..b0f479e748 100644 --- a/tests/package.json +++ b/tests/package.json @@ -11,32 +11,38 @@ "devDependencies": { "@blocknote/ariakit": "workspace:^", "@blocknote/core": "workspace:^", - "@blocknote/mantine": "workspace:^", "@blocknote/diagram-block": "workspace:^", - "@blocknote/xl-email-exporter": "workspace:^", - "@blocknote/xl-pdf-exporter": "workspace:^", - "@react-pdf/renderer": "^4.5.1", - "pdfjs-dist": "^4.10.38", + "@blocknote/mantine": "workspace:^", "@blocknote/math-block": "workspace:^", "@blocknote/react": "workspace:^", "@blocknote/shadcn": "workspace:^", + "@blocknote/xl-email-exporter": "workspace:^", "@blocknote/xl-multi-column": "workspace:^", + "@blocknote/xl-pdf-exporter": "workspace:^", "@playwright/test": "1.60.0", + "@react-pdf/renderer": "^4.5.1", "@tailwindcss/vite": "^4.1.14", "@tiptap/pm": "^3.29.2", "@types/node": "^20.19.22", "@types/react": "^19.2.3", "@types/react-dom": "^19.2.3", + "@types/selenium-webdriver": "^4.35.6", "@vitest/browser-playwright": "4.1.10", "@vitest/ui": "4.1.5", "@y/protocols": "^1.0.6-rc.1", "@y/y": "^14.0.0-rc.23", + "appium": "^3.7.0", + "appium-xcuitest-driver": "^12.8.2", "htmlfy": "^0.6.7", + "pdfjs-dist": "^4.10.38", + "playwright-core": "^1.62.1", "react": "^19.2.5", "react-dom": "^19.2.5", "react-icons": "^5.5.0", "rimraf": "^5.0.10", + "selenium-webdriver": "^4.48.0", "vite-plus": "catalog:", + "vitest": "4.1.10", "vitest-browser-react": "^2.2.0" }, "dependencies": { diff --git a/tests/src/end-to-end/copypaste/copypaste.test.tsx b/tests/src/end-to-end/copypaste/copypaste.test.tsx index eb5400db18..930dd45f9c 100644 --- a/tests/src/end-to-end/copypaste/copypaste.test.tsx +++ b/tests/src/end-to-end/copypaste/copypaste.test.tsx @@ -25,6 +25,11 @@ import { import { getRect, mouseSequence } from "../../utils/mouse.js"; import { executeSlashCommand } from "../../utils/slashmenu.js"; +// The android browser instance runs this suite too (see +// vite.config.browser.ts); tests that drive selection or resizing with +// positional mouse drags don't translate to the touch-emulated context: +const onAndroid = /android/i.test(navigator.userAgent); + describe("Check Copy/Paste Functionality", () => { beforeEach(async () => { await render(); @@ -128,51 +133,53 @@ describe("Check Copy/Paste Functionality", () => { }, ); - test.skipIf(browserName === "firefox" || browserName === "webkit")( - "Images should keep props", - async () => { - await focusOnEditor(); - await userEvent.keyboard("paragraph"); - - const IMAGE_EMBED_URL = "https://placehold.co/800x540.png"; - await executeSlashCommand("image"); - - await userEvent.click(await waitForSelector(`[data-test="embed-tab"]`)); - await userEvent.click(await waitForSelector(`[data-test="embed-input"]`)); - await userEvent.keyboard(IMAGE_EMBED_URL); - await userEvent.click( - await waitForSelector(`[data-test="embed-input-button"]`), - ); - await waitForSelector(`img[src="${IMAGE_EMBED_URL}"]`); - - await userEvent.click(await waitForSelector(`img`)); - - await waitForSelector(`[class*="bn-resize-handle"][style*="right"]`); - const resizeHandleBoundingBox = getRect( - `[class*="bn-resize-handle"][style*="right"]`, - ); - await mouseSequence([ - { - type: "move", - x: resizeHandleBoundingBox.x + resizeHandleBoundingBox.width / 2, - y: resizeHandleBoundingBox.y + resizeHandleBoundingBox.height / 2, - steps: 5, - }, - { type: "down" }, - { - type: "move", - x: resizeHandleBoundingBox.x + resizeHandleBoundingBox.width / 2 - 50, - y: resizeHandleBoundingBox.y + resizeHandleBoundingBox.height / 2, - steps: 5, - }, - { type: "up" }, - ]); - - await copyPaste(); - - await compareDocToSnapshot("images"); - }, - ); + // Skipped on android: sets previewWidth by mouse-dragging the resize + // handle, which doesn't operate under touch emulation, so the prop is + // legitimately absent from the pasted result. + test.skipIf( + browserName === "firefox" || browserName === "webkit" || onAndroid, + )("Images should keep props", async () => { + await focusOnEditor(); + await userEvent.keyboard("paragraph"); + + const IMAGE_EMBED_URL = "https://placehold.co/800x540.png"; + await executeSlashCommand("image"); + + await userEvent.click(await waitForSelector(`[data-test="embed-tab"]`)); + await userEvent.click(await waitForSelector(`[data-test="embed-input"]`)); + await userEvent.keyboard(IMAGE_EMBED_URL); + await userEvent.click( + await waitForSelector(`[data-test="embed-input-button"]`), + ); + await waitForSelector(`img[src="${IMAGE_EMBED_URL}"]`); + + await userEvent.click(await waitForSelector(`img`)); + + await waitForSelector(`[class*="bn-resize-handle"][style*="right"]`); + const resizeHandleBoundingBox = getRect( + `[class*="bn-resize-handle"][style*="right"]`, + ); + await mouseSequence([ + { + type: "move", + x: resizeHandleBoundingBox.x + resizeHandleBoundingBox.width / 2, + y: resizeHandleBoundingBox.y + resizeHandleBoundingBox.height / 2, + steps: 5, + }, + { type: "down" }, + { + type: "move", + x: resizeHandleBoundingBox.x + resizeHandleBoundingBox.width / 2 - 50, + y: resizeHandleBoundingBox.y + resizeHandleBoundingBox.height / 2, + steps: 5, + }, + { type: "up" }, + ]); + + await copyPaste(); + + await compareDocToSnapshot("images"); + }); }); describe("Check Copy/Paste From Non-Editable Block", () => { @@ -183,32 +190,34 @@ describe("Check Copy/Paste From Non-Editable Block", () => { // Firefox doesn't yet support the async clipboard API. Webkit copy/paste // stopped working after updating to Playwright 1.33. - test.skipIf(browserName === "firefox" || browserName === "webkit")( - "Should be able to copy/paste text from a non-editable block", - async () => { - // Click and drag across the non-editable block's text to select part of it. - const box = getRect('[data-content-type="nonEditable"] p'); - await mouseSequence([ - { type: "move", x: box.x + 2, y: box.y + box.height / 2 }, - { type: "down" }, - { - type: "move", - x: box.x + box.width * 0.25, - y: box.y + box.height / 2, - steps: 5, - }, - { type: "up" }, - ]); - - await userEvent.keyboard(`{${MOD}>}c{/${MOD}}`); - - // Click the trailing block to create a new empty paragraph and focus - // the editor there. - await userEvent.click(await waitForSelector(DOC_TRAILING_BLOCK_SELECTOR)); - - await userEvent.keyboard(`{${MOD}>}v{/${MOD}}`); - - await compareDocToSnapshot("nonEditableBlock"); - }, - ); + // Skipped on android: selects text with a positional mouse drag, which + // doesn't operate under touch emulation — Mod+C then copies nothing and the + // paste emits whatever the previous test left on the shared clipboard. + test.skipIf( + browserName === "firefox" || browserName === "webkit" || onAndroid, + )("Should be able to copy/paste text from a non-editable block", async () => { + // Click and drag across the non-editable block's text to select part of it. + const box = getRect('[data-content-type="nonEditable"] p'); + await mouseSequence([ + { type: "move", x: box.x + 2, y: box.y + box.height / 2 }, + { type: "down" }, + { + type: "move", + x: box.x + box.width * 0.25, + y: box.y + box.height / 2, + steps: 5, + }, + { type: "up" }, + ]); + + await userEvent.keyboard(`{${MOD}>}c{/${MOD}}`); + + // Click the trailing block to create a new empty paragraph and focus + // the editor there. + await userEvent.click(await waitForSelector(DOC_TRAILING_BLOCK_SELECTOR)); + + await userEvent.keyboard(`{${MOD}>}v{/${MOD}}`); + + await compareDocToSnapshot("nonEditableBlock"); + }); }); diff --git a/tests/src/end-to-end/form/compositionSubmit.test.tsx b/tests/src/end-to-end/form/compositionSubmit.test.tsx new file mode 100644 index 0000000000..6867a93792 --- /dev/null +++ b/tests/src/end-to-end/form/compositionSubmit.test.tsx @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { browserName, commands, userEvent } from "../../utils/context.js"; +import type { ImeCompositionCommand } from "../../utils/imeComposition.js"; + +/** + * Why the popover forms need no composition guard. + * + * The Enter handlers that `Form.Root`'s submit path replaced all guarded on + * `isComposing` — necessary for a *keydown* handler, because the keydown for + * an IME-consumed key still dispatches to JS. Native form submission is a + * different category: the IME consumes the confirming Enter (it reaches the + * page as keyCode 229, which the browser runs no default action for), so + * implicit submission never fires mid-composition. This is why no plain + * `` in the world carries composition handling. + * + * These tests pin the two halves of that contract on the real IME event + * sequence. What they deliberately do *not* do is inject a bare Enter while + * composition is held open: CDP can fabricate that state, and the browser + * does submit on it, but no real IME delivers an unconsumed Enter + * mid-composition — and guarding against the fabricated state would mean + * betting that every IME fires `compositionend` before the submit it + * triggers, or a Gboard-style single-press commit-and-submit gets swallowed. + */ + +const browserCommands = commands as typeof commands & { + imeComposition: ImeCompositionCommand; +}; + +// `Input.imeSetComposition` is CDP-only. Firefox and WebKit have no equivalent +// in their automation protocols, so real composition state can't be entered +// there at all — the behaviour is chromium-verified only. +const describeIme = browserName === "chromium" ? describe : describe.skip; + +let form: HTMLFormElement | undefined; + +afterEach(() => { + form?.remove(); + form = undefined; +}); + +function buildForm() { + form = document.createElement("form"); + const submits: string[] = []; + const compositions: string[] = []; + form.addEventListener("submit", (event) => { + event.preventDefault(); + submits.push("submit"); + }); + + const input = document.createElement("input"); + input.type = "text"; + input.name = "url"; + input.addEventListener("compositionstart", () => + compositions.push("compositionstart"), + ); + input.addEventListener("compositionend", () => + compositions.push("compositionend"), + ); + form.append(input); + + // What `Form.Root` renders, so that this mirrors a real popover form. + const button = document.createElement("button"); + button.type = "submit"; + button.tabIndex = -1; + form.append(button); + + document.body.append(form); + return { input, submits, compositions }; +} + +describeIme("IME composition and form submission", () => { + test("accepting a candidate does not submit the form", async () => { + // The real accept path: the IME replaces the composition with the final + // text (`insertText`), and the confirming key never reaches the page as + // an actionable Enter — so nothing submits, natively. + const { input, submits, compositions } = buildForm(); + input.focus(); + + await browserCommands.imeComposition([ + { type: "setComposition", text: "にほん" }, + { type: "commit", text: "日本" }, + ]); + + expect(compositions).toContain("compositionstart"); + expect(input.value).toBe("日本"); + expect( + submits, + "accepting an IME candidate must not submit the popover", + ).toEqual([]); + }); + + test("Enter after the composition ends does submit", async () => { + // The other half of the contract: once composition is over, Enter has to + // work normally, or CJK users could never submit at all. + const { input, submits } = buildForm(); + input.focus(); + + await browserCommands.imeComposition([ + { type: "setComposition", text: "にほん" }, + { type: "commit", text: "日本" }, + ]); + await userEvent.keyboard("{Enter}"); + + expect(submits).toEqual(["submit"]); + }); +}); diff --git a/tests/src/end-to-end/form/implicitSubmit.test.tsx b/tests/src/end-to-end/form/implicitSubmit.test.tsx new file mode 100644 index 0000000000..0091450333 --- /dev/null +++ b/tests/src/end-to-end/form/implicitSubmit.test.tsx @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { userEvent } from "../../utils/context.js"; + +/** + * The platform rules that `Form.Root` is built on. + * + * Since the toolbar popovers submit through the form's `submit` event rather + * than a key handler (a mobile IME's action key fires the former and not the + * latter), "does Enter reach `submit`?" became load-bearing. The answer is not + * uniform: HTML only submits implicitly when the form has a submit button, or + * exactly one field that blocks implicit submission + * (https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission). + * + * So these assert the rule per engine rather than trusting the spec — the + * multi-field case is exactly the link toolbar's URL + title form, and the + * hidden-button case is what `Form.Root` renders to make submission work + * regardless of how many fields a caller puts in it. + */ + +const forms: HTMLFormElement[] = []; + +afterEach(() => { + while (forms.length) { + forms.pop()!.remove(); + } +}); + +type SubmitButton = "none" | "hidden" | "visually-hidden"; + +function buildForm( + inputCount: number, + submitButton: SubmitButton, + tabIndex?: number, +) { + const form = document.createElement("form"); + const submits: string[] = []; + form.addEventListener("submit", (event) => { + event.preventDefault(); + submits.push("submit"); + }); + + const inputs: HTMLInputElement[] = []; + for (let i = 0; i < inputCount; i++) { + const input = document.createElement("input"); + input.type = "text"; + input.name = `field-${i}`; + form.append(input); + inputs.push(input); + } + + if (submitButton !== "none") { + const button = document.createElement("button"); + button.type = "submit"; + if (tabIndex !== undefined) { + button.tabIndex = tabIndex; + } + if (submitButton === "hidden") { + button.hidden = true; + } else { + button.style.cssText = + "position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)"; + } + form.append(button); + } + + document.body.append(form); + forms.push(form); + return { inputs, submits }; +} + +async function pressEnterIn(input: HTMLInputElement) { + input.focus(); + await userEvent.keyboard("{Enter}"); +} + +describe("Implicit form submission", () => { + test("a single field submits without a submit button", async () => { + const { inputs, submits } = buildForm(1, "none"); + + await pressEnterIn(inputs[0]); + + expect(submits).toEqual(["submit"]); + }); + + test("several fields do NOT submit without a submit button", async () => { + // The reason `Form.Root` cannot just be a bare ``: the link + // toolbar's edit form has two fields, so Enter would reach nothing. + const { inputs, submits } = buildForm(2, "none"); + + await pressEnterIn(inputs[0]); + + expect(submits).toEqual([]); + }); + + test("several fields submit once a hidden submit button is present", async () => { + const { inputs, submits } = buildForm(2, "hidden"); + + await pressEnterIn(inputs[0]); + expect(submits).toEqual(["submit"]); + + // From the last field too, where a mobile IME offers its action key. + await pressEnterIn(inputs[1]); + expect(submits).toEqual(["submit", "submit"]); + }); + + test("several fields submit with a visually hidden submit button", async () => { + // What `Form.Root` actually renders: clipped rather than `display: none`, + // so assistive technology still sees a submit control. Keeping it out of + // the layout must not cost the implicit submission that `hidden` provided. + const { inputs, submits } = buildForm(2, "visually-hidden"); + + await pressEnterIn(inputs[0]); + + expect(submits).toEqual(["submit"]); + }); + + test("a submit button outside the tab order still submits", async () => { + // `Form.Root` sets `tabIndex={-1}` on it, so that a control nobody can see + // never becomes a tab stop. Implicit submission looks for the form's + // default button and must not care about that. + const { inputs, submits } = buildForm(2, "visually-hidden", -1); + + await pressEnterIn(inputs[0]); + + expect(submits).toEqual(["submit"]); + }); + + test("a submit button does not make Enter submit twice", async () => { + const { inputs, submits } = buildForm(1, "hidden"); + + await pressEnterIn(inputs[0]); + + expect(submits).toEqual(["submit"]); + }); +}); diff --git a/tests/src/end-to-end/form/popoverSubmit.test.tsx b/tests/src/end-to-end/form/popoverSubmit.test.tsx new file mode 100644 index 0000000000..f12492ccad --- /dev/null +++ b/tests/src/end-to-end/form/popoverSubmit.test.tsx @@ -0,0 +1,189 @@ +import TestingApp from "@examples/01-basic/testing/src/App"; +import { beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { browserName, commands, userEvent } from "../../utils/context.js"; +import type { ImeCompositionCommand } from "../../utils/imeComposition.js"; +import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; +import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; +import { executeSlashCommand } from "../../utils/slashmenu.js"; + +const browserCommands = commands as typeof commands & { + imeComposition: ImeCompositionCommand; +}; + +/** + * The toolbar popovers commit through their form's `submit` event, because a + * mobile IME's action key fires that and no key event at all. + * + * These drive Enter rather than calling the handlers, so they cover the whole + * path a browser takes to reach `onSubmit` — including whether the form is + * eligible for implicit submission at all, which depends on how many fields + * the popover happens to render (see ./implicitSubmit.test.tsx). + */ + +beforeEach(async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); +}); + +async function createLink(url: string) { + await focusOnEditor(); + await userEvent.keyboard("link me"); + await userEvent.keyboard("{Home}{Shift>}{End}{/Shift}"); + await userEvent.click(await waitForSelector(LINK_BUTTON_SELECTOR)); + const input = (await waitForSelector( + 'input[name="url"]', + )) as HTMLInputElement; + await userEvent.click(input); + await userEvent.keyboard(`${url}{Enter}`); + return waitForSelector(`a[href="https://${url}"]`); +} + +describe("Submitting a toolbar popover with Enter", () => { + test("the link edit form commits, though it has two fields", async () => { + // The regression this guards: HTML only submits a form implicitly when it + // has a submit button *or* exactly one field. The create form has one + // field (url) and submits on its own; this edit form adds the title + // field, so without the submit button `Form.Root` renders, Enter reaches + // nothing and the edit is silently dropped. + const link = await createLink("example.com"); + + await userEvent.hover(link); + await vi.waitFor(() => { + const editButton = [ + ...document.querySelectorAll(".bn-toolbar button"), + ].find((button) => button.textContent?.trim() === "Edit link"); + if (!editButton) { + throw new Error("the link toolbar's edit button never appeared"); + } + editButton.click(); + }); + + const urlInput = (await waitForSelector( + 'input[name="url"]', + )) as HTMLInputElement; + // Both fields are present — that is what makes this case different. + expect(document.querySelector('input[name="title"]')).not.toBeNull(); + + await userEvent.tripleClick(urlInput); + await userEvent.keyboard("edited.com{Enter}"); + + await vi.waitFor(() => { + if (!document.querySelector('a[href="https://edited.com"]')) { + throw new Error("Enter did not commit the two-field edit form"); + } + }); + }); + + test("the submit control stays available to assistive technology", async () => { + // `display: none` would take the button out of the accessibility tree + // entirely, leaving Enter as the only way to commit — nothing for a + // screen reader or voice control to target. It has to be clipped instead, + // and carry a real accessible name. + await createLink("example.com"); + + await userEvent.hover( + await waitForSelector('a[href="https://example.com"]'), + ); + await vi.waitFor(() => { + const editButton = [ + ...document.querySelectorAll(".bn-toolbar button"), + ].find((button) => button.textContent?.trim() === "Edit link"); + if (!editButton) { + throw new Error("the link toolbar's edit button never appeared"); + } + editButton.click(); + }); + const input = await waitForSelector('input[name="url"]'); + + const submit = input.closest("form")!.querySelector("button[type=submit]"); + expect(submit, "the form must expose a submit control").not.toBeNull(); + + const styles = getComputedStyle(submit!); + expect(styles.display).not.toBe("none"); + expect(styles.visibility).not.toBe("hidden"); + expect(submit!.textContent?.trim(), "it needs an accessible name").toBe( + "Submit", + ); + // Out of the tab order, so sighted keyboard users never land on a control + // they can't see. + expect((submit as HTMLButtonElement).tabIndex).toBe(-1); + }); + + test("the embed tab keeps its button out of the form", async () => { + // Two things ride on the button staying outside the ``, which is why + // this asserts the structure rather than an outcome: + // + // - `Form.Root` must not also add its hidden submit button, or a screen + // reader announces two separate actions for the one thing this panel + // does. + // - Inside the form the button would fire `onClick` *and* submit on the + // skins whose panel button defaults to `type="submit"` (ariakit and + // shadcn; mantine's defaults to `type="button"`), embedding twice. + // Only mantine runs in this suite, so a double-commit assertion here + // could never fail — the structural check is what actually guards it. + await focusOnEditor(); + await executeSlashCommand("image"); + await userEvent.click(await waitForSelector(`[data-test="embed-tab"]`)); + const input = await waitForSelector(`[data-test="embed-input"]`); + + const form = input.closest("form"); + expect(form, "the embed field must still be in a form").not.toBeNull(); + expect(form!.querySelectorAll("button").length).toBe(0); + }); + + test("the embed tab's URL field commits", async () => { + // The embed tab used to be the one input with an Enter handler and no + // form at all, so its action key did nothing on mobile. + await focusOnEditor(); + await executeSlashCommand("image"); + + await userEvent.click(await waitForSelector(`[data-test="embed-tab"]`)); + const input = (await waitForSelector( + `[data-test="embed-input"]`, + )) as HTMLInputElement; + await userEvent.click(input); + + const url = "https://placehold.co/800x540.png"; + await userEvent.keyboard(`${url}{Enter}`); + + await waitForSelector(`img[src="${url}"]`); + }); + + // `Input.imeSetComposition` is CDP-only, so the real composition state can + // only be entered in chromium. + test.skipIf(browserName !== "chromium")( + "accepting an IME candidate does not commit the popover", + async () => { + // The real accept path: the IME consumes the confirming key and + // replaces the composition with the final text, so no actionable Enter + // reaches the page and nothing submits — natively, with no composition + // guard in `Form.Root` (see ./compositionSubmit.test.tsx for why none + // is needed). + await focusOnEditor(); + await userEvent.keyboard("link me"); + await userEvent.keyboard("{Home}{Shift>}{End}{/Shift}"); + await userEvent.click(await waitForSelector(LINK_BUTTON_SELECTOR)); + const input = (await waitForSelector( + 'input[name="url"]', + )) as HTMLInputElement; + await userEvent.click(input); + + await browserCommands.imeComposition([ + { type: "setComposition", text: "example.co" }, + { type: "commit", text: "example.com" }, + ]); + + expect(input.value).toBe("example.com"); + expect( + document.querySelector(`${EDITOR_SELECTOR} a`), + "accepting a candidate must not commit the link", + ).toBeNull(); + + // Enter after the composition commits it as usual. + await userEvent.keyboard("{Enter}"); + await waitForSelector(`${EDITOR_SELECTOR} a`); + }, + ); +}); diff --git a/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx index c33f704dd2..1a9b460015 100644 --- a/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx +++ b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx @@ -22,7 +22,16 @@ beforeEach(async () => { await waitForSelector(EDITOR_SELECTOR); }); +// The android browser instance runs this suite too (see +// vite.config.browser.ts); a couple of tests use idioms that don't transfer: +const onAndroid = /android/i.test(navigator.userAgent); + describe("Check Keyboard Handlers' Behaviour", () => { + // Also covers the android instance: with a cross-block selection, + // prosemirror-view's Android keydown bail skips Enter handling and its own + // keypress handler cancels the browser default without doing anything — + // BlockNote's keypress interception (KeyboardShortcutsExtension) closes + // that hole. See also the cross-block case in mobile/androidEnter.test.tsx. test("Check Enter when selection is not empty", async () => { await focusOnEditor(); await insertHeading(1); @@ -42,7 +51,10 @@ describe("Check Keyboard Handlers' Behaviour", () => { await compareDocToSnapshot("enterSelectionNotEmpty"); }); - test("Check Enter preserves marks", async () => { + // Skipped on the android instance: drives selection with coordinate + // double-clicks, a mouse idiom that doesn't translate to touch emulation at + // phone width. + test.skipIf(onAndroid)("Check Enter preserves marks", async () => { await focusOnEditor(); await insertHeading(1); @@ -313,6 +325,12 @@ describe("Check Keyboard Handlers' Behaviour", () => { await insertParagraph(); await userEvent.keyboard("{ArrowUp}"); + // ArrowUp crosses from an unnested line into an indented one, so its + // goal-x lands near the last character's boundary — which side it falls + // on varies with subpixel text metrics (flaky on the mobile-emulated + // instances). The test is about Delete at the *end* of the block; make + // that position explicit. + await userEvent.keyboard("{End}"); await userEvent.keyboard("{Delete}"); await compareDocToSnapshot("deleteShallowerBlock"); diff --git a/tests/src/end-to-end/mobile/androidEnter.test.tsx b/tests/src/end-to-end/mobile/androidEnter.test.tsx new file mode 100644 index 0000000000..fff0542835 --- /dev/null +++ b/tests/src/end-to-end/mobile/androidEnter.test.tsx @@ -0,0 +1,206 @@ +import App from "@examples/01-basic/testing/src/App"; +import { describe, expect, test, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { userEvent } from "../../utils/context.js"; +import { + BLOCK_CONTAINER_SELECTOR, + EDITOR_SELECTOR, +} from "../../utils/const.js"; +import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; + +// Runs in the "android" browser instance (Android UA + touch emulation at +// context level — see vite.config.browser.ts), which makes prosemirror-view +// take its Android code path: Enter keydowns are ignored there, and PM's own +// fallback — parsing the native DOM change — misparses BlockNote's nested +// block DOM and corrupts the document (TypeCellOS/BlockNote#3001: Enter +// inserting a space, doing nothing, or breaking tables). BlockNote +// intercepts both delivery routes instead (see KeyboardShortcutsExtension): +// `keypress` for hardware/synthetic keyboards, `beforeinput` for the IME. +// The tests below pin one route each. +describe("Enter on Android", () => { + test("keyboard-delivered Enter (keydown + keypress) splits the block", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await userEvent.keyboard("First line"); + + const blocksBefore = document.querySelectorAll( + BLOCK_CONTAINER_SELECTOR, + ).length; + const textBefore = document.querySelector(EDITOR_SELECTOR)!.textContent; + + await userEvent.keyboard("{Enter}"); + + await vi.waitFor(() => { + const blocks = document.querySelectorAll(BLOCK_CONTAINER_SELECTOR).length; + if (blocks !== blocksBefore + 1) { + throw new Error( + `Enter did not split the block (blocks ${blocksBefore} -> ${blocks})`, + ); + } + }); + // The classic #3001 misbehavior inserts a space or mangles text instead. + expect(document.querySelector(EDITOR_SELECTOR)!.textContent).toBe( + textBefore, + ); + + await userEvent.keyboard("Second line"); + await vi.waitFor(() => { + if ( + !document + .querySelector(EDITOR_SELECTOR)! + .textContent!.includes("Second line") + ) { + throw new Error("typing after Enter did not land in the new block"); + } + }); + }); + + // The IME path itself: real soft keyboards deliver Enter as keyCode 229 + + // `beforeinput: insertParagraph` with NO keypress, so the keypress + // interception (which covers hardware/synthetic keyboards, above) never + // runs. No automated input layer produces that exact trusted sequence — a + // synthetic InputEvent reaches prosemirror's handleDOMEvents all the same, + // so this pins the `beforeinput` interception the way IMEs actually invoke + // it. (Without the interception a synthetic event simply does nothing, so + // this fails red without the fix.) + test.skipIf(!/android/i.test(navigator.userAgent))( + "IME-delivered Enter (beforeinput, no keypress) splits the block", + async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await userEvent.keyboard("Ime line"); + const blocksBefore = document.querySelectorAll( + BLOCK_CONTAINER_SELECTOR, + ).length; + + document.querySelector(EDITOR_SELECTOR)!.dispatchEvent( + new InputEvent("beforeinput", { + inputType: "insertParagraph", + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor(() => { + const blocks = document.querySelectorAll( + BLOCK_CONTAINER_SELECTOR, + ).length; + if (blocks !== blocksBefore + 1) { + throw new Error( + `beforeinput Enter did not split (blocks ${blocksBefore} -> ${blocks})`, + ); + } + }); + expect(document.querySelector(EDITOR_SELECTOR)!.textContent).toBe( + "Ime line", + ); + }, + ); + + // With a NON-EMPTY cross-block selection, an Enter keydown+keypress pair + // (hardware or synthetic keyboard) used to be a silent no-op on Android: + // prosemirror-view's Android keydown bail skips Enter handling, and its own + // keypress handler then cancels the browser default for cross-parent + // selections without doing anything. BlockNote's `handleKeyPress` + // interception routes it through the keymap chain instead. The hole (and + // this test) is Android-only: everywhere else the keymap already handles + // Enter at keydown, so the keypress branch never matters — and on the + // iOS-emulated instance the setup itself is unreliable (typing after a + // settled Enter lands back in the previous block, a webkit-on-Linux + // emulation artifact the real-device suite doesn't show). + const onAndroid = /android/i.test(navigator.userAgent); + test.skipIf(!onAndroid)( + "Enter with a cross-block selection deletes it and splits", + async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await userEvent.keyboard("First line"); + await userEvent.keyboard("{Enter}"); + // The split can settle asynchronously (iOS path); typing must land in the + // new block before the selection below can target both paragraphs. + await vi.waitFor(() => { + if ( + !Array.from(document.querySelectorAll(`${EDITOR_SELECTOR} p`)).some( + (el) => el.textContent === "", + ) + ) { + throw new Error("Enter split not settled"); + } + }); + await userEvent.keyboard("Second line"); + await vi.waitFor(() => { + const texts = Array.from( + document.querySelectorAll(`${EDITOR_SELECTOR} p`), + ).map((el) => el.textContent); + if (!texts.includes("First line") || !texts.includes("Second line")) { + throw new Error(`paragraphs not settled: ${JSON.stringify(texts)}`); + } + }); + + // Select from mid-first-line to mid-second-line via a DOM range — + // arrow-key selection maps goal columns differently per engine, while + // ProseMirror syncs a programmatic range from `selectionchange` on all of + // them. Selects "ne" + "Sec" across the block boundary. + function textPosition( + paragraphText: string, + offset: number, + ): [Text, number] { + const paragraph = Array.from( + document.querySelectorAll(`${EDITOR_SELECTOR} p`), + ).find((el) => el.textContent === paragraphText); + if (!paragraph) { + throw new Error( + `paragraph ${JSON.stringify(paragraphText)} not found`, + ); + } + const walker = document.createTreeWalker( + paragraph, + NodeFilter.SHOW_TEXT, + ); + let consumed = 0; + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + const length = n.textContent!.length; + if (offset <= consumed + length) { + return [n as Text, offset - consumed]; + } + consumed += length; + } + throw new Error( + `offset ${offset} beyond ${JSON.stringify(paragraphText)}`, + ); + } + await vi.waitFor(() => { + const range = document.createRange(); + range.setStart(...textPosition("First line", "First li".length)); + range.setEnd(...textPosition("Second line", "Sec".length)); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + if (selection.isCollapsed) { + throw new Error("cross-block selection did not apply"); + } + }); + + await userEvent.keyboard("{Enter}"); + + // The selected span ("ne" + "Sec") is deleted and the remainder split + // across two blocks: "First li" + "ond line". + await vi.waitFor(() => { + const text = document.querySelector(EDITOR_SELECTOR)!.textContent!; + if (text.includes("First line")) { + throw new Error(`Enter did not delete the selection: ${text}`); + } + if (!text.includes("First li") || !text.includes("ond line")) { + throw new Error(`unexpected text after Enter: ${text}`); + } + }); + expect( + document.querySelectorAll(BLOCK_CONTAINER_SELECTOR).length, + ).toBeGreaterThanOrEqual(2); + }, + ); +}); diff --git a/tests/src/end-to-end/mobile/linkSubmit.test.tsx b/tests/src/end-to-end/mobile/linkSubmit.test.tsx new file mode 100644 index 0000000000..36418b731e --- /dev/null +++ b/tests/src/end-to-end/mobile/linkSubmit.test.tsx @@ -0,0 +1,131 @@ +import App from "@examples/03-ui-components/14-mobile-formatting-toolbar/src/App"; +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { page, userEvent } from "../../utils/context.js"; +import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; +import { waitForSelector } from "../../utils/editor.js"; +import { ensureTouchEmulation } from "../../utils/ensureTouchEmulation.js"; + +const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar"; + +// Submitting the link popover from an editor that is *not* the last on the +// page. Reported from a device: the link was never created and focus jumped +// to the second editor instead. +// +// Coverage limit worth knowing: the device-only half of that bug is which +// action Android's IME assigns to the Enter key. Being inside a real +// is what makes it offer a submitting action instead of "Next" (advance +// focus, no key event at all) — confirmed on a device, where the popover +// commits from the first editor with no `enterkeyhint` hinting involved. +// +// No automated environment we have can exercise that choice: emulation always +// dispatches a real Enter, and on BrowserStack no input channel reaches the +// on-screen keyboard (see tests/device/README.md). What a test *can* hold onto +// is that submission works without a key event at all, which is the second +// test below; the IME's choice itself stays a release-checklist item. + +beforeEach(async () => { + ensureTouchEmulation(); + await page.viewport(393, 727); +}); + +afterEach(async () => { + await page.viewport(393, 727); +}); + +describe("Submitting the link popover", () => { + test("creates the link in its own editor and keeps focus there", async () => { + await render(); + await vi.waitFor(() => { + if (document.querySelectorAll(EDITOR_SELECTOR).length < 2) { + throw new Error("expected the example's two editors"); + } + }); + const [first, second] = + document.querySelectorAll(EDITOR_SELECTOR); + + await userEvent.click(first.querySelector("p")!); + await userEvent.keyboard( + "{Home}{Shift>}{ArrowRight}{ArrowRight}{ArrowRight}{/Shift}", + ); + await page.viewport(393, 427); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + + await userEvent.click( + await waitForSelector( + `${MOBILE_TOOLBAR_SELECTOR} ${LINK_BUTTON_SELECTOR}`, + ), + ); + const input = (await waitForSelector( + 'input[name="url"]', + )) as HTMLInputElement; + + await userEvent.click(input); + await userEvent.keyboard("example.com{Enter}"); + + await vi.waitFor(() => { + if (!first.querySelector('a[href="https://example.com"]')) { + throw new Error( + "link was not created in the editor it was opened from", + ); + } + }); + expect(second.querySelector('a[href="https://example.com"]')).toBeNull(); + + // Focus must not have escaped into the other editor. + expect(document.activeElement?.closest(EDITOR_SELECTOR)).not.toBe(second); + }); + + // The path a mobile IME actually takes. When its action key means "submit", + // the browser submits the form — it does not necessarily deliver an Enter + // keydown, so a popover that only listens for that key has no way to + // commit. Driving the form's own submit is how that arrives, and it is the + // part of the device-only bug a test can reproduce: without a real + // wired to a submit handler, nothing happens at all. + test("submitting the form creates the link, without any key event", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + const [first] = document.querySelectorAll(EDITOR_SELECTOR); + + await userEvent.click(first.querySelector("p")!); + await userEvent.keyboard( + "{Home}{Shift>}{ArrowRight}{ArrowRight}{ArrowRight}{/Shift}", + ); + await page.viewport(393, 427); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + await userEvent.click( + await waitForSelector( + `${MOBILE_TOOLBAR_SELECTOR} ${LINK_BUTTON_SELECTOR}`, + ), + ); + const input = (await waitForSelector( + 'input[name="url"]', + )) as HTMLInputElement; + await userEvent.click(input); + await userEvent.keyboard("example.com"); + + const form = input.closest("form"); + expect( + form, + "the popover must be a real , or the browser has no way to " + + "submit it when a mobile IME's action key asks it to", + ).not.toBeNull(); + + // No Enter anywhere: this is the browser submitting the form itself. + form!.requestSubmit(); + + await vi.waitFor(() => { + if (!first.querySelector('a[href="https://example.com"]')) { + throw new Error("submitting the form did not create the link"); + } + }); + }); +}); diff --git a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx new file mode 100644 index 0000000000..11c86e5027 --- /dev/null +++ b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx @@ -0,0 +1,200 @@ +import App from "@examples/01-basic/testing/src/App"; +import { afterEach, beforeEach, describe, test, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { page, userEvent } from "../../utils/context.js"; +import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; +import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; +import { ensureTouchEmulation } from "../../utils/ensureTouchEmulation.js"; + +const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar"; +const LINK_POPOVER_SELECTOR = ".bn-form-popover"; + +// Runs in the "android" browser instance (Android UA + touch emulation at +// context level — see vite.config.browser.ts), so `isTouchDevice()` is +// genuinely true. The on-screen keyboard is emulated by resizing the +// viewport: `useVirtualKeyboard` treats a >150px height drop as the keyboard +// opening — which is exactly how a real keyboard manifests with +// `interactive-widget=resizes-content`. The extra ±60px step mimics Gboard +// showing its suggestion strip when focus moves into an input: the resize +// that used to make Mantine's `hideDetached` hide the link popover, blurring +// its focused input and collapsing the keyboard, toolbar, and popover (the +// Android Chrome bug behind PR #2982). +const VIEWPORT_WIDTH = 393; +const KEYBOARD_CLOSED = 727; +const KEYBOARD_OPEN = 427; +const KEYBOARD_OPEN_WITH_SUGGESTION_STRIP = 367; + +// Lets a viewport resize propagate: the resize event, the floating-ui +// autoUpdate pass it triggers, and React's commit each take a frame. +async function settleFrames(count = 3) { + for (let i = 0; i < count; i++) { + await new Promise(requestAnimationFrame); + } +} + +function activeUrlInput() { + const active = document.activeElement; + return active instanceof HTMLInputElement && active.name === "url" + ? active + : undefined; +} + +beforeEach(async () => { + ensureTouchEmulation(); + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_CLOSED); + await render(); + await waitForSelector(EDITOR_SELECTOR); +}); + +afterEach(async () => { + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_CLOSED); +}); + +describe("Mobile formatting toolbar", () => { + test("shows while the virtual keyboard is open and hides when it closes", async () => { + await focusOnEditor(); + await userEvent.keyboard("Mobile toolbar"); + + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_OPEN); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_CLOSED); + await vi.waitFor(() => { + if (document.querySelector(MOBILE_TOOLBAR_SELECTOR)) { + throw new Error( + "mobile toolbar still visible after the keyboard closed", + ); + } + }); + }); + + test("link popover holds focus through keyboard resizes and creates the link", async () => { + await focusOnEditor(); + await userEvent.keyboard("Link target"); + await userEvent.keyboard("{Shift>}{Home}{/Shift}"); + + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_OPEN); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + + await userEvent.click( + await waitForSelector( + `${MOBILE_TOOLBAR_SELECTOR} ${LINK_BUTTON_SELECTOR}`, + ), + ); + + // The URL input autofocuses when the popover opens. + await vi.waitFor(() => { + if (!activeUrlInput()) { + throw new Error("URL input did not receive focus on popover open"); + } + }); + + // iOS Safari auto-zooms the page when an input with a computed font-size + // under 16px takes focus, and that zoom perturbs the visual viewport the + // toolbar positions itself from. Emulation can't reproduce the zoom + // itself (it's device behaviour, not engine behaviour — the real-device + // suite asserts visualViewport.scale directly), so this guards the CSS + // contract that prevents it. + { + const fontSize = parseFloat(getComputedStyle(activeUrlInput()!).fontSize); + if (fontSize < 16) { + throw new Error( + `URL input font-size is ${fontSize}px; iOS Safari auto-zooms below ` + + `16px (see the pointer:coarse rule in blocknoteStyles.css)`, + ); + } + } + + // Focusing an input makes the keyboard show its suggestion strip, then + // settle back. The focused input must survive both resizes. + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_OPEN_WITH_SUGGESTION_STRIP); + await settleFrames(); + if (!activeUrlInput()) { + throw new Error("URL input lost focus when the suggestion strip resized"); + } + + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_OPEN); + await settleFrames(); + if (!activeUrlInput()) { + throw new Error( + "URL input lost focus when the suggestion strip resize settled", + ); + } + + await userEvent.keyboard("example.com"); + await userEvent.keyboard("{Enter}"); + + await waitForSelector(`${EDITOR_SELECTOR} a[href="https://example.com"]`); + + // Submitting closes the popover but leaves the toolbar up: on mobile the + // toolbar stays mounted (unlike desktop, which unmounts it and the popover + // with it), so the popover must close itself — the lingering popover + // otherwise covers the toolbar and swallows taps on its buttons. + await vi.waitFor(() => { + if (document.querySelector(LINK_POPOVER_SELECTOR)) { + throw new Error("link popover still open after submitting"); + } + if (!document.querySelector(MOBILE_TOOLBAR_SELECTOR)) { + throw new Error("mobile toolbar disappeared after submitting a link"); + } + }); + + // Reopening the popover with the whole link selected must pre-fill its + // URL: `getSelectedLinkUrl` scans the selected range for the link mark, + // since a probe at a single boundary position misses it — `marks()` + // excludes a link at its left edge, and browsers disagree by a position + // on where a selection over a link starts. + await userEvent.keyboard("{Shift>}{Home}{/Shift}"); + await userEvent.click( + await waitForSelector( + `${MOBILE_TOOLBAR_SELECTOR} ${LINK_BUTTON_SELECTOR}`, + ), + ); + await vi.waitFor(() => { + const input = activeUrlInput(); + if (input?.value !== "https://example.com") { + throw new Error( + `URL input not pre-filled for a fully selected link (value: ${JSON.stringify(input?.value)})`, + ); + } + }); + }); + + // Closing the popover from its trigger must hand focus back to the editor: + // on a real device, focus resting on the toolbar button closes the + // on-screen keyboard (a button can't take text input) and the whole + // editing session collapses with it. + test("toggling the link popover closed returns focus to the editor", async () => { + await focusOnEditor(); + await userEvent.keyboard("Link target"); + await userEvent.keyboard("{Shift>}{Home}{/Shift}"); + + await page.viewport(VIEWPORT_WIDTH, KEYBOARD_OPEN); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + const linkButton = await waitForSelector( + `${MOBILE_TOOLBAR_SELECTOR} ${LINK_BUTTON_SELECTOR}`, + ); + + await userEvent.click(linkButton); + await vi.waitFor(() => { + if (!(document.activeElement instanceof HTMLInputElement)) { + throw new Error("URL input did not receive focus on popover open"); + } + }); + + await userEvent.click(linkButton); + await vi.waitFor(() => { + if (document.querySelector('input[name="url"]')) { + throw new Error("popover did not close on trigger toggle"); + } + if (!document.activeElement?.closest(EDITOR_SELECTOR)) { + throw new Error( + `focus did not return to the editor (active: ${String( + document.activeElement?.className, + ).slice(0, 40)})`, + ); + } + }); + }); +}); diff --git a/tests/src/end-to-end/mobile/popoverScroll.test.tsx b/tests/src/end-to-end/mobile/popoverScroll.test.tsx new file mode 100644 index 0000000000..f77703be48 --- /dev/null +++ b/tests/src/end-to-end/mobile/popoverScroll.test.tsx @@ -0,0 +1,86 @@ +import App from "@examples/03-ui-components/14-mobile-formatting-toolbar/src/App"; +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { page, userEvent } from "../../utils/context.js"; +import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; +import { waitForSelector } from "../../utils/editor.js"; +import { ensureTouchEmulation } from "../../utils/ensureTouchEmulation.js"; + +const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar"; + +// Uses the mobile-formatting-toolbar example because it is a realistic page: +// long static text with editors partway down, and two of them. Opening a +// toolbar popover there used to reset the page scroll to the top, taking the +// block being edited off screen entirely — the popover's input autofocused +// while floating-ui had not positioned the popover yet, so the browser's +// scroll-into-view chased it to its pre-positioned spot. + +beforeEach(async () => { + ensureTouchEmulation(); + await page.viewport(393, 727); +}); + +afterEach(async () => { + await page.viewport(393, 727); +}); + +describe("Opening a toolbar popover", () => { + test("does not scroll the page away from the block being edited", async () => { + await render(); + await vi.waitFor(() => { + if (document.querySelectorAll(EDITOR_SELECTOR).length < 2) { + throw new Error("expected the example's two editors"); + } + }); + + const editor = document.querySelectorAll(EDITOR_SELECTOR)[0]; + await userEvent.click(editor.querySelector("p")!); + await userEvent.keyboard( + "{Home}{Shift>}{ArrowRight}{ArrowRight}{ArrowRight}{/Shift}", + ); + + // "Keyboard opens". + await page.viewport(393, 427); + await waitForSelector(MOBILE_TOOLBAR_SELECTOR); + + // The example defaults to the pinned scroll-container layout, where that + // element scrolls rather than the document. + const scroller = + document.querySelector(".bn-scroll-container") ?? + document.scrollingElement!; + const scrollBefore = scroller.scrollTop; + const editorTopBefore = editor.getBoundingClientRect().top; + // The regression only shows when the page is actually scrolled. + expect(scrollBefore).toBeGreaterThan(0); + + await userEvent.click( + await waitForSelector( + `${MOBILE_TOOLBAR_SELECTOR} ${LINK_BUTTON_SELECTOR}`, + ), + ); + await vi.waitFor(() => { + if (!document.querySelector('input[name="url"]')) { + throw new Error("link popover did not open"); + } + }); + // Let any scroll-into-view settle before measuring. + await new Promise((resolve) => setTimeout(resolve, 400)); + + expect( + Math.abs(scroller.scrollTop - scrollBefore), + `opening the popover scrolled the page (${scrollBefore} -> ${scroller.scrollTop})`, + ).toBeLessThanOrEqual(2); + expect( + Math.abs(editor.getBoundingClientRect().top - editorTopBefore), + "the edited editor moved on screen when the popover opened", + ).toBeLessThanOrEqual(2); + }); +}); diff --git a/tests/src/utils/ensureTouchEmulation.ts b/tests/src/utils/ensureTouchEmulation.ts new file mode 100644 index 0000000000..eeeddaf920 --- /dev/null +++ b/tests/src/utils/ensureTouchEmulation.ts @@ -0,0 +1,28 @@ +/** + * Asserts that the android instance's touch emulation is still in effect. + * + * The emulation itself is configured per instance in vite.config.browser.ts + * (the playwright provider's contextOptions) — this cannot re-create it, only + * detect its loss. Loss has one known cause: Playwright's element-screenshot + * path for **iframe elements** (what `screenshotFull` captures for export + * previews) rewrites the device-metrics override and permanently drops the + * context's touch emulation — `navigator.maxTouchPoints` becomes 0 for every + * later test file. The android instance therefore keeps such suites out of + * its include; touch-dependent tests call this in `beforeEach` so that if the + * include ever regresses, the run fails naming the cause instead of silently + * testing a desktop context that merely claims to be mobile. + */ +export function ensureTouchEmulation() { + if ( + navigator.maxTouchPoints === 0 || + !window.matchMedia("(pointer: coarse)").matches + ) { + throw new Error( + "Touch emulation has been dropped for this browser context. A " + + "previously run test file took an iframe-element screenshot " + + "(screenshotFull), which permanently disables the context's touch " + + "emulation — keep such suites out of the android instance's include " + + "in vite.config.browser.ts.", + ); + } +} diff --git a/tests/src/utils/imeComposition.ts b/tests/src/utils/imeComposition.ts new file mode 100644 index 0000000000..6579a10239 --- /dev/null +++ b/tests/src/utils/imeComposition.ts @@ -0,0 +1,69 @@ +import type { BrowserCommand } from "vite-plus/test/node"; + +/** + * One step of an emulated IME session. `setComposition` updates the active + * composition (starting one if none is active); `commit` finalizes it with + * the given text — pass different text than the last composition update to + * emulate an autocorrect-style replacement. + */ +export type ImeStep = + | { + type: "setComposition"; + text: string; + selectionStart?: number; + selectionEnd?: number; + /** + * With `replacementEnd`, the composition replaces this range of + * already-committed text instead of inserting at the caret — the shape + * of retroactive autocorrect (e.g. Gboard fixing the previous word when + * space is typed). Offsets are in the focused editable's text. + */ + replacementStart?: number; + replacementEnd?: number; + } + | { type: "commit"; text: string }; + +/** + * Browser-side signature of the {@link imeComposition} command (Vitest strips + * the Node-only context parameter — see positionalMouse.ts for the pattern). + */ +export type ImeCompositionCommand = (steps: ImeStep[]) => Promise; + +/** + * Drives Chromium's real IME composition pipeline over CDP + * (`Input.imeSetComposition` / `Input.insertText`): the browser produces the + * genuine `compositionstart/update/end` + `beforeinput: + * insertCompositionText` sequence with actual DOM mutation, targeting the + * focused element — the same events a mobile IME (Gboard, Samsung Keyboard) + * generates, which no synthetic `CompositionEvent` dispatch can reproduce + * (those are untrusted and never touch the DOM). Chromium-only. + */ +export const imeComposition: BrowserCommand<[steps: ImeStep[]]> = async ( + ctx, + steps, +) => { + const cdp = await ctx.context.newCDPSession(ctx.page); + try { + for (const step of steps) { + if (step.type === "setComposition") { + await cdp.send("Input.imeSetComposition", { + text: step.text, + selectionStart: step.selectionStart ?? step.text.length, + selectionEnd: step.selectionEnd ?? step.text.length, + ...(step.replacementEnd !== undefined + ? { + replacementStart: step.replacementStart ?? 0, + replacementEnd: step.replacementEnd, + } + : {}), + }); + } else { + await cdp.send("Input.insertText", { text: step.text }); + } + } + } finally { + await cdp.detach().catch(() => { + // Session already gone (e.g. page navigated) — nothing to clean up. + }); + } +}; diff --git a/tests/vite.config.browser.ts b/tests/vite.config.browser.ts index 21fb2a1e1b..b1d0553599 100644 --- a/tests/vite.config.browser.ts +++ b/tests/vite.config.browser.ts @@ -4,6 +4,7 @@ import * as path from "path"; import { defineConfig, type UserConfig } from "vite-plus"; import { playwright } from "vite-plus/test/browser/providers/playwright"; import { positionalMouse } from "./src/utils/positionalMouse.js"; +import { imeComposition } from "./src/utils/imeComposition.js"; // 1280x720 matches the old Playwright defaults so visual baselines have room. // Used as the playwright context viewport for every browser instance. @@ -88,6 +89,7 @@ export default defineConfig( "./src/end-to-end/**/*.test.tsx", "../packages/*/src/**/*.browser.test.{ts,tsx}", ], + setupFiles: ["./vitestSetup.browser.ts"], // Running three browsers concurrently inside one Docker container already // saturates CPU; layering per-browser file parallelism on top causes @@ -139,7 +141,7 @@ export default defineConfig( // still show in the HTML report (errors + stack traces don't depend // on these shots), so disable them. See `e2e:report` to view. screenshotFailures: false, - commands: { positionalMouse }, + commands: { positionalMouse, imeComposition }, instances: [ { browser: "chromium", @@ -151,12 +153,64 @@ export default defineConfig( "--disable-dev-shm-usage", ], }, + // end-to-end/mobile runs only in the "android" instance below. + exclude: ["**/end-to-end/mobile/**"], }, { browser: "firefox", + exclude: ["**/end-to-end/mobile/**"], }, { browser: "webkit", + exclude: ["**/end-to-end/mobile/**"], + }, + { + // Android-emulated chromium: mobile-specific end-to-end tests. + // The context makes `isTouchDevice()` genuinely true and puts + // prosemirror-view on its Android code paths (it samples the + // user agent at module load), so the mobile tests need no + // platform stubs. See tests/src/end-to-end/mobile/. + browser: "chromium", + name: "android", + launchOptions: { + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + ], + }, + provider: playwright({ + contextOptions: { + viewport: { width: 393, height: 727 }, + userAgent: + "Mozilla/5.0 (Linux; Android 12; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Mobile Safari/537.36", + isMobile: true, + hasTouch: true, + }, + }), + // Mobile-specific tests plus the screenshot-free behavioural + // suites where Android genuinely differs (IME key handling, + // suggestion menus). Those only pass under this emulation with + // the Enter fix in this change — before it, every test that + // presses Enter to make a second block failed here. + // + // Keep iframe-screenshotting suites (the exporters' + // `screenshotFull` previews) out permanently: Playwright's + // element-screenshot path for iframe elements drops the + // context's touch emulation for later files (see + // utils/ensureTouchEmulation.ts). Individual tests that drive + // selection or resizing with positional mouse drags carry + // `skipIf(onAndroid)` guards. Not included: indentation (drives + // the desktop floating toolbar, clipped at phone width). + include: [ + "./src/end-to-end/mobile/**/*.test.tsx", + // The popover form-submission suites are this instance's + // reason to exist — the bugs they guard were Android bugs. + "./src/end-to-end/form/**/*.test.tsx", + "./src/end-to-end/keyboardhandlers/**/*.test.tsx", + "./src/end-to-end/emojipicker/**/*.test.tsx", + "./src/end-to-end/copypaste/**/*.test.tsx", + ], }, ], },