Skip to content
Draft
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
102 changes: 102 additions & 0 deletions connectors/bowmark/endpoints/library/endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { assert, assertEquals, assertRejects } from "@std/assert";
import type { Json } from "@shared/core";
import { fromFileUrl } from "@std/path";
import {
liveSkip,
loadFixture,
runEndpoint,
testBundle,
testSealedUnit,
} from "@shared/testing";

const fixturesDir = fromFileUrl(new URL("../../fixtures/", import.meta.url));

Deno.test("bowmark: two endpoints, one auth fn, dollar pool, no consolidate", async () => {
const bundle = await testBundle();
const ids = Object.keys(bundle.endpoints).filter((id) =>
id.startsWith("bowmark#")
).sort();
assertEquals(ids, ["bowmark#library", "bowmark#run"]);

const library = bundle.endpoints["bowmark#library"];
const run = bundle.endpoints["bowmark#run"];
assertEquals(library.request.method, "GET");
assertEquals(
library.request.url,
"https://api.bowmark.ai/v1/monid/library",
);
assertEquals(run.request.method, "POST");
assertEquals(run.request.url, "https://api.bowmark.ai/v1/monid/run");

assertEquals(library.auth.inject.$fn.key, run.auth.inject.$fn.key);
assertEquals(library.usage.consolidate, undefined);
assertEquals(run.usage.consolidate, undefined);
assertEquals(library.usage.model.kind, "FREE");
assertEquals(run.usage.model.kind, "PER_UNIT");
assertEquals(Object.keys(run.usage.credits), ["default"]);
});

Deno.test("bowmark#library happy (recorded): JSON envelope, free", async () => {
const unit = await testSealedUnit("bowmark#library");
const fixture = await loadFixture(`${fixturesDir}library-flights.json`);
const result = await runEndpoint({
unit,
input: { queryParams: { query: "flights" } },
mode: "replay",
fixture,
});
assertEquals(result.httpStatus, 200);
assertEquals(result.isProviderError, false);
assertEquals(result.usage, { credits: {}, evidence: {} });
const output = result.output as Record<string, unknown>;
assertEquals(output.query, "flights");
assert(
typeof output.library === "string" &&
(output.library as string).includes("bowmark.flights"),
"library markdown names the capability",
);
});

Deno.test("bowmark#library: query optional but never empty; unknown keys rejected", async () => {
const unit = await testSealedUnit("bowmark#library");
const fixture = await loadFixture(`${fixturesDir}library-flights.json`);
const rejected: Record<string, Json>[] = [
{ query: "" },
{ query: "x", bogus: 1 },
];
for (const queryParams of rejected) {
await assertRejects(
() =>
runEndpoint({
unit,
input: { queryParams },
mode: "replay",
fixture,
}),
Error,
"INVALID_INPUT",
JSON.stringify(queryParams),
);
}
});

Deno.test({
name: "bowmark#library live (gated on BOWMARK_API_KEY)",
ignore: liveSkip("bowmark"),
fn: async () => {
const unit = await testSealedUnit("bowmark#library");
const result = await runEndpoint({
unit,
input: { queryParams: { query: "flights" } },
mode: "live",
});
assertEquals(
result.isProviderError,
false,
JSON.stringify(result.output),
);
assertEquals(result.usage, { credits: {}, evidence: {} });
const output = result.output as Record<string, unknown>;
assertEquals(typeof output.library, "string");
},
});
44 changes: 44 additions & 0 deletions connectors/bowmark/endpoints/library/endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { defineEndpoint, UsageModelKind } from "@shared/core";
import { zBowmarkLibraryQueryParams } from "./schema/inputs.ts";

/**
* GET /v1/library: the callable vocabulary, as Markdown inside JSON.
*
* `Accept: application/json` selects the JSON envelope
* `{library, query, queries}`; without it Bowmark answers raw Markdown.
* Free: a library read touches no website (design D3).
*/
export default defineEndpoint({
meta: {
displayName: "Bowmark Library",
summary:
"Look up which Bowmark functions cover a task, with their exact types.",
description: "Returns the part of Bowmark's function library that " +
"matches a task or a site: capabilities such as " +
"`bowmark.flights.search` that fan out across several sites, " +
"plus that site's own provider (`bowmark.providers.kayak`) " +
"when you named one, each with argument and return types and " +
"examples, as Markdown in `library`. Touches no website and " +
"is free, so call it first, once per part of the task. A " +
"query that matches nothing returns an index of the whole " +
"library, never an error. Then write a script against these " +
"names and send it to Bowmark /run.",
docsUrl: "https://bowmark.ai/docs/quickstart",
categories: ["web-automation"],
notes: ["Free. A library read never touches a website."],
},
request: {
method: "GET",
path: "/library",
headers: { Accept: "application/json" },
},
input: {
schema: {
queryParams: zBowmarkLibraryQueryParams.extend({
query: zBowmarkLibraryQueryParams.shape.query.unwrap()
.min(1).optional(),
}),
},
},
usage: { model: { kind: UsageModelKind.FREE } },
});
16 changes: 16 additions & 0 deletions connectors/bowmark/endpoints/library/schema/inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { z } from "zod";

/**
* GET /v1/library query params (bowmark.ai/docs, 2026-10-10). Mirror
* carries optionality only (D25). Bowmark also accepts `query` repeated
* (one lookup per value, up to 6); this connector exposes the single form.
*/
export const zBowmarkLibraryQueryParams = z.object({
query: z.string().describe(
'What the task needs, in plain words ("flights", "price a ' +
'GPU", "book a table") or a site ("kayak.com", ' +
'"Newegg"). Given only a URL, pass its hostname. A query ' +
"that matches nothing returns a one-line index of the whole " +
"library rather than an error.",
).optional(),
}).strict();
106 changes: 106 additions & 0 deletions connectors/bowmark/endpoints/run/endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { assertEquals, assertRejects } from "@std/assert";
import type { Json } from "@shared/core";
import { fromFileUrl } from "@std/path";
import {
liveSkip,
loadFixture,
runEndpoint,
testSealedUnit,
} from "@shared/testing";

const fixturesDir = fromFileUrl(new URL("../../fixtures/", import.meta.url));
const SCRIPT = 'const page = await bowmark.read.page("https://example.com"); ' +
"return { title: page.title, url: page.url };";

Deno.test("bowmark#run happy (recorded): status ok bills one run", async () => {
const unit = await testSealedUnit("bowmark#run");
const fixture = await loadFixture(`${fixturesDir}run-ok.json`);
const result = await runEndpoint({
unit,
input: { body: { script: SCRIPT } },
mode: "replay",
fixture,
});
assertEquals(result.httpStatus, 200);
assertEquals(result.isProviderError, false);
assertEquals(result.usage, {
credits: { default: 0.04 },
evidence: { RESULT: 1 },
});
const output = result.output as Record<string, unknown>;
assertEquals(output.ok, true);
assertEquals(output.status, "ok");
assertEquals(output.result, {
url: "https://example.com/",
title: "Example Domain",
});
});

Deno.test("bowmark#run failed run (recorded): HTTP 200, status error, bills 0", async () => {
const unit = await testSealedUnit("bowmark#run");
const fixture = await loadFixture(`${fixturesDir}run-error.json`);
const result = await runEndpoint({
unit,
input: { body: { script: 'throw new Error("no such flight")' } },
mode: "replay",
fixture,
});
assertEquals(result.httpStatus, 200);
assertEquals(result.isProviderError, false);
assertEquals(result.usage, { credits: {}, evidence: { RESULT: 0 } });
const output = result.output as Record<string, unknown>;
assertEquals(output.status, "error");
assertEquals(output.result, null);
});

Deno.test("bowmark#run provider error (recorded 401): data, zero usage", async () => {
const unit = await testSealedUnit("bowmark#run");
const fixture = await loadFixture(`${fixturesDir}unauthorized-run.json`);
const result = await runEndpoint({
unit,
input: { body: { script: "return 1" } },
mode: "replay",
fixture,
});
assertEquals(result.httpStatus, 401);
assertEquals(result.isProviderError, true);
assertEquals(result.usage, { credits: {}, evidence: {} });
assertEquals((result.output as Record<string, unknown>).ok, false);
});

Deno.test("bowmark#run: script required and non-empty; unknown keys rejected", async () => {
const unit = await testSealedUnit("bowmark#run");
const fixture = await loadFixture(`${fixturesDir}run-ok.json`);
const rejected: Json[] = [{}, { script: "" }, { script: "x", bogus: 1 }];
for (const body of rejected) {
await assertRejects(
() =>
runEndpoint({ unit, input: { body }, mode: "replay", fixture }),
Error,
"INVALID_INPUT",
JSON.stringify(body),
);
}
});

Deno.test({
name: "bowmark#run live (gated on BOWMARK_API_KEY)",
ignore: liveSkip("bowmark"),
fn: async () => {
const unit = await testSealedUnit("bowmark#run");
const result = await runEndpoint({
unit,
input: { body: { script: "return 1 + 1" } },
mode: "live",
});
assertEquals(
result.isProviderError,
false,
JSON.stringify(result.output),
);
const output = result.output as Record<string, unknown>;
assertEquals(output.status, "ok");
assertEquals(output.result, 2);
assertEquals(result.usage.evidence, { RESULT: 1 });
},
});
73 changes: 73 additions & 0 deletions connectors/bowmark/endpoints/run/endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { defineEndpoint, Unit, UsageModelKind } from "@shared/core";
import { zBowmarkRunBody } from "./schema/inputs.ts";

/**
* POST /v1/run: execute a script against the live sites.
*
* Synchronous: Bowmark holds the request until the run finishes (its own
* run ceiling is 120 s), so the timeouts sit just above that.
*
* Usage (design D2): PER_UNIT RESULT, one per run that produced a result
* (`status` "ok" or "partial"). An `error` run, a `needs_user` pause and
* every non-2xx bill 0.
*/
export default defineEndpoint({
meta: {
displayName: "Bowmark Run",
summary:
"Run a short script on live websites and get structured data back.",
description: "Executes a JavaScript body against the `bowmark` " +
"global on Bowmark's machines and returns `{ok, status, " +
"result, logs, error, ms, runId}`. Call Bowmark /library " +
"first for the exact function names. Branch on `status`, " +
'not `ok`: "ok" is a whole answer; "partial" keeps ok ' +
"true and a real `result` but `incomplete` names what did " +
"not answer, so say the answer is narrower than asked; " +
'"needs_user" means a site needs the account owner signed ' +
"in (`meta.handoff.url` is the link for a human; send the " +
'same script again afterwards); "error" means there is no ' +
"result and `error` says why. `incomplete.failures[].fixable` " +
"true means your argument was wrong: fix it and rerun. Keep " +
"results small (under ~25,000 characters).",
docsUrl: "https://bowmark.ai/docs/quickstart",
categories: ["web-automation"],
notes: [
"Flat price per run that returns a result (status ok or " +
"partial). A run that errors or pauses for a sign-in is free.",
],
},
request: { method: "POST", path: "/run" },
input: {
schema: {
body: zBowmarkRunBody.extend({
script: zBowmarkRunBody.shape.script.unwrap().min(1),
}),
},
},
timeouts: { requestMs: 130_000, runMs: 135_000 },
usage: {
model: {
kind: UsageModelKind.PER_UNIT,
unit: Unit.RESULT,
label: "runs with a result",
description: "runs that finished with status ok or partial",
consumes: { credit: "default", amount: 0.04 },
},
/** The hold promises one run. */
estimate: () => ({ counts: { RESULT: 1 } }),
/** 1 for a run whose `status` is "ok" or "partial", 0 otherwise
* (error, needs_user, or a body that is not an object). */
evidence: ({ data }) => {
const body = data.output;
if (
typeof body !== "object" || body === null ||
Array.isArray(body)
) {
return { counts: { RESULT: 0 } };
}
const status = (body as Record<string, unknown>).status;
const done = status === "ok" || status === "partial";
return { counts: { RESULT: done ? 1 : 0 } };
},
},
});
20 changes: 20 additions & 0 deletions connectors/bowmark/endpoints/run/schema/inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from "zod";

/**
* POST /v1/run body (bowmark.ai/docs, 2026-10-10). Mirror carries
* optionality only (D25); the binding requires `script`.
*/
export const zBowmarkRunBody = z.object({
script: z.string().describe(
"A plain async JavaScript body (not a wrapping function) " +
"written against the `bowmark` global, using the exact " +
"names Bowmark /library returned. `return` the value you " +
"want back; `log(...)` for progress lines. Built-ins plus " +
"URL/URLSearchParams exist; fetch, import, setTimeout and a " +
"filesystem do not: `bowmark` is the only I/O. Run several " +
"calls together with Promise.allSettled, never one after " +
"another. Example: `const { flights } = await " +
"bowmark.flights.search({ from: 'SFO', to: 'JFK', depart: " +
"'2026-11-02' }); return flights.slice(0, 5);`",
).optional(),
}).strict();
22 changes: 22 additions & 0 deletions connectors/bowmark/fixtures/library-flights.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "library-flights",
"description": "Recorded 2026-10-10 against GET https://api.bowmark.ai/v1/monid/library?query=flights with Accept: application/json. The Markdown `library` string is trimmed by the recorder. Free: zero usage.",
"calls": [
{
"req": {
"method": "GET",
"url": "https://api.bowmark.ai/v1/monid/library?query=flights"
},
"res": {
"status": 200,
"body": {
"library": "**Found:** `bowmark.flights`.\n\nAlways in scope, no import: `log(...args)` — progress lines; `URL` and `URLSearchParams`; `bowmark.files` — keep a file the run produces and get a link.\n\n# Capability: Flights (call as `bowmark.flights`)\n\nSearch flights with one call and get back normalized, price-sorted results (the same physical flight appears once). Each result carries the site it came from (`site`) and every site it was found on (`sites`). Returns `{ flights, warnings }` — `warnings` names any",
"query": "flights",
"queries": [
"flights"
]
}
}
}
]
}
Loading