diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd2739ba..5edab4e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,37 @@ jobs: - name: Audit discovered tests run: pnpm test:audit + workspace-scroll-regression: + name: Workspace scroll regression + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 9.0.0 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 20.19.0 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run workspace scroll regression + run: pnpm test:e2e:workspace + build: name: Build all packages runs-on: ubuntu-24.04 diff --git a/.gitignore b/.gitignore index 88290af5..0a8ac74b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ test-results/ dist/ build/ .next/ +.next-e2e/ out/ *.tsbuildinfo @@ -125,6 +126,7 @@ scripts/jest/ **/__mocks__/ **/*.test.* **/*.spec.* +!/e2e/**/*.spec.ts !packages/backend/src/services/certificate-detector-seal.service.test.ts !packages/backend/src/services/writing-detector-config.test.ts **/jest.config.js diff --git a/e2e/document-workspace-scroll.spec.ts b/e2e/document-workspace-scroll.spec.ts new file mode 100644 index 00000000..834f1e30 --- /dev/null +++ b/e2e/document-workspace-scroll.spec.ts @@ -0,0 +1,371 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const DOCUMENT_ID = 'demo-doc-workspace-scroll-e2e'; +const DOCUMENT_TITLE = 'Workspace scroll regression'; +const DOCUMENT_STORAGE_KEY = `humanly:demo-workspace:document:${DOCUMENT_ID}`; +const RULES_DISMISSAL_KEY = `humanly:writing-rules-dismissed:personal:${DOCUMENT_ID}`; +const PDF_PATH = '/__e2e__/workspace-scroll.pdf'; + +const DESKTOP_VIEWPORTS = [ + { width: 1440, height: 900 }, + { width: 1024, height: 768 }, +] as const; + +function buildBlankPdf(pageCount: number): Buffer { + const objects: string[] = []; + const pageReferences: string[] = []; + + objects[1] = '<< /Type /Catalog /Pages 2 0 R >>'; + for (let index = 0; index < pageCount; index += 1) { + const pageObject = 3 + index * 2; + const contentObject = pageObject + 1; + pageReferences.push(`${pageObject} 0 R`); + objects[pageObject] = + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ` + + `/Resources << >> /Contents ${contentObject} 0 R >>`; + objects[contentObject] = '<< /Length 4 >>\nstream\nq\nQ\nendstream'; + } + objects[2] = + `<< /Type /Pages /Kids [${pageReferences.join(' ')}] ` + + `/Count ${pageCount} >>`; + + let source = '%PDF-1.4\n% Humanly workspace regression fixture\n'; + const offsets = new Array(objects.length).fill(0); + for (let objectNumber = 1; objectNumber < objects.length; objectNumber += 1) { + offsets[objectNumber] = Buffer.byteLength(source, 'ascii'); + source += `${objectNumber} 0 obj\n${objects[objectNumber]}\nendobj\n`; + } + + const xrefOffset = Buffer.byteLength(source, 'ascii'); + source += `xref\n0 ${objects.length}\n`; + source += '0000000000 65535 f \n'; + for (let objectNumber = 1; objectNumber < objects.length; objectNumber += 1) { + source += `${String(offsets[objectNumber]).padStart(10, '0')} 00000 n \n`; + } + source += + `trailer\n<< /Size ${objects.length} /Root 1 0 R >>\n` + + `startxref\n${xrefOffset}\n%%EOF\n`; + + return Buffer.from(source, 'ascii'); +} + +function buildLexicalContent(paragraphCount: number) { + const paragraphs = Array.from({ length: paragraphCount }, (_, index) => ({ + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: + `Paragraph ${index + 1}. This browser-local fixture is deliberately ` + + 'long enough to require internal editor scrolling.', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + })); + + return { + root: { + children: paragraphs, + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }; +} + +function buildDemoRecord(pdfUrl: string) { + const now = new Date().toISOString(); + const content = buildLexicalContent(90); + const plainText = content.root.children + .map((paragraph) => paragraph.children[0].text) + .join('\n'); + + return { + document: { + id: DOCUMENT_ID, + userId: 'demo-user-local', + title: DOCUMENT_TITLE, + description: null, + content, + plainText, + status: 'draft', + version: 1, + wordCount: plainText.trim().split(/\s+/).length, + characterCount: plainText.length, + finalTextCharacterCount: plainText.length, + environmentConfig: { + preset: 'default_writing', + taskType: 'personal', + instructions: { + hasInstructionPdf: true, + taskInstruction: '', + editableAfterSubmission: true, + }, + aiAccess: 'off', + allowedModels: [], + customModels: [], + aiTokenBudget: { shortcutMaxTokens: 1024, chatMaxTokens: 4096 }, + aiPolicy: { mode: 'off' }, + aiUsageLimit: { mode: 'unlimited' }, + time: { lateSubmission: 'allowed' }, + submission: { mode: 'multiple', attemptPolicy: { mode: 'single' } }, + traceability: { + trackAiUsage: false, + trackTyping: true, + trackCopyPaste: false, + trackFocusBlur: true, + }, + detectors: { + anomalyPattern: { enabled: true }, + humanTyping: { enabled: true }, + }, + resourceAccess: 'view-only', + copyPastePolicy: 'allowed', + }, + writingStartedAt: null, + createdAt: now, + updatedAt: now, + lastEditedAt: now, + }, + linkedFile: { + id: 'demo-pdf-workspace-scroll-e2e', + ownerUserId: 'demo-user-local', + documentId: DOCUMENT_ID, + purpose: 'document_source_pdf', + title: 'workspace-scroll.pdf', + originalFilename: 'workspace-scroll.pdf', + mimeType: 'application/pdf', + storageProvider: 'browser-local', + storageKey: pdfUrl, + storageBucket: null, + storageRegion: null, + storageEtag: null, + fileSize: 0, + checksum: 'e2e-browser-local', + pageCount: 8, + uploadStatus: 'ready', + textIndexStatus: 'unavailable', + legacySourceId: null, + createdAt: now, + updatedAt: now, + }, + events: [], + certificateIds: [], + }; +} + +async function fulfillPdfRange(route: Route, pdf: Buffer) { + const rangeHeader = route.request().headers().range; + const match = rangeHeader?.match(/^bytes=(\d+)-(\d*)$/); + + if (!match) { + await route.fulfill({ + status: 200, + contentType: 'application/pdf', + headers: { + 'Accept-Ranges': 'bytes', + 'Content-Length': String(pdf.length), + }, + body: pdf, + }); + return; + } + + const start = Number(match[1]); + const requestedEnd = match[2] ? Number(match[2]) : pdf.length - 1; + const end = Math.min(requestedEnd, pdf.length - 1); + const body = pdf.subarray(start, end + 1); + + await route.fulfill({ + status: 206, + contentType: 'application/pdf', + headers: { + 'Accept-Ranges': 'bytes', + 'Content-Length': String(body.length), + 'Content-Range': `bytes ${start}-${end}/${pdf.length}`, + }, + body, + }); +} + +async function seedBrowserLocalWorkspace(page: Page, baseURL: string) { + const pdf = buildBlankPdf(8); + await page.route(`**${PDF_PATH}`, (route) => fulfillPdfRange(route, pdf)); + await page.route('https://www.googletagmanager.com/**', (route) => + route.abort() + ); + + await page.goto('/documents/new?demo=1', { waitUntil: 'domcontentloaded' }); + await page.evaluate( + ({ documentStorageKey, dismissalKey, record }) => { + window.sessionStorage.setItem(documentStorageKey, JSON.stringify(record)); + window.localStorage.setItem(dismissalKey, 'dismissed'); + }, + { + documentStorageKey: DOCUMENT_STORAGE_KEY, + dismissalKey: RULES_DISMISSAL_KEY, + record: buildDemoRecord(`${baseURL}${PDF_PATH}`), + } + ); +} + +for (const viewport of DESKTOP_VIEWPORTS) { + test.describe(`${viewport.width}x${viewport.height}`, () => { + test.use({ viewport }); + + test('keeps workspace chrome fixed and content scrolling internally', async ({ + page, + baseURL, + }) => { + if (!baseURL) { + throw new Error('Playwright baseURL is required'); + } + + await seedBrowserLocalWorkspace(page, baseURL); + await page.goto(`/documents/${DOCUMENT_ID}?demo=1`, { + waitUntil: 'domcontentloaded', + }); + + await expect( + page.getByRole('heading', { name: DOCUMENT_TITLE, exact: true }) + ).toBeVisible(); + const navbar = page.locator('nav'); + const editorToolbar = page.locator('.editor-toolbar'); + const editorContent = page.locator('.editor-content-editable'); + const pdfCanvas = page.locator('[data-page] canvas').first(); + const zoomInButton = page.getByRole('button', { + name: 'Zoom in', + exact: true, + }); + + await expect(navbar).toBeVisible(); + await expect(editorToolbar).toBeVisible(); + await expect(editorContent).toBeVisible(); + await expect(zoomInButton).toBeVisible(); + await expect(pdfCanvas).toBeVisible(); + + const before = await page.evaluate(() => { + const html = document.documentElement; + const body = document.body; + const nav = document.querySelector('nav'); + const toolbar = document.querySelector('.editor-toolbar'); + const editable = document.querySelector('.editor-content-editable'); + const editorScroller = editable?.parentElement; + const canvas = document.querySelector('[data-page] canvas'); + let pdfScroller = canvas?.parentElement || null; + + while (pdfScroller && pdfScroller !== body) { + const overflowY = window.getComputedStyle(pdfScroller).overflowY; + if ( + (overflowY === 'auto' || overflowY === 'scroll') && + pdfScroller.scrollHeight > pdfScroller.clientHeight + 1 + ) { + break; + } + pdfScroller = pdfScroller.parentElement; + } + + const top = (element: Element | null) => + element ? Math.round(element.getBoundingClientRect().top) : null; + + return { + htmlClientHeight: html.clientHeight, + htmlScrollHeight: html.scrollHeight, + bodyClientHeight: body.clientHeight, + bodyScrollHeight: body.scrollHeight, + windowScrollY: window.scrollY, + navbarTop: top(nav), + editorToolbarTop: top(toolbar), + editorOverflowY: editorScroller + ? window.getComputedStyle(editorScroller).overflowY + : null, + editorClientHeight: editorScroller?.clientHeight || 0, + editorScrollHeight: editorScroller?.scrollHeight || 0, + pdfClientHeight: pdfScroller?.clientHeight || 0, + pdfScrollHeight: pdfScroller?.scrollHeight || 0, + }; + }); + + expect(before.htmlScrollHeight).toBeLessThanOrEqual( + before.htmlClientHeight + 1 + ); + expect(before.bodyScrollHeight).toBeLessThanOrEqual( + before.bodyClientHeight + 1 + ); + expect(before.windowScrollY).toBe(0); + expect(before.navbarTop).toBe(0); + expect(before.editorOverflowY).toBe('auto'); + expect(before.editorScrollHeight).toBeGreaterThan( + before.editorClientHeight + ); + expect(before.pdfScrollHeight).toBeGreaterThan(before.pdfClientHeight); + + const scrolled = await page.evaluate(() => { + const editable = document.querySelector('.editor-content-editable'); + const editorScroller = editable?.parentElement; + const canvas = document.querySelector('[data-page] canvas'); + let pdfScroller = canvas?.parentElement || null; + + while (pdfScroller && pdfScroller !== document.body) { + const overflowY = window.getComputedStyle(pdfScroller).overflowY; + if ( + (overflowY === 'auto' || overflowY === 'scroll') && + pdfScroller.scrollHeight > pdfScroller.clientHeight + 1 + ) { + break; + } + pdfScroller = pdfScroller.parentElement; + } + + if (editorScroller) + editorScroller.scrollTop = editorScroller.scrollHeight; + if (pdfScroller) pdfScroller.scrollTop = pdfScroller.scrollHeight; + + return new Promise<{ + editorScrollTop: number; + pdfScrollTop: number; + windowScrollY: number; + editorToolbarTop: number | null; + navbarTop: number | null; + }>((resolve) => { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + const toolbar = document.querySelector('.editor-toolbar'); + const navbar = document.querySelector('nav'); + resolve({ + editorScrollTop: editorScroller?.scrollTop || 0, + pdfScrollTop: pdfScroller?.scrollTop || 0, + windowScrollY: window.scrollY, + editorToolbarTop: toolbar + ? Math.round(toolbar.getBoundingClientRect().top) + : null, + navbarTop: navbar + ? Math.round(navbar.getBoundingClientRect().top) + : null, + }); + }); + }); + }); + }); + + expect(scrolled.editorScrollTop).toBeGreaterThan(0); + expect(scrolled.pdfScrollTop).toBeGreaterThan(0); + expect(scrolled.windowScrollY).toBe(0); + expect(scrolled.navbarTop).toBe(before.navbarTop); + expect(scrolled.editorToolbarTop).toBe(before.editorToolbarTop); + await expect(navbar).toBeVisible(); + await expect(editorToolbar).toBeVisible(); + await expect(zoomInButton).toBeVisible(); + }); + }); +} diff --git a/package.json b/package.json index 8917e714..3afcceb6 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "type-check": "pnpm build:shared && pnpm build:editor && pnpm --filter @humanly/backend lint && pnpm --filter @humanly/frontend type-check && pnpm --filter @humanly/frontend-user type-check", "test:backend": "pnpm --filter @humanly/backend test:runnable", "test:frontend-user": "pnpm --filter @humanly/frontend-user test:runnable", + "test:e2e:workspace": "pnpm build:shared && pnpm build:editor && playwright test e2e/document-workspace-scroll.spec.ts", "test:create-humanly": "pnpm --filter create-humanly test", "test:runnable": "pnpm build:shared && pnpm test:backend && pnpm test:frontend-user && pnpm test:create-humanly", "test:audit": "node scripts/ci/test-audit.mjs", @@ -38,6 +39,7 @@ "docker:logs": "docker compose logs -f" }, "devDependencies": { + "@playwright/test": "1.61.1", "@types/node": "^20.10.0", "eslint": "^8.55.0", "prettier": "^3.1.0", diff --git a/packages/frontend-user/next.config.js b/packages/frontend-user/next.config.js index 82ff010e..09431ba4 100644 --- a/packages/frontend-user/next.config.js +++ b/packages/frontend-user/next.config.js @@ -1,6 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + distDir: process.env.NEXT_DIST_DIR || '.next', transpilePackages: ['@humanly/shared', '@humanly/editor'], env: { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, diff --git a/packages/frontend-user/src/app/documents/template.tsx b/packages/frontend-user/src/app/documents/template.tsx index 4f8d1431..c1844168 100644 --- a/packages/frontend-user/src/app/documents/template.tsx +++ b/packages/frontend-user/src/app/documents/template.tsx @@ -3,12 +3,16 @@ import { usePathname } from 'next/navigation'; // Short fade-in on navigation within /documents. The full-height Lexical editor -// at /documents/ manages its own layout, so we skip the wrapper there to -// avoid disturbing it. Placed at the section level so the persistent Navbar +// at /documents/ manages its own layout, so we skip the wrapper +// there to avoid disturbing it. Placed at the section level so the persistent Navbar // (rendered in documents/layout.tsx) does not animate. CSS-only via // tailwindcss-animate; degrades under prefers-reduced-motion. See issue #971. -const isEditorRoute = (pathname: string) => - /^\/documents\/[a-f0-9-]{36}/.test(pathname); +const isEditorRoute = (pathname: string) => { + const documentId = pathname.match(/^\/documents\/([^/]+)/)?.[1]; + return Boolean( + documentId && documentId !== 'new' && documentId !== 'preview' + ); +}; export default function DocumentsTemplate({ children, diff --git a/packages/frontend-user/tsconfig.json b/packages/frontend-user/tsconfig.json index b72054c9..5d2868c8 100644 --- a/packages/frontend-user/tsconfig.json +++ b/packages/frontend-user/tsconfig.json @@ -23,6 +23,6 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"], "exclude": ["node_modules", "src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"] } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..aa877522 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from '@playwright/test'; + +const port = 3102; +const baseURL = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: './e2e', + outputDir: 'test-results/playwright', + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + timeout: 60_000, + expect: { + timeout: 20_000, + }, + reporter: process.env.CI + ? [['line'], ['html', { open: 'never', outputFolder: 'playwright-report' }]] + : [['line']], + use: { + baseURL, + headless: true, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'off', + }, + webServer: { + command: + `NEXT_PUBLIC_API_URL= NEXT_DIST_DIR=.next-e2e ` + + `pnpm --filter @humanly/frontend-user exec next dev -p ${port} -H 127.0.0.1`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06cf5385..4573f7c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: specifier: ^5.1.1 version: 5.1.1 devDependencies: + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 '@types/node': specifier: ^20.10.0 version: 20.19.39 @@ -1109,6 +1112,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -3134,6 +3142,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4204,6 +4217,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + png-js@1.1.0: resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==} @@ -5684,6 +5707,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -7511,7 +7538,7 @@ snapshots: eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -7541,7 +7568,7 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -7556,7 +7583,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7896,6 +7923,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9205,6 +9235,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + png-js@1.1.0: dependencies: browserify-zlib: 0.2.0