diff --git a/.github/workflows/sync-extension.yml b/.github/workflows/sync-extension.yml index 7fa2ff87..201a45ce 100644 --- a/.github/workflows/sync-extension.yml +++ b/.github/workflows/sync-extension.yml @@ -25,6 +25,7 @@ jobs: uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 # Fetch all history for proper branching - name: Set up Node.js uses: actions/setup-node@v4 @@ -62,14 +63,38 @@ jobs: mkdir -p extension-temp cd extension-temp - # Download each file from release - curl -L -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" \ + # First, try to download the zip archive if available + ZIP_URL=$(curl -s -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" \ "https://api.github.com/repos/$REPO/releases/tags/$TAG" | \ - jq -r '.assets[] | select(.name | endswith(".js") or endswith(".wasm") or endswith(".json") or endswith(".d.ts")) | .browser_download_url' | \ - while read url; do - filename=$(basename "$url") - curl -L -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" "$url" -o "$filename" - done + jq -r '.assets[] | select(.name == "extension-package.zip") | .browser_download_url') + + if [ -n "$ZIP_URL" ] && [ "$ZIP_URL" != "null" ]; then + echo "📦 Downloading extension-package.zip..." + curl -L -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" "$ZIP_URL" -o extension-package.zip + unzip -q extension-package.zip -d . + # Files should now be in extension-temp/extension-package/ or extension-temp/ + if [ -d "extension-package" ]; then + mv extension-package/* . 2>/dev/null || true + rmdir extension-package 2>/dev/null || true + fi + else + echo "📁 Downloading individual files from release..." + # Download each file from release + curl -s -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" \ + "https://api.github.com/repos/$REPO/releases/tags/$TAG" | \ + jq -r '.assets[] | select(.name | endswith(".js") or endswith(".wasm") or endswith(".json") or endswith(".d.ts")) | .browser_download_url' | \ + while read url; do + if [ -n "$url" ] && [ "$url" != "null" ]; then + filename=$(basename "$url") + echo " Downloading $filename..." + curl -L -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" "$url" -o "$filename" + fi + done + fi + + # Verify files were downloaded + echo "📋 Downloaded files:" + ls -la - name: Copy extension files if: steps.release.outputs.skip != 'true' @@ -77,16 +102,40 @@ jobs: # Create extension directory structure mkdir -p src/extension/pkg - # Copy extension files - cp extension-temp/manifest.json src/extension/ 2>/dev/null || echo "manifest.json not found in release" - cp extension-temp/content.js src/extension/ 2>/dev/null || echo "content.js not found in release" - cp extension-temp/background.js src/extension/ 2>/dev/null || echo "background.js not found in release" - cp extension-temp/injected_api.js src/extension/ 2>/dev/null || echo "injected_api.js not found in release" + # Copy extension files (check both root and pkg subdirectory) + cp extension-temp/manifest.json src/extension/ 2>/dev/null || echo "⚠️ manifest.json not found in release" + cp extension-temp/content.js src/extension/ 2>/dev/null || echo "⚠️ content.js not found in release" + cp extension-temp/background.js src/extension/ 2>/dev/null || echo "⚠️ background.js not found in release" + cp extension-temp/injected_api.js src/extension/ 2>/dev/null || echo "⚠️ injected_api.js not found in release" - # Copy WASM files - cp extension-temp/pkg/sentience_core.js src/extension/pkg/ 2>/dev/null || echo "sentience_core.js not found" - cp extension-temp/pkg/sentience_core_bg.wasm src/extension/pkg/ 2>/dev/null || echo "sentience_core_bg.wasm not found" - cp extension-temp/pkg/*.d.ts src/extension/pkg/ 2>/dev/null || echo "Type definitions not found" + # Copy WASM files (check both root and pkg subdirectory) + if [ -f "extension-temp/pkg/sentience_core.js" ]; then + cp extension-temp/pkg/sentience_core.js src/extension/pkg/ + elif [ -f "extension-temp/sentience_core.js" ]; then + cp extension-temp/sentience_core.js src/extension/pkg/ + else + echo "⚠️ sentience_core.js not found" + fi + + if [ -f "extension-temp/pkg/sentience_core_bg.wasm" ]; then + cp extension-temp/pkg/sentience_core_bg.wasm src/extension/pkg/ + elif [ -f "extension-temp/sentience_core_bg.wasm" ]; then + cp extension-temp/sentience_core_bg.wasm src/extension/pkg/ + else + echo "⚠️ sentience_core_bg.wasm not found" + fi + + # Copy TypeScript definitions + if [ -d "extension-temp/pkg" ]; then + cp extension-temp/pkg/*.d.ts src/extension/pkg/ 2>/dev/null || echo "⚠️ Type definitions not found" + elif [ -d "extension-temp" ]; then + cp extension-temp/*.d.ts src/extension/pkg/ 2>/dev/null || echo "⚠️ Type definitions not found" + fi + + # Verify copied files + echo "📋 Copied files:" + ls -la src/extension/ + ls -la src/extension/pkg/ 2>/dev/null || echo "⚠️ pkg directory not created" - name: Check for changes if: steps.release.outputs.skip != 'true' @@ -107,7 +156,9 @@ jobs: if: steps.release.outputs.skip != 'true' && steps.changes.outputs.changed == 'true' uses: peter-evans/create-pull-request@v5 with: - token: ${{ secrets.GITHUB_TOKEN }} + # Use GITHUB_TOKEN (built-in) if repository allows PR creation, otherwise use PR_TOKEN (PAT) + # To use PAT: create secret named PR_TOKEN with a Personal Access Token that has 'repo' scope + token: ${{ secrets.PR_TOKEN || secrets.GITHUB_TOKEN }} commit-message: "chore: sync extension files from sentience-chrome ${{ steps.release.outputs.tag }}" title: "Sync Extension: ${{ steps.release.outputs.tag }}" body: | @@ -117,7 +168,10 @@ jobs: - Extension manifest and scripts - WASM binary and bindings - **Source:** [sentience-chrome release ${{ steps.release.outputs.tag }}](${{ secrets.SENTIENCE_CHROME_REPO }}/releases/tag/${{ steps.release.outputs.tag }}) + **Source:** [sentience-chrome release ${{ steps.release.outputs.tag }}](https://github.com/${{ secrets.SENTIENCE_CHROME_REPO }}/releases/tag/${{ steps.release.outputs.tag }}) branch: sync-extension-${{ steps.release.outputs.tag }} delete-branch: true + labels: | + automated + extension-sync diff --git a/README.md b/README.md index 51639242..95e8ad81 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,14 @@ npm run build - `snapshot(browser, options)` - Capture page state - TypeScript types for type safety +### Content Reading & Screenshots +- `read(browser, options)` - Read page content as text or markdown + - Enhanced markdown conversion using `turndown` (better than extension's lightweight conversion) + - Supports `enhance_markdown` option to use improved conversion +- `screenshot(browser, options)` - Capture standalone screenshot + - Returns base64-encoded data URL + - Supports PNG and JPEG formats with quality control + ### Day 4: Query Engine - `query(snapshot, selector)` - Find elements matching selector - `find(snapshot, selector)` - Find single best match @@ -105,6 +113,50 @@ See `examples/` directory: - `query-demo.ts` - Query engine - `wait-and-click.ts` - Wait and actions +### Content Reading Example + +```typescript +import { SentienceBrowser, read } from './src'; + +const browser = new SentienceBrowser(); +await browser.start(); + +await browser.getPage().goto('https://example.com'); +await browser.getPage().waitForLoadState('networkidle'); + +// Read as enhanced markdown (better quality) +const result = await read(browser, { + format: 'markdown', + enhance_markdown: true +}); +console.log(result.content); // High-quality markdown + +await browser.close(); +``` + +### Screenshot Example + +```typescript +import { SentienceBrowser, screenshot } from './src'; +import { writeFileSync } from 'fs'; + +const browser = new SentienceBrowser(); +await browser.start(); + +await browser.getPage().goto('https://example.com'); +await browser.getPage().waitForLoadState('networkidle'); + +// Capture PNG screenshot +const dataUrl = await screenshot(browser, { format: 'png' }); + +// Save to file +const base64Data = dataUrl.split(',')[1]; +const imageData = Buffer.from(base64Data, 'base64'); +writeFileSync('screenshot.png', imageData); + +await browser.close(); +``` + ## Testing ```bash diff --git a/package-lock.json b/package-lock.json index 066a936f..03338e67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,8 +7,10 @@ "": { "name": "sentience-ts", "version": "0.1.0", + "license": "MIT", "dependencies": { "playwright": "^1.40.0", + "turndown": "^7.2.2", "zod": "^3.22.0" }, "bin": { @@ -17,10 +19,14 @@ "devDependencies": { "@types/jest": "^29.5.14", "@types/node": "^20.0.0", + "@types/turndown": "^5.0.3", "jest": "^29.0.0", "ts-jest": "^29.0.0", "ts-node": "^10.9.0", "typescript": "^5.0.0" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@babel/code-frame": { @@ -912,6 +918,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1077,6 +1089,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3802,6 +3821,15 @@ } } }, + "node_modules/turndown": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/package.json b/package.json index 600157e7..406ed784 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ }, "dependencies": { "playwright": "^1.40.0", + "turndown": "^7.2.2", "zod": "^3.22.0" }, "devDependencies": { "@types/jest": "^29.5.14", "@types/node": "^20.0.0", + "@types/turndown": "^5.0.3", "jest": "^29.0.0", "ts-jest": "^29.0.0", "ts-node": "^10.9.0", diff --git a/src/index.ts b/src/index.ts index 442e0f92..f01d4dd2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,5 +11,7 @@ export { expect, Expectation } from './expect'; export { Inspector, inspect } from './inspector'; export { Recorder, Trace, TraceStep, record } from './recorder'; export { ScriptGenerator, generate } from './generator'; +export { read, ReadOptions, ReadResult } from './read'; +export { screenshot, ScreenshotOptions } from './screenshot'; export * from './types'; diff --git a/src/read.ts b/src/read.ts new file mode 100644 index 00000000..ba6b5c84 --- /dev/null +++ b/src/read.ts @@ -0,0 +1,80 @@ +/** + * Read page content - enhanced markdown conversion + */ + +import { SentienceBrowser } from './browser'; +import TurndownService from 'turndown'; + +export interface ReadOptions { + format?: 'text' | 'markdown'; + enhance_markdown?: boolean; +} + +export interface ReadResult { + status: 'success' | 'error'; + url: string; + format: 'text' | 'markdown'; + content: string; + length: number; + error?: string; +} + +/** + * Read page content as text or markdown + * + * @param browser - SentienceBrowser instance + * @param options - Read options + * @returns ReadResult with page content + */ +export async function read( + browser: SentienceBrowser, + options: ReadOptions = {} +): Promise { + const page = browser.getPage(); + const format = options.format || 'text'; + const enhanceMarkdown = options.enhance_markdown !== false; // Default to true + + // Get basic content from extension + const result = (await page.evaluate( + (opts) => { + return (window as any).sentience.read(opts); + }, + { format } + )) as ReadResult; + + // Enhance markdown if requested and format is markdown + if (format === 'markdown' && enhanceMarkdown && result.status === 'success') { + try { + // Get full HTML from page + const htmlContent = await page.evaluate( + () => document.documentElement.outerHTML + ); + + // Use turndown for better conversion + const turndownService = new TurndownService({ + headingStyle: 'atx', // Use # for headings + bulletListMarker: '-', // Use - for lists + codeBlockStyle: 'fenced', // Use ``` for code blocks + }); + + // Add custom rules for better conversion + turndownService.addRule('strikethrough', { + filter: ['del', 's', 'strike'] as any, + replacement: (content: string) => `~~${content}~~`, + }); + + // Strip unwanted tags + turndownService.remove(['script', 'style', 'nav', 'footer', 'header', 'noscript']); + + const enhancedMarkdown = turndownService.turndown(htmlContent); + result.content = enhancedMarkdown; + result.length = enhancedMarkdown.length; + } catch (e) { + // If enhancement fails, use extension's result + result.error = `Markdown enhancement failed: ${e}`; + } + } + + return result; +} + diff --git a/src/screenshot.ts b/src/screenshot.ts new file mode 100644 index 00000000..e7ee1381 --- /dev/null +++ b/src/screenshot.ts @@ -0,0 +1,50 @@ +/** + * Screenshot functionality - standalone screenshot capture + */ + +import { SentienceBrowser } from './browser'; + +export interface ScreenshotOptions { + format?: 'png' | 'jpeg'; + quality?: number; // 1-100, only used for JPEG +} + +/** + * Capture screenshot of current page + * + * @param browser - SentienceBrowser instance + * @param options - Screenshot options + * @returns Base64-encoded screenshot data URL (e.g., "data:image/png;base64,...") + */ +export async function screenshot( + browser: SentienceBrowser, + options: ScreenshotOptions = {} +): Promise { + const page = browser.getPage(); + const format = options.format || 'png'; + const quality = options.quality; + + if (format === 'jpeg' && quality !== undefined) { + if (quality < 1 || quality > 100) { + throw new Error('Quality must be between 1 and 100 for JPEG format'); + } + } + + // Use Playwright's screenshot with base64 encoding + const screenshotOptions: any = { + type: format, + encoding: 'base64', + }; + + if (format === 'jpeg' && quality !== undefined) { + screenshotOptions.quality = quality; + } + + // Capture screenshot + const base64Data = await page.screenshot(screenshotOptions); + + // Return as data URL + const mimeType = format === 'png' ? 'image/png' : 'image/jpeg'; + return `data:${mimeType};base64,${base64Data}`; +} + diff --git a/tests/read.test.ts b/tests/read.test.ts new file mode 100644 index 00000000..758f5eb5 --- /dev/null +++ b/tests/read.test.ts @@ -0,0 +1,75 @@ +/** + * Tests for read functionality + */ + +import { SentienceBrowser, read } from '../src'; +import { createTestBrowser } from './test-utils'; + +describe('read', () => { + it('should read page as text', async () => { + const browser = await createTestBrowser(); + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const result = await read(browser, { format: 'text' }); + + expect(result.status).toBe('success'); + expect(result.format).toBe('text'); + expect(result.content).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + expect(result.url).toBe('https://example.com'); + } finally { + await browser.close(); + } + }); + + it('should read page as markdown', async () => { + const browser = await createTestBrowser(); + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const result = await read(browser, { format: 'markdown' }); + + expect(result.status).toBe('success'); + expect(result.format).toBe('markdown'); + expect(result.content).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + expect(result.url).toBe('https://example.com'); + } finally { + await browser.close(); + } + }); + + it('should enhance markdown by default', async () => { + const browser = await createTestBrowser(); + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + // Test with enhancement (default) + const resultEnhanced = await read(browser, { + format: 'markdown', + enhance_markdown: true, + }); + + expect(resultEnhanced.status).toBe('success'); + expect(resultEnhanced.format).toBe('markdown'); + expect(resultEnhanced.content.length).toBeGreaterThan(0); + + // Test without enhancement + const resultBasic = await read(browser, { + format: 'markdown', + enhance_markdown: false, + }); + + expect(resultBasic.status).toBe('success'); + expect(resultBasic.format).toBe('markdown'); + expect(resultBasic.content.length).toBeGreaterThan(0); + } finally { + await browser.close(); + } + }); +}); + diff --git a/tests/screenshot.test.ts b/tests/screenshot.test.ts new file mode 100644 index 00000000..9787260a --- /dev/null +++ b/tests/screenshot.test.ts @@ -0,0 +1,84 @@ +/** + * Tests for screenshot functionality + */ + +import { SentienceBrowser, screenshot } from '../src'; +import { createTestBrowser } from './test-utils'; + +describe('screenshot', () => { + it('should capture PNG screenshot', async () => { + const browser = await createTestBrowser(); + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const dataUrl = await screenshot(browser, { format: 'png' }); + + expect(dataUrl).toMatch(/^data:image\/png;base64,/); + + // Decode and verify it's valid base64 + const base64Data = dataUrl.split(',')[1]; + const imageData = Buffer.from(base64Data, 'base64'); + expect(imageData.length).toBeGreaterThan(0); + } finally { + await browser.close(); + } + }); + + it('should capture JPEG screenshot', async () => { + const browser = await createTestBrowser(); + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const dataUrl = await screenshot(browser, { format: 'jpeg', quality: 80 }); + + expect(dataUrl).toMatch(/^data:image\/jpeg;base64,/); + + // Decode and verify it's valid base64 + const base64Data = dataUrl.split(',')[1]; + const imageData = Buffer.from(base64Data, 'base64'); + expect(imageData.length).toBeGreaterThan(0); + } finally { + await browser.close(); + } + }); + + it('should use PNG as default format', async () => { + const browser = await createTestBrowser(); + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const dataUrl = await screenshot(browser); + + expect(dataUrl).toMatch(/^data:image\/png;base64,/); + } finally { + await browser.close(); + } + }); + + it('should validate JPEG quality', async () => { + const browser = await createTestBrowser(); + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + // Valid quality + await screenshot(browser, { format: 'jpeg', quality: 50 }); // Should not throw + + // Invalid quality - too low + await expect( + screenshot(browser, { format: 'jpeg', quality: 0 }) + ).rejects.toThrow('Quality must be between 1 and 100'); + + // Invalid quality - too high + await expect( + screenshot(browser, { format: 'jpeg', quality: 101 }) + ).rejects.toThrow('Quality must be between 1 and 100'); + } finally { + await browser.close(); + } + }); +}); +