diff --git a/pieces/agentmark/README.md b/pieces/agentmark/README.md new file mode 100644 index 0000000..44a539b --- /dev/null +++ b/pieces/agentmark/README.md @@ -0,0 +1,46 @@ +# @thinkfleet/piece-agentmark + +[Activepieces](https://www.activepieces.com/) piece for [AgentMark](https://github.com/ThinkfleetAI/agentmark) — convert any web page or PDF into a compact AgentMark snapshot from inside a flow, and fill PDF forms with data from previous flow steps. + +## What this piece adds + +| Action | What it does | Inputs | Outputs | +|---|---|---|---| +| **Capture Web Page** | Launch Chromium, snapshot a URL, return AgentMark | `url`, `wait_until`, `timeout_ms`, `headless` | `agentmark`, `url`, `title`, `kind`, `action_count`, `bytes` | +| **Capture PDF** | Convert a PDF (URL/file/base64/data URI) to AgentMark; optional OCR | `source`, `source_url`, `title`, `password`, `enable_ocr`, `ocr_language` | `agentmark`, `source_url`, `bytes`, `ocr_used` | +| **Fill PDF Form** | Fill an AcroForm PDF and return the filled bytes | `source`, `values`, `flatten`, `return_format`, `password` | `filled_pdf` (data URI or raw base64), `bytes`, `fields_applied`, `fields_skipped`, `flattened` | + +All actions run on the Activepieces worker — no external service required. Browser-based snapshots use Chromium via Playwright; PDF support uses pdfjs-dist + pdf-lib; optional OCR uses Tesseract.js with Poppler (`pdftoppm`). + +## Why use this in a flow + +- **Drop AgentMark into any agent flow** without writing code. The agent loop lives in your flow — call `Capture Page`, pass the snapshot to your AI step, then `Page Execute` (coming soon) or chain another snapshot. +- **Fill insurance/government/vendor PDFs** from CRM data. Map field action IDs from a previous step to records pulled from your data store, run `Fill PDF Form`, attach the result to an email. +- **Extract structured data from scanned docs** by enabling OCR on `Capture PDF`. Works on "Microsoft Print To PDF" output and scanner outputs. + +## Activepieces setup + +This piece depends on: + +- `@thinkfleet/agentmark` (the core library) +- `playwright-core` for browser-based actions +- `pdfjs-dist` (optional, required for any PDF action) +- `pdf-lib` (optional, required for `Fill PDF Form`) +- `tesseract.js` + Poppler installed on the worker (optional, required for OCR) + +Install Chromium binaries on the worker once: + +```bash +npx playwright-core install chromium +``` + +Install Poppler on the worker (only if using OCR): + +```bash +brew install poppler # macOS +apt-get install poppler-utils # Ubuntu/Debian +``` + +## License + +MIT. diff --git a/pieces/agentmark/package.json b/pieces/agentmark/package.json new file mode 100644 index 0000000..e24f1de --- /dev/null +++ b/pieces/agentmark/package.json @@ -0,0 +1,61 @@ +{ + "name": "@thinkfleet/piece-agentmark", + "version": "0.1.0", + "description": "Activepieces piece — convert any web page or PDF into an AgentMark snapshot, then drive it from any flow.", + "type": "commonjs", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ThinkfleetAI/agentmark.git", + "directory": "pieces/agentmark" + }, + "homepage": "https://agentmark.dev", + "keywords": [ + "activepieces", + "piece", + "agentmark", + "ai", + "browser", + "pdf", + "ocr", + "form-fill" + ], + "scripts": { + "build": "tsc -p tsconfig.lib.json && cp package.json dist/", + "test": "vitest run" + }, + "files": [ + "dist", + "README.md" + ], + "dependencies": { + "@thinkfleet/agentmark": "file:../..", + "tslib": "^2.3.0", + "undici": "^7.0.0" + }, + "_publishing_note": "@thinkfleet/agentmark is `file:../..` for in-monorepo development; bump to ^0.7.0 (or whatever version is on npm) before publishing this piece.", + "peerDependencies": { + "@activepieces/pieces-framework": ">=0.7.0", + "playwright-core": ">=1.40.0" + }, + "peerDependenciesMeta": { + "@activepieces/pieces-framework": { + "optional": false + }, + "playwright-core": { + "optional": false + } + }, + "devDependencies": { + "@activepieces/pieces-framework": "*", + "@types/node": "20.19.9", + "pdf-lib": "^1.17.1", + "pdfjs-dist": "^4.10.38", + "playwright-core": "^1.40.0", + "tesseract.js": "^5.1.1", + "typescript": "^5.4.0", + "vitest": "3.0.8" + } +} diff --git a/pieces/agentmark/src/index.ts b/pieces/agentmark/src/index.ts new file mode 100644 index 0000000..2cc15cd --- /dev/null +++ b/pieces/agentmark/src/index.ts @@ -0,0 +1,27 @@ +/** + * @thinkfleet/piece-agentmark — Activepieces piece for AgentMark. + * + * Drop into any Activepieces flow to convert web pages or PDFs into + * AgentMark snapshots and fill PDF forms — all without leaving the flow + * builder. Wraps the core @thinkfleet/agentmark library. + */ + +import { createPiece, PieceAuth } from '@activepieces/pieces-framework' +import { snapshotWebPage } from './lib/actions/snapshot-web-page' +import { snapshotPdf } from './lib/actions/snapshot-pdf' +import { fillPdfForm } from './lib/actions/fill-pdf-form' + +export const agentmark = createPiece({ + displayName: 'AgentMark', + description: + 'Convert web pages and PDFs into compact AgentMark snapshots; fill ' + + 'AcroForm PDFs from flow data. Powered by @thinkfleet/agentmark.', + auth: PieceAuth.None(), + minimumSupportedRelease: '0.78.0', + logoUrl: 'https://agentmark.dev/logo.svg', + authors: ['thinkfleet'], + actions: [snapshotWebPage, snapshotPdf, fillPdfForm], + triggers: [], +}) + +export { snapshotWebPage, snapshotPdf, fillPdfForm } diff --git a/pieces/agentmark/src/lib/actions/fill-pdf-form.ts b/pieces/agentmark/src/lib/actions/fill-pdf-form.ts new file mode 100644 index 0000000..29f0859 --- /dev/null +++ b/pieces/agentmark/src/lib/actions/fill-pdf-form.ts @@ -0,0 +1,105 @@ +import { createAction, Property } from '@activepieces/pieces-framework' +import { openPdfDocument } from '@thinkfleet/agentmark' +import { resolveBytes, bytesToBase64DataUri } from '../common' + +export const fillPdfForm = createAction({ + name: 'fill_pdf_form', + displayName: 'Fill PDF Form', + description: + 'Fill an AcroForm PDF in one atomic step. Pass a values object keyed ' + + 'by AgentMark action ID OR by original field name; the action ' + + 'matches either. Returns the filled PDF as a base64 data URI.', + props: { + source: Property.LongText({ + displayName: 'PDF Source', + description: + 'HTTP(S) URL, file path, file:// URI, data: URI, or base64 string.', + required: true, + }), + values: Property.Json({ + displayName: 'Field Values', + description: + 'Object mapping field IDs (action IDs like `act_field_1`) or ' + + 'field names (e.g. `applicant.first_name`) to values. ' + + 'Strings for text/select/radio, booleans for checkboxes, ' + + 'arrays for multi-select.', + required: true, + defaultValue: {}, + }), + flatten: Property.Checkbox({ + displayName: 'Flatten', + description: + 'Bake values into page content. Resulting PDF is no longer fillable.', + required: false, + defaultValue: false, + }), + return_format: Property.StaticDropdown({ + displayName: 'Return Format', + description: 'How the filled PDF is returned in the action output.', + required: false, + defaultValue: 'data_uri', + options: { + disabled: false, + options: [ + { label: 'Base64 data URI', value: 'data_uri' }, + { label: 'Raw base64 (no scheme)', value: 'base64' }, + ], + }, + }), + password: Property.ShortText({ + displayName: 'Password', + description: 'Password for encrypted PDFs.', + required: false, + }), + }, + async run(context) { + const { source, values, flatten, return_format, password } = context.propsValue + const data = await resolveBytes(source) + const doc = await openPdfDocument({ + data, + sourceUrl: source.startsWith('http') ? source : 'inline:pdf', + password, + }) + + try { + const valuesMap = (values ?? {}) as Record + + // Build action-id-keyed dispatch map from BOTH action IDs and + // original field names. Caller can use whichever is convenient. + const actionIdByName = new Map() + for (const [actionId, field] of doc.fields) { + actionIdByName.set(field.fieldName, actionId) + } + + const summary: Array<{ key: string; resolved_action_id: string }> = [] + const skipped: string[] = [] + + for (const [key, value] of Object.entries(valuesMap)) { + const resolved = doc.fields.has(key) + ? key + : actionIdByName.get(key) + if (!resolved) { + skipped.push(key) + continue + } + await doc.execute(resolved, value) + summary.push({ key, resolved_action_id: resolved }) + } + + const filled = await doc.save({ flatten: flatten === true }) + const out = return_format === 'base64' + ? Buffer.from(filled).toString('base64') + : bytesToBase64DataUri(filled) + + return { + filled_pdf: out, + bytes: filled.length, + fields_applied: summary, + fields_skipped: skipped, + flattened: flatten === true, + } + } finally { + await doc.close() + } + }, +}) diff --git a/pieces/agentmark/src/lib/actions/snapshot-pdf.ts b/pieces/agentmark/src/lib/actions/snapshot-pdf.ts new file mode 100644 index 0000000..2a65c00 --- /dev/null +++ b/pieces/agentmark/src/lib/actions/snapshot-pdf.ts @@ -0,0 +1,101 @@ +import { createAction, Property } from '@activepieces/pieces-framework' +import { + convertPdf, + PopplerRenderBackend, + TesseractOcrBackend, +} from '@thinkfleet/agentmark' +import { resolveBytes } from '../common' + +export const snapshotPdf = createAction({ + name: 'snapshot_pdf', + displayName: 'Capture PDF', + description: + 'Convert a PDF (URL, file path, base64, or data URI) into a compact ' + + 'AgentMark snapshot. PDFs with form fields produce kind: \'form\'; ' + + 'plain documents produce kind: \'document\'. Optionally OCR pages ' + + 'with no extractable text using Tesseract + Poppler.', + props: { + source: Property.LongText({ + displayName: 'Source', + description: + 'HTTP(S) URL, file path, file:// URI, data:application/pdf;base64,... ' + + 'URI, or a bare base64 string.', + required: true, + }), + source_url: Property.ShortText({ + displayName: 'Source URL (override)', + description: + 'Optional URI to record as the snapshot\'s `url` field. Useful ' + + 'when the input is a data URI or in-memory base64 and you ' + + 'want a stable identifier for downstream steps.', + required: false, + }), + title: Property.ShortText({ + displayName: 'Title (override)', + description: 'Override the document title. Leave blank to use the PDF metadata title.', + required: false, + }), + password: Property.ShortText({ + displayName: 'Password', + description: 'Password for encrypted PDFs.', + required: false, + }), + enable_ocr: Property.Checkbox({ + displayName: 'Enable OCR', + description: + 'Run Tesseract OCR on pages with no extractable text. Required ' + + 'for scanned PDFs and "Microsoft Print To PDF" output. Slower; ' + + 'requires Poppler installed on the worker host (pdftoppm).', + required: false, + defaultValue: false, + }), + ocr_language: Property.ShortText({ + displayName: 'OCR Language', + description: 'BCP-47 language hint. Default: eng.', + required: false, + defaultValue: 'eng', + }), + }, + async run(context) { + const { + source, + source_url, + title, + password, + enable_ocr, + ocr_language, + } = context.propsValue + + const data = await resolveBytes(source) + const sourceUrl = source_url + ?? (source.startsWith('http') ? source : 'inline:pdf') + + const ocrBackend = enable_ocr ? new TesseractOcrBackend({ language: ocr_language ?? 'eng' }) : undefined + try { + const { agentmark } = await convertPdf({ + data, + sourceUrl, + title, + password, + ocr: enable_ocr + ? { + render: new PopplerRenderBackend(), + ocr: ocrBackend!, + mode: 'auto', + dpi: 200, + language: ocr_language ?? 'eng', + } + : undefined, + }) + + return { + agentmark, + source_url: sourceUrl, + bytes: agentmark.length, + ocr_used: enable_ocr === true, + } + } finally { + await ocrBackend?.close().catch(() => {}) + } + }, +}) diff --git a/pieces/agentmark/src/lib/actions/snapshot-web-page.ts b/pieces/agentmark/src/lib/actions/snapshot-web-page.ts new file mode 100644 index 0000000..7a7d3bd --- /dev/null +++ b/pieces/agentmark/src/lib/actions/snapshot-web-page.ts @@ -0,0 +1,69 @@ +import { createAction, Property } from '@activepieces/pieces-framework' +import { createBrowser } from '@thinkfleet/agentmark' + +export const snapshotWebPage = createAction({ + name: 'snapshot_web_page', + displayName: 'Capture Web Page', + description: + 'Navigate to a URL and return a compact AgentMark snapshot. The result ' + + 'is 5–10× smaller than raw HTML and can be passed to any LLM as the ' + + 'page representation.', + props: { + url: Property.ShortText({ + displayName: 'URL', + description: 'The page to capture.', + required: true, + }), + wait_until: Property.StaticDropdown({ + displayName: 'Wait Until', + description: 'When to consider the page loaded.', + required: false, + defaultValue: 'load', + options: { + disabled: false, + options: [ + { label: 'Page load event', value: 'load' }, + { label: 'DOM content loaded', value: 'domcontentloaded' }, + { label: 'Network idle', value: 'networkidle' }, + ], + }, + }), + timeout_ms: Property.Number({ + displayName: 'Navigation Timeout (ms)', + description: 'Default 30000.', + required: false, + defaultValue: 30_000, + }), + headless: Property.Checkbox({ + displayName: 'Headless', + description: 'Run Chromium in headless mode (recommended).', + required: false, + defaultValue: true, + }), + }, + async run(context) { + const { url, wait_until, timeout_ms, headless } = context.propsValue + const browser = await createBrowser({ + launch: { headless: headless !== false }, + }) + try { + const page = await browser.newPage() + await page.goto(url, { + waitUntil: (wait_until as 'load' | 'domcontentloaded' | 'networkidle') ?? 'load', + timeout: timeout_ms ?? 30_000, + }) + const snap = await page.snapshot() + return { + agentmark: snap.agentmark, + url: page.url(), + title: snap.snapshot.title, + kind: snap.snapshot.kind ?? 'webpage', + action_count: Object.keys(snap.snapshot.actions ?? {}).length, + bytes: snap.agentmark.length, + captured_at: snap.capturedAt.toISOString(), + } + } finally { + await browser.close() + } + }, +}) diff --git a/pieces/agentmark/src/lib/common.ts b/pieces/agentmark/src/lib/common.ts new file mode 100644 index 0000000..45e8a98 --- /dev/null +++ b/pieces/agentmark/src/lib/common.ts @@ -0,0 +1,63 @@ +/** + * Shared helpers for AgentMark Activepieces actions. + */ + +import { readFile } from 'node:fs/promises' +import { fetch } from 'undici' +import * as path from 'node:path' + +/** + * Resolve a "PDF source" prop into raw bytes. The piece accepts any of: + * - HTTP(S) URL → fetched + * - File path / file:// URI → read from disk (Activepieces workers run with + * filesystem access; flows that move files use temp paths) + * - data: URI with base64 payload → decoded inline + * - Bare base64 string (no scheme) → decoded as PDF bytes + */ +export async function resolveBytes(source: string): Promise { + if (!source) throw new Error('Empty source') + + if (source.startsWith('http://') || source.startsWith('https://')) { + const res = await fetch(source) + if (!res.ok) { + throw new Error(`Fetch failed: ${res.status} ${res.statusText} (${source})`) + } + const buf = Buffer.from(await res.arrayBuffer()) + return new Uint8Array(buf) + } + + if (source.startsWith('data:')) { + const commaAt = source.indexOf(',') + if (commaAt === -1) throw new Error('Malformed data URI') + const header = source.slice(5, commaAt) + const payload = source.slice(commaAt + 1) + if (header.includes(';base64')) { + return new Uint8Array(Buffer.from(payload, 'base64')) + } + return new Uint8Array(Buffer.from(decodeURIComponent(payload), 'utf8')) + } + + if (source.startsWith('file://')) { + const fp = new URL(source).pathname + const buf = await readFile(fp) + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) + } + + // Heuristic for "looks like a path": contains a path separator, doesn't + // contain whitespace, and ends with a likely extension. Anything else + // gets decoded as base64. + const looksLikePath = + (source.includes('/') || source.includes('\\')) + && !/\s/.test(source) + && /\.[a-z0-9]{2,5}$/i.test(source) + if (looksLikePath) { + const buf = await readFile(path.resolve(source)) + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) + } + + return new Uint8Array(Buffer.from(source, 'base64')) +} + +export function bytesToBase64DataUri(bytes: Uint8Array, mime = 'application/pdf'): string { + return `data:${mime};base64,${Buffer.from(bytes).toString('base64')}` +} diff --git a/pieces/agentmark/test/piece.test.ts b/pieces/agentmark/test/piece.test.ts new file mode 100644 index 0000000..e02f3a1 --- /dev/null +++ b/pieces/agentmark/test/piece.test.ts @@ -0,0 +1,300 @@ +/** + * Tests for the AgentMark Activepieces piece. + * + * Verifies action shape (names, prop schemas, descriptions) and exercises + * fill_pdf_form end-to-end against an in-memory fillable PDF. The web-page + * snapshot action launches Chromium and is gated on AGENTMARK_INTEGRATION + * to keep CI fast. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { PDFDocument, StandardFonts } from 'pdf-lib' +import { agentmark } from '../src/index' +import { fillPdfForm } from '../src/lib/actions/fill-pdf-form' +import { snapshotPdf } from '../src/lib/actions/snapshot-pdf' +import { snapshotWebPage } from '../src/lib/actions/snapshot-web-page' + +const tmpFiles: string[] = [] + +function tmpPath(suffix = '.pdf'): string { + const p = path.join( + os.tmpdir(), + `agentmark-piece-test-${process.pid}-${Date.now()}-${Math.random()}${suffix}`, + ) + tmpFiles.push(p) + return p +} + +async function buildFillableForm(target: string): Promise { + const doc = await PDFDocument.create() + doc.setTitle('Piece Test Form') + const page = doc.addPage([595, 842]) + const font = await doc.embedFont(StandardFonts.Helvetica) + const form = doc.getForm() + + const tf = form.createTextField('company') + tf.addToPage(page, { x: 50, y: 700, width: 200, height: 18, font }) + + const cb = form.createCheckBox('agree') + cb.addToPage(page, { x: 50, y: 650, width: 12, height: 12 }) + + const bytes = await doc.save() + await fs.writeFile(target, bytes) +} + +afterEach(async () => { + for (const p of tmpFiles.splice(0)) { + await fs.unlink(p).catch(() => {}) + } +}) + +// ────────────────────────────────────────────────────────────────────────── +// Piece metadata + action shape +// ────────────────────────────────────────────────────────────────────────── + +describe('AgentMark Activepieces piece', () => { + it('declares display name + minimum release', () => { + expect(agentmark.displayName).toBe('AgentMark') + expect(agentmark.minimumSupportedRelease).toBeDefined() + expect('auth' in agentmark).toBe(true) + }) + + it('exposes the three v1 actions', () => { + const actions = Object.keys(agentmark.actions()) + expect(actions).toEqual( + expect.arrayContaining(['snapshot_web_page', 'snapshot_pdf', 'fill_pdf_form']), + ) + }) + + it('every action has a non-empty description and props schema', () => { + const actions = Object.values(agentmark.actions()) + for (const action of actions) { + expect(action.description.length).toBeGreaterThan(15) + expect(action.props).toBeDefined() + } + }) +}) + +describe('snapshot_web_page action shape', () => { + it('declares URL, wait_until, timeout_ms, headless props', () => { + const props = snapshotWebPage.props + expect(props.url).toBeDefined() + expect(props.wait_until).toBeDefined() + expect(props.timeout_ms).toBeDefined() + expect(props.headless).toBeDefined() + }) +}) + +describe('snapshot_pdf action shape', () => { + it('declares source, source_url, title, password, ocr props', () => { + const props = snapshotPdf.props + expect(props.source).toBeDefined() + expect(props.source_url).toBeDefined() + expect(props.title).toBeDefined() + expect(props.password).toBeDefined() + expect(props.enable_ocr).toBeDefined() + expect(props.ocr_language).toBeDefined() + }) +}) + +describe('fill_pdf_form action shape', () => { + it('declares source, values, flatten, return_format, password props', () => { + const props = fillPdfForm.props + expect(props.source).toBeDefined() + expect(props.values).toBeDefined() + expect(props.flatten).toBeDefined() + expect(props.return_format).toBeDefined() + expect(props.password).toBeDefined() + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// fill_pdf_form end-to-end (no browsers needed) +// ────────────────────────────────────────────────────────────────────────── + +/** + * Minimal Activepieces context for an action `run`. Just enough surface + * to invoke our actions; we don't exercise context.server / context.flows. + */ +function fakeContext>(propsValue: T) { + return { propsValue } as unknown as Parameters[0] +} + +describe('fill_pdf_form — end-to-end', () => { + it('fills fields by action ID and returns a valid base64 data URI', async () => { + const pdfPath = tmpPath() + await buildFillableForm(pdfPath) + + const result = await fillPdfForm.run( + fakeContext({ + source: pdfPath, + values: { act_field_1: 'Acme Inc.', act_field_2: true }, + flatten: false, + return_format: 'data_uri', + }), + ) + + expect(result.fields_applied.length).toBe(2) + expect(result.fields_skipped).toEqual([]) + expect(result.filled_pdf.startsWith('data:application/pdf;base64,')).toBe(true) + expect(result.bytes).toBeGreaterThan(100) + }) + + it('fills fields by original field name (not just action ID)', async () => { + const pdfPath = tmpPath() + await buildFillableForm(pdfPath) + + const result = await fillPdfForm.run( + fakeContext({ + source: pdfPath, + values: { company: 'Inc by Name', agree: true }, + flatten: false, + return_format: 'data_uri', + }), + ) + expect(result.fields_applied.length).toBe(2) + const resolved = result.fields_applied.map((s) => s.resolved_action_id).sort() + expect(resolved).toEqual(['act_field_1', 'act_field_2']) + }) + + it('reports unknown keys via fields_skipped (does not throw)', async () => { + const pdfPath = tmpPath() + await buildFillableForm(pdfPath) + + const result = await fillPdfForm.run( + fakeContext({ + source: pdfPath, + values: { + company: 'Real', + nonexistent_field: 'ignored', + another_missing: 42, + }, + flatten: false, + return_format: 'data_uri', + }), + ) + expect(result.fields_applied.length).toBe(1) + expect(result.fields_skipped).toEqual(['nonexistent_field', 'another_missing']) + }) + + it('return_format=base64 returns raw base64 (no data URI prefix)', async () => { + const pdfPath = tmpPath() + await buildFillableForm(pdfPath) + + const result = await fillPdfForm.run( + fakeContext({ + source: pdfPath, + values: { company: 'Plain' }, + flatten: false, + return_format: 'base64', + }), + ) + expect(result.filled_pdf.startsWith('data:')).toBe(false) + // base64 alphabet only + expect(result.filled_pdf).toMatch(/^[A-Za-z0-9+/=]+$/) + }) + + it('flatten: true removes the form so the result is no longer fillable', async () => { + const pdfPath = tmpPath() + await buildFillableForm(pdfPath) + + const result = await fillPdfForm.run( + fakeContext({ + source: pdfPath, + values: { company: 'Flattened' }, + flatten: true, + return_format: 'base64', + }), + ) + expect(result.flattened).toBe(true) + // Re-load via pdf-lib and check the form has no fields + const filled = Buffer.from(result.filled_pdf, 'base64') + const reloaded = await PDFDocument.load(filled) + expect(reloaded.getForm().getFields().length).toBe(0) + }) + + it('accepts a base64 data URI as source (no temp file required)', async () => { + const pdfPath = tmpPath() + await buildFillableForm(pdfPath) + const bytes = await fs.readFile(pdfPath) + const dataUri = `data:application/pdf;base64,${bytes.toString('base64')}` + + const result = await fillPdfForm.run( + fakeContext({ + source: dataUri, + values: { company: 'From Data URI' }, + flatten: false, + return_format: 'data_uri', + }), + ) + expect(result.fields_applied.length).toBe(1) + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// snapshot_pdf end-to-end (no OCR — keeps test fast) +// ────────────────────────────────────────────────────────────────────────── + +describe('snapshot_pdf — end-to-end (text PDF, no OCR)', () => { + it('returns kind: form when AcroForm fields are present', async () => { + const pdfPath = tmpPath() + await buildFillableForm(pdfPath) + + const result = await snapshotPdf.run( + fakeContext({ + source: pdfPath, + source_url: 'file:///tmp/test.pdf', + enable_ocr: false, + }), + ) + + expect(result.bytes).toBeGreaterThan(50) + expect(result.agentmark).toContain('kind: form') + expect(result.source_url).toBe('file:///tmp/test.pdf') + expect(result.ocr_used).toBe(false) + }) +}) + +// ────────────────────────────────────────────────────────────────────────── +// snapshot_web_page — gated on real Chromium +// ────────────────────────────────────────────────────────────────────────── + +const RUN_BROWSER_TESTS = process.env.AGENTMARK_INTEGRATION === '1' + +describe.runIf(RUN_BROWSER_TESTS)('snapshot_web_page — real Chromium', () => { + let serverUrl: string + let server: import('node:http').Server + + beforeAll(async () => { + const http = await import('node:http') + server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end('Test

