diff --git a/.changeset/olive-readers-turn.md b/.changeset/olive-readers-turn.md new file mode 100644 index 0000000000..4a87e2393a --- /dev/null +++ b/.changeset/olive-readers-turn.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/kitten-lynx-test-infra": patch +--- + +feat: support page.screenshot() diff --git a/packages/testing-library/kitten-lynx/lynx.config.js b/packages/testing-library/kitten-lynx/lynx.config.js index 59d494d6f7..c157c81bbd 100644 --- a/packages/testing-library/kitten-lynx/lynx.config.js +++ b/packages/testing-library/kitten-lynx/lynx.config.js @@ -11,6 +11,9 @@ export default defineConfig({ 'react-example': './test-fixture/cases/react-example/index.tsx', }, }, + output: { + assetPrefix: 'http://127.0.0.1:3001/', + }, plugins: [ pluginReactLynx(), ], diff --git a/packages/testing-library/kitten-lynx/package.json b/packages/testing-library/kitten-lynx/package.json index b1c382e961..b8b4541688 100644 --- a/packages/testing-library/kitten-lynx/package.json +++ b/packages/testing-library/kitten-lynx/package.json @@ -31,7 +31,7 @@ "dist" ], "scripts": { - "build": "tsc", + "build": "rslib build", "serve": "rspeedy dev", "test": "vitest run" }, @@ -48,6 +48,8 @@ "@lynx-js/types": "3.7.0", "@types/react": "^18.3.28", "execa": "^9.6.1", + "jpeg-js": "^0.4.4", + "rsbuild-plugin-publint": "0.3.4", "typescript": "^5.9.3", "vitest": "^3.2.4" } diff --git a/packages/testing-library/kitten-lynx/rslib.config.ts b/packages/testing-library/kitten-lynx/rslib.config.ts new file mode 100644 index 0000000000..acc693cb4e --- /dev/null +++ b/packages/testing-library/kitten-lynx/rslib.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@rslib/core'; +import { pluginPublint } from 'rsbuild-plugin-publint'; + +export default defineConfig({ + lib: [ + { format: 'esm', syntax: 'es2022', dts: { bundle: true, tsgo: true } }, + ], + plugins: [ + pluginPublint(), + ], +}); diff --git a/packages/testing-library/kitten-lynx/src/KittenLynxView.ts b/packages/testing-library/kitten-lynx/src/KittenLynxView.ts index 4bbafa135d..c8b60e62be 100644 --- a/packages/testing-library/kitten-lynx/src/KittenLynxView.ts +++ b/packages/testing-library/kitten-lynx/src/KittenLynxView.ts @@ -329,4 +329,68 @@ export class KittenLynxView { this.#contentToStringImpl(buffer, document.root); return buffer.join(''); } + + /** + * Captures a screenshot of the page. + * + * @param options - Screenshot options, such as path, format, and quality. + * @returns A Buffer with the image data. + */ + async screenshot(options?: { + path?: string; + format?: 'jpeg' | 'png' | 'webp'; + quality?: number; + }): Promise { + const { ReadableStream } = await import('node:stream/web'); + const sessionId = (this._channel as any)._sessionId; + + let pushMessage!: (msg: any) => void; + let closeStream!: () => void; + const inputStream = new ReadableStream({ + start(controller) { + pushMessage = (msg) => controller.enqueue(msg); + closeStream = () => controller.close(); + }, + }); + + const stream = await this._connector.sendCDPStream( + this._clientId, + inputStream, + ); + + let buffer: Buffer | undefined; + + try { + pushMessage({ + method: 'Lynx.getScreenshot', + params: {}, + sessionId, + }); + + for await (const msg of stream) { + if ((msg as any).method === 'Lynx.screenshotCaptured') { + const data = (msg as any).params?.data; + if (data) { + buffer = Buffer.from(data, 'base64'); + break; // Stop listening after receiving the first frame + } + } + } + } finally { + closeStream(); + if (typeof stream[Symbol.asyncDispose] === 'function') { + await stream[Symbol.asyncDispose](); + } + } + + if (!buffer) { + throw new Error('Failed to capture screenshot'); + } + + if (options?.path) { + const fs = await import('node:fs/promises'); + await fs.writeFile(options.path, buffer); + } + return buffer; + } } diff --git a/packages/testing-library/kitten-lynx/tests/lynx.spec.ts b/packages/testing-library/kitten-lynx/tests/lynx.spec.ts index 7c956a40cf..63fb96dc61 100644 --- a/packages/testing-library/kitten-lynx/tests/lynx.spec.ts +++ b/packages/testing-library/kitten-lynx/tests/lynx.spec.ts @@ -2,7 +2,7 @@ import { Lynx } from '../src/Lynx.js'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; - +import jpeg from 'jpeg-js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const cwd = path.dirname(__dirname); @@ -10,6 +10,7 @@ const cwd = path.dirname(__dirname); import { AdbServerClient, type Adb } from '@yume-chan/adb'; import { AdbServerNodeTcpConnector } from '@yume-chan/adb-server-node-tcp'; import { execa } from 'execa'; +import type { KittenLynxView } from '../src/KittenLynxView.js'; execa({ env: { ...process.env, @@ -25,6 +26,7 @@ execa({ describe('kitten-lynx testing framework', () => { let lynx: Lynx; let adb: Adb; + let page: KittenLynxView; beforeAll(async () => { // Use ADB port forwarding @@ -42,28 +44,77 @@ describe('kitten-lynx testing framework', () => { } lynx = await Lynx.connect(); - }); + + page = await lynx.newPage(); + await page.goto('http://127.0.0.1:3001/react-example.lynx.bundle', { + timeout: 15000, + }); + }, 90000); afterAll(async () => { await lynx?.close(); }); it('can navigate to a page and read the DOM', async () => { - const page = await lynx.newPage(); - - await page.goto('http://127.0.0.1:3001/react-example.lynx.bundle', { - timeout: 15000, - }); - const content = await page.content(); expect(content).toContain('have fun'); + }); - const rootElement = await page.locator('view'); - expect(rootElement).toBeDefined(); + it('can get attributes from an element', async () => { + const titleElement = await page.locator('.Title'); + expect(titleElement).toBeDefined(); - if (rootElement) { - const styles = await rootElement.computedStyleMap(); + if (titleElement) { + const classAttr = await titleElement.getAttribute('class'); + expect(classAttr).toBe('Title'); + + const textAttr = await titleElement.getAttribute('text'); + expect(textAttr).toBe('React'); + } + }); + + it('can fetch computed style map of an element', async () => { + const titleElement = await page.locator('.Title'); + expect(titleElement).toBeDefined(); + + if (titleElement) { + const styles = await titleElement.computedStyleMap(); expect(styles.size).toBeGreaterThan(0); + expect(styles.has('display')).toBe(true); + } + }); + + it('can click', async () => { + const logoParent = await page.locator('.Logo'); + expect(await page.locator('.Logo--lynx')).toBeDefined(); + await logoParent?.tap(); + await new Promise(resolve => setTimeout(resolve, 2000)); + expect(await page.locator('.Logo--react')).toBeDefined(); + expect(await page.locator('.Logo--lynx')).toBeUndefined(); + await logoParent?.tap(); + await new Promise(resolve => setTimeout(resolve, 2000)); + expect(await page.locator('.Logo--react')).toBeUndefined(); + expect(await page.locator('.Logo--lynx')).toBeDefined(); + }); + + it('can take a screenshot', async () => { + const buffer = await page.screenshot(); + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + + // Assert the screenshot is not a completely white image + const rawImageData = jpeg.decode(buffer, { useTArray: true }); + let hasNonWhite = false; + for (let i = 0; i < rawImageData.data.length; i += 4) { + if ( + rawImageData.data[i] !== 255 + || rawImageData.data[i + 1] !== 255 + || rawImageData.data[i + 2] !== 255 + ) { + hasNonWhite = true; + break; + } } - }, 90000); // Increase timeout to 90s as connecting/launching emulator app can be slow + expect(hasNonWhite).toBe(true); + }); }); diff --git a/packages/testing-library/kitten-lynx/tsconfig.json b/packages/testing-library/kitten-lynx/tsconfig.json index 44d7dc5c8d..8033555566 100644 --- a/packages/testing-library/kitten-lynx/tsconfig.json +++ b/packages/testing-library/kitten-lynx/tsconfig.json @@ -13,7 +13,6 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "baseUrl": ".", "paths": { "@lynx-js/devtool-connector": ["../../mcp-servers/devtool-connector/dist/index.d.ts"], "@lynx-js/devtool-connector/transport": ["../../mcp-servers/devtool-connector/dist/transport/index.d.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92c6126c5e..ab805fa4b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1255,6 +1255,12 @@ importers: execa: specifier: ^9.6.1 version: 9.6.1 + jpeg-js: + specifier: ^0.4.4 + version: 0.4.4 + rsbuild-plugin-publint: + specifier: 0.3.4 + version: 0.3.4(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0)) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -7288,6 +7294,9 @@ packages: jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -16275,6 +16284,8 @@ snapshots: jose@6.1.3: {} + jpeg-js@0.4.4: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {}