diff --git a/.github/workflows/just_checks.yml b/.github/workflows/just_checks.yml index ff89b3da53..322f33de04 100644 --- a/.github/workflows/just_checks.yml +++ b/.github/workflows/just_checks.yml @@ -23,6 +23,7 @@ jobs: test-rust, test-harperjs, test-vscode, + test-lintframework, test-chrome-plugin, test-firefox-plugin, test-obsidian, diff --git a/justfile b/justfile index 66c6bbc9a1..1b22cf7d09 100644 --- a/justfile +++ b/justfile @@ -124,6 +124,16 @@ test-obsidian: build-obsidian pnpm playwright install pnpm test +alias test-lint-framework := test-lintframework +test-lintframework: build-lint-framework + #!/usr/bin/env bash + set -eo pipefail + + pnpm install + cd "{{justfile_directory()}}/packages/lint-framework" + pnpm playwright install + pnpm test + alias dev-wordpress := dev-wp dev-wp: build-harperjs #!/usr/bin/env bash @@ -496,7 +506,7 @@ test-rust: cargo test -q # Test everything. -test: test-rust test-harperjs test-vscode test-obsidian test-chrome-plugin test-firefox-plugin +test: test-rust test-harperjs test-vscode test-obsidian test-lintframework test-chrome-plugin test-firefox-plugin # Use `harper-cli` to parse a provided file and print out the resulting tokens. parse file: diff --git a/packages/chrome-plugin/tests/testUtils.ts b/packages/chrome-plugin/tests/testUtils.ts index d265f0664a..a7af7437a0 100644 --- a/packages/chrome-plugin/tests/testUtils.ts +++ b/packages/chrome-plugin/tests/testUtils.ts @@ -8,6 +8,21 @@ type ScreenPoint = { y: number; }; +/** + * How long to let linting settle once highlights first appear. + * + * The follow-up pass over the final text is event-driven: `LintFramework` + * coalesces requests that arrive mid-lint and issues one as soon as the + * in-flight pass releases. This only has to cover that pass plus a render, + * which is why a modest interval is enough. + * + * It is deliberately not sized against the framework's one-second self-poll. + * This wait starts at an arbitrary phase relative to that timer, so it could + * not reliably cover it anyway -- and relying on the poll is the behaviour the + * coalescing fix exists to avoid. + */ +const LINT_SETTLE_MS = 1500; + export async function getBackground(context: BrowserContext) { return ( context.serviceWorkers()[0] ?? @@ -290,6 +305,14 @@ export async function testCanIgnoreSuggestion( // Ensure the test text produces only the spelling lint we intend to ignore. await expect(getHarperHighlights(page)).toHaveCount(1); + // That first highlight can come from a lint computed against a *prefix* of + // the text, because input events arriving mid-lint are coalesced into a + // follow-up pass. It looks identical on screen, but its context hash covers + // different trailing tokens, so ignoring it records a hash that never + // matches the real lint and the highlight returns. Wait for the follow-up + // pass to settle before acting on anything. + await page.waitForTimeout(LINT_SETTLE_MS); + // Open the popup for the highlight and click Ignore. const opened = await clickHarperHighlight(page); expect(opened).toBe(true); diff --git a/packages/lint-framework/package.json b/packages/lint-framework/package.json index 858a142581..514404dddb 100644 --- a/packages/lint-framework/package.json +++ b/packages/lint-framework/package.json @@ -18,7 +18,7 @@ "scripts": { "build": "tsc && vite build -l warn", "dev": "vite", - "test": "echo 'no tests'" + "test": "vitest run" }, "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", @@ -35,6 +35,10 @@ "type-fest": "^4.37.0", "typescript": "catalog:", "vite": "^6.1.0", - "vite-plugin-dts": "^4.5.0" + "vite-plugin-dts": "^4.5.0", + "@vitest/browser": "^4.0.16", + "@vitest/browser-playwright": "^4.0.16", + "vitest": "^4.0.16", + "@playwright/test": "1.60.0" } } diff --git a/packages/lint-framework/src/lint/LintFramework.test.ts b/packages/lint-framework/src/lint/LintFramework.test.ts new file mode 100644 index 0000000000..725c781d9d --- /dev/null +++ b/packages/lint-framework/src/lint/LintFramework.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import LintFramework from './LintFramework'; +import type { UnpackedLintGroups } from './unpackLint'; + +/** + * `LintFramework` polls itself every second to cover editors that fail to emit + * events, so that poll can supply a lint the framework never scheduled itself. + * Every wait here is bounded by wall-clock time well below one second, and the + * tests additionally assert on *when* a follow-up arrived. A lint produced by + * the poll is a second late by construction and cannot satisfy that, however + * slowly the machine happens to be running. + * + * Frame counting was tried first and is not good enough: `requestAnimationFrame` + * is throttled when a page is backgrounded and stretches under load, so a fixed + * number of frames can silently exceed the poll interval. That turns a broken + * scheduler into a passing test, which is worse than a flake. + * + * This value MUST stay below the poll interval in `LintFramework`'s + * constructor. Raising it above one second is what would let the poll satisfy + * these tests, quietly restoring the failure mode described above. + */ +const SETTLE_BUDGET_MS = 400; + +/** Let queued microtasks and one animation frame run. */ +async function tick() { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await Promise.resolve(); +} + +/** Wait for `count` lint requests, giving up well before the one-second poll. */ +async function waitForCalls(calls: string[], count: number) { + const deadline = performance.now() + SETTLE_BUDGET_MS; + while (calls.length < count && performance.now() < deadline) { + await tick(); + } +} + +/** Give the framework room to act when we are asserting that it does not. */ +async function quietPeriod() { + const deadline = performance.now() + SETTLE_BUDGET_MS / 2; + while (performance.now() < deadline) { + await tick(); + } +} + +/** + * A lint provider whose responses are resolved by hand, so a test can hold a + * lint "in flight" and decide exactly when it completes. + */ +function deferredProvider() { + const calls: string[] = []; + /** When each entry in `calls` was requested, for asserting promptness. */ + const callTimes: number[] = []; + const pending: { resolve: () => void; reject: (reason: Error) => void }[] = []; + + const provider = (text: string): Promise => { + calls.push(text); + callTimes.push(performance.now()); + return new Promise((resolve, reject) => { + pending.push({ resolve: () => resolve({}), reject }); + }); + }; + + async function settleOldest(outcome: 'resolve' | 'reject') { + const next = pending.shift(); + if (next == null) { + throw new Error('No outstanding lint request to settle.'); + } + + if (outcome === 'resolve') { + next.resolve(); + } else { + next.reject(new Error(PROVIDER_FAILURE)); + } + + await tick(); + } + + return { + provider, + calls, + callTimes, + /** Complete the oldest outstanding request. */ + resolveNext: () => settleOldest('resolve'), + /** Fail the oldest outstanding request. */ + rejectNext: () => settleOldest('reject'), + }; +} + +const PROVIDER_FAILURE = 'lint provider exploded'; + +/** + * `update()` calls `requestLintUpdate()` without awaiting it, so a rejected + * lint surfaces as an unhandled rejection rather than reaching a caller. That + * is existing behaviour and out of scope here; swallow the one failure this + * test provokes so it does not read as an unrelated error. + */ +function suppressProviderFailure(event: PromiseRejectionEvent) { + if (event.reason instanceof Error && event.reason.message === PROVIDER_FAILURE) { + event.preventDefault(); + } +} + +const targets: HTMLTextAreaElement[] = []; + +function makeTextarea(value: string): HTMLTextAreaElement { + const el = document.createElement('textarea'); + el.value = value; + el.style.width = '400px'; + el.style.height = '100px'; + document.body.appendChild(el); + targets.push(el); + return el; +} + +afterEach(() => { + for (const el of targets.splice(0)) { + el.remove(); + } +}); + +describe('LintFramework lint scheduling', () => { + it('re-lints with the final text as soon as the in-flight pass finishes', async () => { + const editor = makeTextarea('T'); + const { provider, calls, callTimes, resolveNext } = deferredProvider(); + const fw = new LintFramework(provider, {}); + + await fw.addTarget(editor); + await waitForCalls(calls, 1); + expect(calls).toEqual(['T']); + + // Further input arrives while the first lint is still in flight. The + // single-flight guard drops each of these. + editor.value = 'This is a mistaek.'; + fw.update(); + fw.update(); + fw.update(); + await quietPeriod(); + expect(calls).toEqual(['T']); + + const releasedAt = performance.now(); + await resolveNext(); + await waitForCalls(calls, 2); + + // Exactly one follow-up, seeing the text as it now stands. + expect(calls).toEqual(['T', 'This is a mistaek.']); + + // And issued off the back of the pass completing, not by the one-second + // poll -- which could not have produced it this quickly. + expect(callTimes[1] - releasedAt).toBeLessThan(SETTLE_BUDGET_MS); + }); + + it('still issues the queued follow-up when the in-flight pass rejects', async () => { + const editor = makeTextarea('T'); + const { provider, calls, callTimes, rejectNext } = deferredProvider(); + const fw = new LintFramework(provider, {}); + + try { + window.addEventListener('unhandledrejection', suppressProviderFailure); + + await fw.addTarget(editor); + await waitForCalls(calls, 1); + expect(calls).toEqual(['T']); + + // Input arrives mid-pass and is coalesced into a follow-up. + editor.value = 'This is a mistaek.'; + fw.update(); + await quietPeriod(); + expect(calls).toEqual(['T']); + + // The pass then fails. Releasing the guard is not enough on its own: the + // queued work has to be handed off too, or the input that arrived during + // a failed lint is silently forgotten until the one-second poll. + const releasedAt = performance.now(); + await rejectNext(); + await waitForCalls(calls, 2); + + expect(calls).toEqual(['T', 'This is a mistaek.']); + expect(callTimes[1] - releasedAt).toBeLessThan(SETTLE_BUDGET_MS); + } finally { + window.removeEventListener('unhandledrejection', suppressProviderFailure); + } + }); + + it('releases the in-flight guard when a lint request rejects', async () => { + const editor = makeTextarea('First.'); + const calls: string[] = []; + let failNext = true; + + const provider = async (text: string): Promise => { + calls.push(text); + if (failNext) { + failNext = false; + throw new Error(PROVIDER_FAILURE); + } + return {}; + }; + + const fw = new LintFramework(provider, {}); + + try { + window.addEventListener('unhandledrejection', suppressProviderFailure); + + await fw.addTarget(editor); + await waitForCalls(calls, 1); + expect(calls[0]).toBe('First.'); + + // A rejection must not leave the framework permanently wedged. + editor.value = 'Second.'; + fw.update(); + await waitForCalls(calls, 2); + + // Assert on order, not on an exact count. Ambient page events -- the + // window listeners cover scroll, resize and selectionchange -- can + // legitimately queue further passes, and a coalesced follow-up may add + // one more. What matters is that the failed pass was followed by one + // seeing the new text. + expect(calls.slice(0, 2)).toEqual(['First.', 'Second.']); + } finally { + window.removeEventListener('unhandledrejection', suppressProviderFailure); + } + }); +}); diff --git a/packages/lint-framework/src/lint/LintFramework.ts b/packages/lint-framework/src/lint/LintFramework.ts index 2ac3c67e0c..656bfc38db 100644 --- a/packages/lint-framework/src/lint/LintFramework.ts +++ b/packages/lint-framework/src/lint/LintFramework.ts @@ -48,6 +48,8 @@ export default class LintFramework { private targets: Set; private scrollableAncestors: Set; private lintRequested = false; + /** Set when a lint request arrives while one is already in flight. */ + private lintDirty = false; private renderRequested = false; private lintDelayTimer: number | null = null; private lastInputAt = 0; @@ -149,51 +151,68 @@ export default class LintFramework { // Avoid duplicate requests in the queue if (!immediate) { this.lintRequested = true; + // This pass reads the text as it is now, so it already covers every + // request made before it started. + this.lintDirty = false; } - const lintResults = await Promise.all( - this.onScreenTargets().map(async (target) => { - if (!document.contains(target)) { - this.targets.delete(target); - return { target: null as HTMLElement | null, lints: {} }; - } + try { + const lintResults = await Promise.all( + this.onScreenTargets().map(async (target) => { + if (!document.contains(target)) { + this.targets.delete(target); + return { target: null as HTMLElement | null, lints: {} }; + } - const { text, isCM, newLineIndices } = this.getTargetText(target); + const { text, isCM, newLineIndices } = this.getTargetText(target); - if (!text || text.length > 120000) { - return { target: null as HTMLElement | null, lints: {} }; - } + if (!text || text.length > 120000) { + return { target: null as HTMLElement | null, lints: {} }; + } - const language = getTargetLanguage(target); - let lintsBySource = await this.lintProvider(text, window.location.hostname, { - forceAllHeadings: isHeading(target), - language, - }); + const language = getTargetLanguage(target); + let lintsBySource = await this.lintProvider(text, window.location.hostname, { + forceAllHeadings: isHeading(target), + language, + }); - if (isCM) { - // We're about to modify a reference, so let's work on a copy. - lintsBySource = window.structuredClone(lintsBySource); + if (isCM) { + // We're about to modify a reference, so let's work on a copy. + lintsBySource = window.structuredClone(lintsBySource); - for (const lints of Object.values(lintsBySource)) { - for (const lint of lints) { - const offset_start = newLineIndices.findIndex((i) => i > lint.span.start); - const offset_end = newLineIndices.findIndex((i) => i > lint.span.end); + for (const lints of Object.values(lintsBySource)) { + for (const lint of lints) { + const offset_start = newLineIndices.findIndex((i) => i > lint.span.start); + const offset_end = newLineIndices.findIndex((i) => i > lint.span.end); - lint.span.start -= offset_start; - lint.span.end -= offset_end; + lint.span.start -= offset_start; + lint.span.end -= offset_end; + } } } - } - return { target: target as HTMLElement, lints: lintsBySource }; - }), - ); + return { target: target as HTMLElement, lints: lintsBySource }; + }), + ); - this.lastLints = lintResults.filter((r) => r.target != null) as any; - if (!immediate) { - this.lintRequested = false; + this.lastLints = lintResults.filter((r) => r.target != null) as any; + this.requestRender(); + } finally { + if (!immediate) { + this.lintRequested = false; + + // Must run after the flag above is cleared, or this call hits the + // same guard, marks the framework dirty again and is dropped. It + // lives in the `finally` so that a pass which threw still hands off + // the input that arrived while it was running. + if (this.lintDirty) { + this.lintDirty = false; + void this.requestLintUpdate(); + } + } } - this.requestRender(); + } else if (!immediate && this.lintRequested) { + this.lintDirty = true; } } diff --git a/packages/lint-framework/vite.config.ts b/packages/lint-framework/vite.config.ts index dbc681ea9a..9ce7746c1c 100644 --- a/packages/lint-framework/vite.config.ts +++ b/packages/lint-framework/vite.config.ts @@ -1,8 +1,19 @@ +/// +import { playwright } from '@vitest/browser-playwright'; import { resolve } from 'path'; import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; export default defineConfig({ + test: { + browser: { + provider: playwright(), + enabled: true, + headless: true, + screenshotFailures: false, + instances: [{ browser: 'chromium' }], + }, + }, build: { lib: { entry: resolve(__dirname, 'src/index.ts'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa3f8406e2..d2a6820ceb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -369,12 +369,21 @@ importers: specifier: ^2.1.1 version: 2.1.1 devDependencies: + '@playwright/test': + specifier: 1.60.0 + version: 1.60.0 '@types/chrome': specifier: 0.1.27 version: 0.1.27 '@types/virtual-dom': specifier: ^2.1.4 version: 2.1.4 + '@vitest/browser': + specifier: ^4.0.16 + version: 4.0.16(msw@2.7.3(@types/node@22.13.10)(typescript@5.9.3))(vite@6.3.5(@types/node@22.13.10)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.0))(vitest@4.0.16) + '@vitest/browser-playwright': + specifier: ^4.0.16 + version: 4.0.16(msw@2.7.3(@types/node@22.13.10)(typescript@5.9.3))(playwright@1.60.0)(vite@6.3.5(@types/node@22.13.10)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.0))(vitest@4.0.16) type-fest: specifier: ^4.37.0 version: 4.37.0 @@ -387,6 +396,9 @@ importers: vite-plugin-dts: specifier: ^4.5.0 version: 4.5.3(@types/node@22.13.10)(rollup@4.53.3)(typescript@5.9.3)(vite@6.3.5(@types/node@22.13.10)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.0)) + vitest: + specifier: ^4.0.16 + version: 4.0.16(@types/node@22.13.10)(@vitest/browser-playwright@4.0.16)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.32.0)(msw@2.7.3(@types/node@22.13.10)(typescript@5.9.3))(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.0) packages/obsidian-plugin: dependencies: @@ -593,7 +605,7 @@ importers: version: 5.43.12 svelte-check: specifier: ^4.1.5 - version: 4.3.4(picomatch@4.0.2)(svelte@5.43.12)(typescript@5.9.3) + version: 4.3.4(picomatch@4.0.3)(svelte@5.43.12)(typescript@5.9.3) tailwindcss: specifier: ^4.1.16 version: 4.2.4 @@ -1693,11 +1705,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} @@ -6793,9 +6805,6 @@ packages: es-module-lexer@0.10.5: resolution: {integrity: sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==} - es-module-lexer@1.6.0: - resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -12537,18 +12546,6 @@ packages: utf-8-validate: optional: true - ws@8.18.1: - resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} - 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 - ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -15930,10 +15927,10 @@ snapshots: '@rollup/pluginutils': 5.1.4(rollup@4.35.0) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.4.3(picomatch@4.0.2) + fdir: 6.4.3(picomatch@4.0.3) is-reference: 1.2.1 - magic-string: 0.30.17 - picomatch: 4.0.2 + magic-string: 0.30.21 + picomatch: 4.0.3 optionalDependencies: rollup: 4.35.0 @@ -20689,8 +20686,6 @@ snapshots: es-module-lexer@0.10.5: {} - es-module-lexer@1.6.0: {} - es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: @@ -21290,9 +21285,9 @@ snapshots: dependencies: pend: 1.2.0 - fdir@6.4.3(picomatch@4.0.2): + fdir@6.4.3(picomatch@4.0.3): optionalDependencies: - picomatch: 4.0.2 + picomatch: 4.0.3 fdir@6.4.4(picomatch@4.0.2): optionalDependencies: @@ -22960,7 +22955,7 @@ snapshots: whatwg-encoding: 2.0.0 whatwg-mimetype: 3.0.0 whatwg-url: 11.0.0 - ws: 8.18.1 + ws: 8.18.3 xml-name-validator: 4.0.0 transitivePeerDependencies: - bufferutil @@ -24972,7 +24967,7 @@ snapshots: debug: 4.4.0 devtools-protocol: 0.0.1367902 typed-query-selector: 2.12.0 - ws: 8.18.1 + ws: 8.18.3 transitivePeerDependencies: - bare-buffer - bufferutil @@ -24986,7 +24981,7 @@ snapshots: debug: 4.4.0 devtools-protocol: 0.0.1413902 typed-query-selector: 2.12.0 - ws: 8.18.1 + ws: 8.18.3 transitivePeerDependencies: - bare-buffer - bufferutil @@ -26242,18 +26237,6 @@ snapshots: brace: 0.11.1 sirv-cli: 1.0.14 - svelte-check@4.3.4(picomatch@4.0.2)(svelte@5.43.12)(typescript@5.9.3): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - chokidar: 4.0.3 - fdir: 6.4.4(picomatch@4.0.2) - picocolors: 1.1.1 - sade: 1.8.1 - svelte: 5.43.12 - typescript: 5.9.3 - transitivePeerDependencies: - - picomatch - svelte-check@4.3.4(picomatch@4.0.3)(svelte@5.43.12)(typescript@5.6.3): dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -27005,7 +26988,7 @@ snapshots: open: 10.1.0 perfect-debounce: 1.0.0 picocolors: 1.1.1 - sirv: 3.0.1 + sirv: 3.0.2 vite: 6.3.5(@types/node@22.13.10)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.7.0) transitivePeerDependencies: - rollup @@ -27287,7 +27270,7 @@ snapshots: sockjs: 0.3.24 spdy: 4.0.2 webpack-dev-middleware: 5.3.4(webpack@5.98.0) - ws: 8.18.1 + ws: 8.18.3 optionalDependencies: webpack: 5.98.0(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack-dev-server@4.15.2)(webpack@5.98.0) @@ -27316,7 +27299,7 @@ snapshots: browserslist: 4.24.4 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.1 - es-module-lexer: 1.6.0 + es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 @@ -27591,8 +27574,6 @@ snapshots: ws@8.13.0: {} - ws@8.18.1: {} - ws@8.18.3: {} x-is-array@0.1.0: {} @@ -27631,7 +27612,7 @@ snapshots: y-protocols: 1.0.6(yjs@13.6.24) yjs: 13.6.24 optionalDependencies: - ws: 8.18.1 + ws: 8.18.3 transitivePeerDependencies: - bufferutil - supports-color