Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[env]
LIBCLANG_PATH = "/opt/homebrew/opt/llvm/lib"
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: CI

on:
push:
branches: [main, master]
pull_request:
branches: [main, master]

jobs:
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4

- name: Install Rust (nightly)
uses: dtolnay/rust-toolchain@nightly

- name: Cache cargo
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.os }}

- name: Install system dependencies (linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libssl-dev pkg-config

- name: Run tests
run: cargo test --all-features
23 changes: 22 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
**/*.rs.bk
Cargo.lock
.DS_Store
**/.DS_Store

# Adapter build artifacts
adapters/python/pardus-playwright/*.egg-info/
Expand All @@ -13,4 +14,24 @@ adapters/node/pardus-puppeteer/dist/
adapters/node/pardus-playwright/node_modules/
adapters/node/pardus-playwright/dist/

.env
# Tauri frontend
crates/pardus-tauri/node_modules/
crates/pardus-tauri/dist/

# Web dashboard
web/node_modules/
web/dist/

# AI agent
ai-agent/pardus-browser/node_modules/
ai-agent/pardus-browser/dist/

# Lockfiles
**/package-lock.json

.env
.env.*
!.env.example

# Research benchmark data
research/
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
members = [
"crates/pardus-core",
"crates/pardus-cdp",
"crates/pardus-challenge",
"crates/pardus-cli",
"crates/pardus-debug",
"crates/pardus-kg",
"crates/pardus-server",
"crates/pardus-tauri/src-tauri",
]
resolver = "2"

Expand All @@ -27,6 +30,8 @@ futures-util = "0.3"
blake3 = "1"
lol_html = "2"
reqwest = { version = "0.12", features = ["cookies", "gzip", "brotli", "deflate", "json"] }
rquest = { version = "5", features = ["cookies", "gzip", "brotli", "deflate", "json", "stream", "socks"] }
rquest-util = "2"
parking_lot = "0.12"
base64 = "0.22"
async-trait = "0.1"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ rustup install nightly
# Clone and build
git clone https://github.com/user/pardus-browser.git
cd pardus-browser
cargo +nightly install --path crates/pardus-cli --feature js
cargo +nightly install --path crates/pardus-cli --features js
```

### Docker
Expand Down
22 changes: 11 additions & 11 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ _(Currently empty)_
## Planned (Near-term)

### Screenshots (Optional)
- [ ] HTML→PNG rendering — For when pixels actually matter
- [ ] Element screenshots — Capture specific element bounds
- [ ] Viewport clipping — Configurable resolution
- [ ] CDP screenshot API — Page.captureScreenshot compliance
- [x] HTML→PNG rendering — For when pixels actually matter
- [x] Element screenshots — Capture specific element bounds
- [x] Viewport clipping — Configurable resolution
- [x] CDP screenshot API — Page.captureScreenshot compliance

---

Expand Down Expand Up @@ -79,17 +79,17 @@ _(Currently empty)_

### Network & Protocol

- [ ] **Request interception** — Intercept, modify, or block requests before they're sent (URL rewrite, header injection, body substitution)
- [ ] **Response mocking** — Return canned responses for specific URL patterns; useful for testing agents against controlled data
- [ ] **Request deduplication** — Avoid parallel fetches of the same resource within a time window
- [ ] **Retry with backoff** — Configurable retry policy for transient failures (5xx, timeout, connection reset)
- [ ] **Cookie jar API** — Full programmatic cookie management (list, set, delete, domain filtering) via CLI, CDP, and library
- [x] **Request interception** — Intercept, modify, or block requests before they're sent (URL rewrite, header injection, body substitution)
- [x] **Response mocking** — Return canned responses for specific URL patterns; useful for testing agents against controlled data
- [x] **Request deduplication** — Avoid parallel fetches of the same resource within a time window
- [x] **Retry with backoff** — Configurable retry policy for transient failures (5xx, timeout, connection reset)
- [x] **Cookie jar API** — Full programmatic cookie management (list, set, delete, domain filtering) via CLI, CDP, and library
- [ ] **Auth token rotation** — Auto-refresh expiring Bearer tokens when 401 is received; configurable refresh endpoint/callback

### Web Standards & Content

- [ ] **PDF text extraction** — Parse PDF bytes to semantic tree (already partially implemented in `pdf.rs`); extend with table, form-field, and image extraction
- [ ] **RSS/Atom feed parsing** — Detect and parse feed content into structured items (title, link, date, summary)
- [x] **PDF text extraction** — Parse PDF bytes to semantic tree with table, form-field (AcroForm), and image metadata extraction
- [x] **RSS/Atom feed parsing** — Detect and parse RSS/Atom feed content into structured items (title, link, date, summary)
- [ ] **Robots.txt parser** — Respect crawl directives; expose `is_allowed(url)` for the knowledge graph crawler
- [ ] **Meta refresh & redirects** — Parse `<meta http-equiv="refresh">` and JS `location.href` assignments as navigations
- [ ] **Content encoding** — Handle gzip/brotli/zstd transfer encodings beyond what reqwest provides automatically
Expand Down
143 changes: 143 additions & 0 deletions ai-agent/pardus-browser/src/__tests__/core/CookieStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { CookieStore } from '../../core/CookieStore.js';
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

describe('CookieStore', () => {
let store: CookieStore;
let testDir: string;

beforeEach(() => {
testDir = join(tmpdir(), `pardus-test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
mkdirSync(testDir, { recursive: true });
store = new CookieStore(testDir);
});

afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});

describe('saveCookies / loadCookies', () => {
it('should save and load cookies round-trip', () => {
const cookies = [
{ name: 'session', value: 'abc123', domain: '.example.com', path: '/', sameSite: 'Lax' as const },
{ name: 'token', value: 'xyz789', domain: '.example.com', path: '/api', secure: true, httpOnly: true },
];

store.saveCookies('test-profile', cookies);

const loaded = store.loadCookies('test-profile');
assert.strictEqual(loaded.length, 2);
assert.strictEqual(loaded[0].name, 'session');
assert.strictEqual(loaded[0].value, 'abc123');
assert.strictEqual(loaded[0].domain, '.example.com');
assert.strictEqual(loaded[1].name, 'token');
assert.strictEqual(loaded[1].secure, true);
assert.strictEqual(loaded[1].httpOnly, true);
});

it('should return empty array for non-existent profile', () => {
const loaded = store.loadCookies('non-existent');
assert.deepStrictEqual(loaded, []);
});

it('should return empty array for empty profile name', () => {
const loaded = store.loadCookies('');
assert.deepStrictEqual(loaded, []);
});

it('should overwrite existing cookies on save', () => {
store.saveCookies('test', [{ name: 'a', value: '1' }]);
store.saveCookies('test', [{ name: 'b', value: '2' }, { name: 'c', value: '3' }]);

const loaded = store.loadCookies('test');
assert.strictEqual(loaded.length, 2);
assert.strictEqual(loaded[0].name, 'b');
});

it('should not save empty cookie array', () => {
store.saveCookies('test', []);

const loaded = store.loadCookies('test');
assert.deepStrictEqual(loaded, []);
});

it('should not save when profile is empty', () => {
store.saveCookies('', [{ name: 'a', value: '1' }]);

const loaded = store.loadCookies('');
assert.deepStrictEqual(loaded, []);
});

it('should create the profile directory if it does not exist', () => {
store.saveCookies('new-profile', [{ name: 'test', value: 'val' }]);

const loaded = store.loadCookies('new-profile');
assert.strictEqual(loaded.length, 1);
});
});

describe('corrupt data handling', () => {
it('should return empty array for corrupt JSON', () => {
const profileDir = join(testDir, 'corrupt');
mkdirSync(profileDir, { recursive: true });
writeFileSync(join(profileDir, 'cookies.json'), 'not valid json{{{');

const loaded = store.loadCookies('corrupt');
assert.deepStrictEqual(loaded, []);
});

it('should return empty array when cookies field is missing', () => {
const profileDir = join(testDir, 'no-cookies');
mkdirSync(profileDir, { recursive: true });
writeFileSync(join(profileDir, 'cookies.json'), JSON.stringify({ savedAt: Date.now() }));

const loaded = store.loadCookies('no-cookies');
assert.deepStrictEqual(loaded, []);
});
});

describe('deleteProfile', () => {
it('should delete an existing profile', () => {
store.saveCookies('to-delete', [{ name: 'a', value: '1' }]);

assert.ok(store.loadCookies('to-delete').length > 0);
store.deleteProfile('to-delete');

const loaded = store.loadCookies('to-delete');
assert.deepStrictEqual(loaded, []);
});

it('should not throw for non-existent profile', () => {
assert.doesNotThrow(() => store.deleteProfile('non-existent'));
});
});

describe('listProfiles', () => {
it('should list profiles that have cookies', () => {
store.saveCookies('profile-a', [{ name: 'a', value: '1' }]);
store.saveCookies('profile-b', [{ name: 'b', value: '2' }]);

const profiles = store.listProfiles();
assert.ok(profiles.includes('profile-a'));
assert.ok(profiles.includes('profile-b'));
assert.strictEqual(profiles.length, 2);
});

it('should not list profiles with empty cookies (not saved)', () => {
store.saveCookies('empty', []);

const profiles = store.listProfiles();
assert.ok(!profiles.includes('empty'));
});

it('should return empty array when no profiles exist', () => {
const profiles = store.listProfiles();
assert.deepStrictEqual(profiles, []);
});
});
});
9 changes: 9 additions & 0 deletions ai-agent/pardus-browser/src/__tests__/llm/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ describe('Prompts', () => {
assert.ok(SYSTEM_PROMPT.includes('browser_close'));
assert.ok(SYSTEM_PROMPT.includes('browser_list'));
assert.ok(SYSTEM_PROMPT.includes('browser_get_state'));
assert.ok(SYSTEM_PROMPT.includes('browser_get_action_plan'));
assert.ok(SYSTEM_PROMPT.includes('browser_auto_fill'));
assert.ok(SYSTEM_PROMPT.includes('browser_wait'));
assert.ok(SYSTEM_PROMPT.includes('browser_get_cookies'));
assert.ok(SYSTEM_PROMPT.includes('browser_set_cookie'));
});

it('should mention correct tool count', () => {
assert.ok(SYSTEM_PROMPT.includes('19 browser tools'));
});

it('should have workflow steps', () => {
Expand Down
30 changes: 30 additions & 0 deletions ai-agent/pardus-browser/src/__tests__/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,36 @@ export function createMockBrowserManager(): BrowserManager {
title: 'Example',
markdown: ''
}),
getActionPlan: async () => ({
success: true,
actionPlan: {
url: instanceUrls.get(id) || 'https://example.com',
suggestions: [
{ action_type: 'Click', reason: 'Submit button found', confidence: 0.95, selector: 'button[type="submit"]' },
{ action_type: 'Fill', reason: 'Empty email field', confidence: 0.9, selector: 'input[name="email"]', label: 'Email' },
],
page_type: 'FormPage',
has_forms: true,
has_pagination: false,
interactive_count: 5,
},
}),
autoFill: async () => ({
success: true,
filledFields: [
{ field_name: 'email', value: 'test@example.com', matched_by: 'ByName' },
{ field_name: 'password', value: 'secret123', matched_by: 'ByType' },
],
unmatchedFields: [
{ field_type: 'text', label: 'Phone', placeholder: 'Enter phone', required: false, field_name: 'phone' },
],
}),
wait: async (condition: string) => ({
success: true,
satisfied: true,
condition,
reason: condition === 'contentLoaded' ? 'content-loaded' : 'content-stable',
}),
kill: () => {},
on: function(event: string, handler: () => void) {
return this;
Expand Down
Loading
Loading