Hi

') + }) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + const addr = server.address() + if (!addr || typeof addr === 'string') throw new Error('no addr') + serverUrl = `http://127.0.0.1:${addr.port}` + }) + + afterAll(async () => { + await new Promise((r) => server.close(() => r())) + }) + + it('captures a real page through the action', async () => { + const result = await snapshotWebPage.run( + fakeContext({ + url: serverUrl, + wait_until: 'load', + timeout_ms: 15_000, + headless: true, + }), + ) + expect(result.title).toBe('Test') + expect(result.bytes).toBeGreaterThan(50) + expect(result.agentmark).toContain('kind: webpage') + }) +}) diff --git a/pieces/agentmark/tsconfig.lib.json b/pieces/agentmark/tsconfig.lib.json new file mode 100644 index 0000000..7f09f03 --- /dev/null +++ b/pieces/agentmark/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022", "dom"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "preserveSymlinks": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8d370a2 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config' + +/** + * Root vitest config. + * + * Default `exclude` is fine for `node_modules` BUT our `pieces/agentmark` + * subdirectory has its own `node_modules/@thinkfleet/agentmark` symlinked + * back to the root. That makes vitest's globs recurse into the piece's + * test directory through the symlink and run root tests *twice* — once + * normally and once from the piece's module-resolution graph, which loads + * separate module instances and breaks `instanceof` checks. Explicitly + * exclude any sibling package's tests; each package runs its own suite. + */ +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + 'pieces/**', + ], + }, +})