mirror of
https://github.com/paperclipai/paperclip-docs.git
synced 2026-11-07 06:16:07 -10:00
Connector catalog, setup guides, and shared access documentation, followed by a final code-accuracy repair pass. This PR does not change application behavior. ## Final accuracy pass Checked against shipped Paperclip `v2026.916.0`, commit `dffc2b3ca1b9e88fa21cb17493083e682dffd1ca`, rather than the dirty local checkout or the earlier canary baseline. - Corrects connector/method-specific identity choices, human audiences, and managed GitHub versus PAT scope and verification. - Explains that **Always allow** covers different arguments for the same agent/action/connection, with project scope when present; uses actual button labels and removes the test-panel control from review instructions. - Qualifies new-action quarantine, including safe-default read exceptions. Removes unconditional approval guarantees. - Separates provider channel/repository reach from Paperclip sender authorization. - Completes sandbox sign-in and model credential-selection instructions from the current UI implementation. - Repairs Notion's hub summary, removes incomplete optional Notion/Sentry client-registration procedures, and corrects Linear's provider-versus-Paperclip attribution. - Removes unsupported Postman tier claims, contradictory Cloudinary/Mem0 verification assertions, API-equivalence claims, and overbroad successful-test assurances. - Retains Supabase's required project reference: both released method definitions explicitly require it. An earlier review suggested otherwise; release code is authoritative here. - Updates the release baseline and removes draft commit chronology from the public introduction. ## Availability and remaining runtime gates - **Google:** Verification is pending. Per the product-owner clarification, authorization can continue where Google offers an Advanced option; this is not available for every account and does not override Workspace policy or other Google prerequisites. Notices on the hub and all Google guides now distinguish an unverified-app warning from blocked authorization. - **Slack is available but not yet fully functional; follow-up fixes are planned.** For agent tools, the released OAuth endpoint/scope defaults differ from Slack's documented MCP contract. The page now warns against treating that route as production-ready and removes the misleading bot-scope walkthrough. Chat-channel bot setup remains separate. Engineering must reconcile the integration and run OAuth plus one read before removing this warning. Source: https://docs.slack.dev/ai/slack-mcp-server/ - No credentialed connector run was performed in this pass. Code consistency, provider documentation, UI rendering, and successful live execution are separate evidence levels. No claim of runtime certification for all connectors. ## Validation - Root build: 253 crawlable documentation pages. - Full `docs:test`: all eight suites passed locally, including root/subpath routes, sitemap history, fingerprints, navigation, skill source blocks, crawlable links, doc-link resolution, and SEO metadata. - Internal-link lint: 258 Markdown files; no broken internal links. - Latest cleanup: full build, all eight docs:test suites, internal-link lint and diff whitespace checks passed; 1,878 link-resolution checks. - Fixed the Calendar MCP reference URL; browser-checked the five inconclusive external destinations (Miro article loads; Asana, Google Cloud, OpenAI and xAI reach sign-in/console screens). Authenticated destinations were not tested beyond those screens. - Replaced the Sandbox providers editorial TODO with a scope note and removed the CLI overview's unfinished-guide announcement. - Browser client navigation: 687 checks passed using installed Chrome. Initial launch lacked the downloaded Playwright browser; rerun used `DOCS_CHROMIUM_PATH` without changing the tests. - Nine representative connector/shared pages at 1440px and 390px: HTTP 200, one article H1, no page-level horizontal overflow. Mobile screenshot inspected; approval page opened in the Codex browser. - Navigation audit reports three existing non-connector orphan files; this patch does not alter those files or their navigation. The previous revision's provider-page expansion, Google shared setup guide, navigation additions, and accidental node_modules-symlink cleanup remain in this PR. Internal review ledgers remain outside the publication branch. No merge or production deployment authorized. --------- Co-authored-by: DevRel Gemini <[email protected]> Co-authored-by: Paperclip <[email protected]> Co-authored-by: Claude Opus 5 (1M context) <[email protected]> Co-authored-by: Michael Nguyen <[email protected]>
400 lines
17 KiB
JavaScript
400 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Drive the built site in a real browser and click the links.
|
|
*
|
|
* The relative-link bug in PAP-18407 survived both link linters because both
|
|
* of them reason about the same thing the build does: source paths and route
|
|
* maps. What broke was the client's own resolution of a link it re-rendered at
|
|
* runtime, and nothing exercised that. Clicking is the only check that would
|
|
* have caught it, so this script clicks.
|
|
*
|
|
* Covers, for the connector overview and a nested connector page:
|
|
* - direct static routes (fresh load of every destination URL);
|
|
* - clicking every internal link in an article, including a root document's
|
|
* links into a subdirectory;
|
|
* - relative links from a nested document (sibling, ../, and up-and-over);
|
|
* - anchors, in-page and cross-page;
|
|
* - back and forward;
|
|
* - refresh on a client-navigated route.
|
|
*
|
|
* Every page load and every navigation also asserts no console error, no page
|
|
* error, and no failed request.
|
|
*
|
|
* Not part of `npm run docs:test`: it needs a browser, and the docs test suite
|
|
* has to run where one is not installed. Run it with `npm run docs:test:client-nav`
|
|
* after `npm run docs:build`.
|
|
*
|
|
* Usage: node scripts/verify-client-navigation.mjs [--site .site] [--page /connectors/]
|
|
*/
|
|
|
|
import http from "node:http";
|
|
import { readFile, stat, access } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(SELF_DIR, "..");
|
|
|
|
function argValue(flag, fallback) {
|
|
const index = process.argv.indexOf(flag);
|
|
return index === -1 ? fallback : process.argv[index + 1];
|
|
}
|
|
|
|
const SITE_DIR = path.resolve(ROOT, argValue("--site", ".site"));
|
|
const OVERVIEW = argValue("--page", "/connectors/");
|
|
|
|
const MIME = {
|
|
".html": "text/html; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".md": "text/markdown; charset=utf-8",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".webp": "image/webp",
|
|
".xml": "application/xml; charset=utf-8",
|
|
".txt": "text/plain; charset=utf-8",
|
|
".woff2": "font/woff2",
|
|
};
|
|
|
|
async function exists(target) {
|
|
try {
|
|
await access(target);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Static server that behaves like the shipped .htaccess/nginx config rather
|
|
* than like python -m http.server: a directory serves its index.html, and an
|
|
* unknown path serves 404.html with a 404 status.
|
|
*/
|
|
async function startServer() {
|
|
const server = http.createServer(async (request, response) => {
|
|
const requestPath = decodeURIComponent(new URL(request.url, "http://localhost").pathname);
|
|
let filePath = path.join(SITE_DIR, requestPath);
|
|
if (!filePath.startsWith(SITE_DIR)) {
|
|
response.writeHead(403).end("Forbidden");
|
|
return;
|
|
}
|
|
try {
|
|
const info = await stat(filePath).catch(() => null);
|
|
if (info?.isDirectory()) filePath = path.join(filePath, "index.html");
|
|
if (!(await exists(filePath))) {
|
|
const notFound = path.join(SITE_DIR, "404.html");
|
|
const body = (await exists(notFound)) ? await readFile(notFound) : Buffer.from("Not found");
|
|
response.writeHead(404, { "content-type": MIME[".html"] }).end(body);
|
|
return;
|
|
}
|
|
const body = await readFile(filePath);
|
|
response.writeHead(200, { "content-type": MIME[path.extname(filePath)] ?? "application/octet-stream" }).end(body);
|
|
} catch (error) {
|
|
response.writeHead(500).end(String(error));
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const { port } = server.address();
|
|
return { server, origin: `http://127.0.0.1:${port}` };
|
|
}
|
|
|
|
async function launchBrowser() {
|
|
const { chromium } = await import("playwright");
|
|
// The bundled download for this platform is not always the one that runs
|
|
// here, so prefer an installed build and let Playwright's own resolution be
|
|
// the fallback.
|
|
const candidates = [
|
|
process.env.DOCS_CHROMIUM_PATH,
|
|
`${process.env.HOME}/.cache/ms-playwright/chromium-1234/chrome-linux/chrome`,
|
|
`${process.env.HOME}/.cache/ms-playwright/chromium-1208/chrome-linux/chrome`,
|
|
].filter(Boolean);
|
|
let executablePath;
|
|
for (const candidate of candidates) {
|
|
if (await exists(candidate)) {
|
|
executablePath = candidate;
|
|
break;
|
|
}
|
|
}
|
|
return chromium.launch({
|
|
...(executablePath ? { executablePath } : {}),
|
|
args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
|
|
});
|
|
}
|
|
|
|
let failures = 0;
|
|
let checks = 0;
|
|
|
|
function check(label, condition, detail = "") {
|
|
checks += 1;
|
|
if (condition) return true;
|
|
failures += 1;
|
|
console.error(` FAIL ${label}${detail ? `\n ${detail}` : ""}`);
|
|
return false;
|
|
}
|
|
|
|
const { server, origin } = await startServer();
|
|
const browser = await launchBrowser();
|
|
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
const page = await context.newPage();
|
|
|
|
// One collector for the whole run; snapshot its length around each navigation.
|
|
// Third-party requests are out of scope: the page asks api.github.com for a
|
|
// star count, which is unauthenticated and rate-limited, so whether it answers
|
|
// says nothing about this site's navigation. Same-origin failures are the
|
|
// signal, and those are never ignored.
|
|
const THIRD_PARTY = /^https?:\/\/(?!127\.0\.0\.1|localhost)/i;
|
|
const problems = [];
|
|
page.on("console", (message) => {
|
|
if (message.type() !== "error") return;
|
|
const text = message.text();
|
|
const url = message.location()?.url ?? "";
|
|
if (THIRD_PARTY.test(url) || /api\.github\.com/.test(text)) return;
|
|
problems.push(`console: ${text}`);
|
|
});
|
|
page.on("pageerror", (error) => problems.push(`pageerror: ${error.message}`));
|
|
page.on("requestfailed", (request) => {
|
|
if (THIRD_PARTY.test(request.url())) return;
|
|
problems.push(`requestfailed: ${request.url()}`);
|
|
});
|
|
page.on("response", (response) => {
|
|
if (THIRD_PARTY.test(response.url()) || response.status() < 400) return;
|
|
problems.push(`http ${response.status()}: ${response.url()}`);
|
|
});
|
|
|
|
function takeProblems() {
|
|
return problems.splice(0, problems.length);
|
|
}
|
|
|
|
async function articleState() {
|
|
return page.evaluate(() => {
|
|
const article = document.getElementById("article");
|
|
return {
|
|
h1: document.querySelector("#article h1")?.textContent?.trim() ?? "",
|
|
title: document.title,
|
|
route: location.pathname + location.hash,
|
|
text: (article?.textContent ?? "").slice(0, 400),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Wait until app.js has rendered an article with a heading. */
|
|
async function waitForArticle() {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const heading = document.querySelector("#article h1");
|
|
return Boolean(heading && heading.textContent && heading.textContent.trim().length > 0);
|
|
},
|
|
undefined,
|
|
{ timeout: 15000 },
|
|
);
|
|
}
|
|
|
|
function assertClean(label) {
|
|
const found = takeProblems();
|
|
return check(`${label}: no console, page, or request errors`, found.length === 0, found.join("\n "));
|
|
}
|
|
|
|
async function assertRendered(label, expectedRoute) {
|
|
await waitForArticle();
|
|
const state = await articleState();
|
|
check(`${label}: renders a heading`, state.h1.length > 0, JSON.stringify(state));
|
|
check(
|
|
`${label}: no load failure in the article`,
|
|
!state.text.includes("Could not load"),
|
|
state.text.slice(0, 200),
|
|
);
|
|
if (expectedRoute) {
|
|
check(`${label}: route is ${expectedRoute}`, state.route === expectedRoute, `actual ${state.route}`);
|
|
}
|
|
assertClean(label);
|
|
return state;
|
|
}
|
|
|
|
console.log(`Client navigation (${origin}${OVERVIEW})`);
|
|
|
|
// ─── 1. The overview loads, and we enumerate every internal destination ────
|
|
const overviewResponse = await page.goto(`${origin}${OVERVIEW}`, { waitUntil: "load" });
|
|
check(`${OVERVIEW} responds 200`, overviewResponse?.status() === 200, `status ${overviewResponse?.status()}`);
|
|
const overviewState = await assertRendered(OVERVIEW, OVERVIEW);
|
|
|
|
const destinations = await page.evaluate(() => {
|
|
const seen = new Map();
|
|
for (const anchor of document.querySelectorAll("#article a[href]")) {
|
|
const href = anchor.getAttribute("href") ?? "";
|
|
// Only same-origin document links; external links and in-page anchors are
|
|
// checked separately.
|
|
if (/^https?:\/\//i.test(href) || href.startsWith("#") || !href) continue;
|
|
const url = new URL(anchor.href, location.href);
|
|
if (url.origin !== location.origin) continue;
|
|
const key = url.pathname;
|
|
if (!seen.has(key)) {
|
|
seen.set(key, { href, pathname: url.pathname, hash: url.hash, text: anchor.textContent.trim() });
|
|
}
|
|
}
|
|
return [...seen.values()];
|
|
});
|
|
|
|
check(`${OVERVIEW} exposes internal destinations`, destinations.length > 10, `found ${destinations.length}`);
|
|
console.log(` ${destinations.length} distinct internal destinations on ${OVERVIEW}`);
|
|
|
|
// Every DOM href on a rendered page must already be a canonical route. A raw
|
|
// `.md` href left in the DOM is the visible symptom of the resolver bug.
|
|
const rawMarkdownHrefs = destinations.filter((entry) => entry.href.endsWith(".md") || entry.href.includes(".md#"));
|
|
check(
|
|
`${OVERVIEW}: no destination is left as a raw .md href`,
|
|
rawMarkdownHrefs.length === 0,
|
|
rawMarkdownHrefs.map((entry) => `${entry.text} -> ${entry.href}`).join("\n "),
|
|
);
|
|
|
|
// ─── 2. Click every destination, then come straight back ───────────────────
|
|
for (const destination of destinations) {
|
|
const label = `click "${destination.text}"`;
|
|
await page.goto(`${origin}${OVERVIEW}`, { waitUntil: "load" });
|
|
await waitForArticle();
|
|
takeProblems();
|
|
|
|
const selector = `#article a[href="${destination.href.replace(/"/g, '\\"')}"]`;
|
|
const anchor = page.locator(selector).first();
|
|
if (!check(`${label}: link is present`, (await anchor.count()) > 0, selector)) continue;
|
|
await anchor.click();
|
|
await page.waitForFunction(
|
|
(expected) => location.pathname === expected,
|
|
destination.pathname,
|
|
{ timeout: 15000 },
|
|
).catch(() => {});
|
|
await assertRendered(label, destination.pathname + destination.hash);
|
|
}
|
|
|
|
// ─── 3. Direct static routes, loaded fresh ─────────────────────────────────
|
|
for (const destination of destinations) {
|
|
const label = `direct ${destination.pathname}`;
|
|
const response = await page.goto(`${origin}${destination.pathname}`, { waitUntil: "load" });
|
|
check(`${label}: responds 200`, response?.status() === 200, `status ${response?.status()}`);
|
|
await assertRendered(label, destination.pathname);
|
|
}
|
|
|
|
// ─── 4. Relative links from a nested document ──────────────────────────────
|
|
// A connector page links to a sibling (access-model.md), up one level
|
|
// (../connectors.md) and up-and-over (../how-to/...). Click whatever it has.
|
|
const NESTED = "/connectors/gmail/";
|
|
await page.goto(`${origin}${NESTED}`, { waitUntil: "load" });
|
|
await assertRendered(`nested ${NESTED}`, NESTED);
|
|
const nestedDestinations = await page.evaluate(() => {
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const anchor of document.querySelectorAll("#article a[href^='/']")) {
|
|
const url = new URL(anchor.href, location.href);
|
|
if (seen.has(url.pathname) || url.pathname === location.pathname) continue;
|
|
seen.add(url.pathname);
|
|
out.push({ href: anchor.getAttribute("href"), pathname: url.pathname, text: anchor.textContent.trim() });
|
|
}
|
|
return out.slice(0, 12);
|
|
});
|
|
check(`${NESTED} exposes relative destinations`, nestedDestinations.length > 2, `found ${nestedDestinations.length}`);
|
|
for (const destination of nestedDestinations) {
|
|
const label = `nested click "${destination.text}"`;
|
|
await page.goto(`${origin}${NESTED}`, { waitUntil: "load" });
|
|
await waitForArticle();
|
|
takeProblems();
|
|
const anchor = page.locator(`#article a[href="${destination.href}"]`).first();
|
|
if (!check(`${label}: link is present`, (await anchor.count()) > 0, destination.href)) continue;
|
|
await anchor.click();
|
|
await page.waitForFunction((expected) => location.pathname === expected, destination.pathname, { timeout: 15000 })
|
|
.catch(() => {});
|
|
await assertRendered(label, destination.pathname);
|
|
}
|
|
|
|
// ─── 5. Anchors ────────────────────────────────────────────────────────────
|
|
await page.goto(`${origin}${OVERVIEW}`, { waitUntil: "load" });
|
|
await waitForArticle();
|
|
takeProblems();
|
|
const inPageAnchor = await page.evaluate(() => {
|
|
const anchor = [...document.querySelectorAll("#article a[href]")].find((candidate) => {
|
|
const url = new URL(candidate.href, location.href);
|
|
return url.pathname === location.pathname && url.hash.length > 1;
|
|
});
|
|
return anchor ? { href: anchor.getAttribute("href"), hash: new URL(anchor.href, location.href).hash } : null;
|
|
});
|
|
if (check("overview has an in-page anchor link", inPageAnchor !== null)) {
|
|
await page.locator(`#article a[href="${inPageAnchor.href}"]`).first().click();
|
|
await page.waitForFunction((expected) => location.hash === expected, inPageAnchor.hash, { timeout: 10000 })
|
|
.catch(() => {});
|
|
const state = await articleState();
|
|
check(
|
|
`in-page anchor lands on ${inPageAnchor.hash}`,
|
|
state.route === `${OVERVIEW}${inPageAnchor.hash}`,
|
|
`actual ${state.route}`,
|
|
);
|
|
const targetExists = await page.evaluate((hash) => Boolean(document.getElementById(hash.slice(1))), inPageAnchor.hash);
|
|
check(`in-page anchor target ${inPageAnchor.hash} exists`, targetExists);
|
|
assertClean("in-page anchor");
|
|
}
|
|
|
|
// A cross-page anchor: /connectors/gmail/ links to action-permissions.md#...,
|
|
// and the resolver has to keep the hash while changing the document.
|
|
await page.goto(`${origin}${NESTED}`, { waitUntil: "load" });
|
|
await waitForArticle();
|
|
takeProblems();
|
|
const crossPageAnchor = await page.evaluate(() => {
|
|
const anchor = [...document.querySelectorAll("#article a[href]")].find((candidate) => {
|
|
const url = new URL(candidate.href, location.href);
|
|
return url.origin === location.origin && url.pathname !== location.pathname && url.hash.length > 1;
|
|
});
|
|
if (!anchor) return null;
|
|
const url = new URL(anchor.href, location.href);
|
|
return { href: anchor.getAttribute("href"), pathname: url.pathname, hash: url.hash };
|
|
});
|
|
if (crossPageAnchor) {
|
|
await page.locator(`#article a[href="${crossPageAnchor.href}"]`).first().click();
|
|
await page.waitForFunction((expected) => location.pathname === expected, crossPageAnchor.pathname, { timeout: 15000 })
|
|
.catch(() => {});
|
|
const state = await assertRendered("cross-page anchor", crossPageAnchor.pathname + crossPageAnchor.hash);
|
|
const targetExists = await page.evaluate(
|
|
(hash) => Boolean(document.getElementById(hash.slice(1))),
|
|
crossPageAnchor.hash,
|
|
);
|
|
check(`cross-page anchor target ${crossPageAnchor.hash} exists on ${crossPageAnchor.pathname}`, targetExists, state.route);
|
|
} else {
|
|
console.log(" (no cross-page anchor link on the nested page; skipped)");
|
|
}
|
|
|
|
// ─── 6. Back, forward, and refresh ─────────────────────────────────────────
|
|
await page.goto(`${origin}${OVERVIEW}`, { waitUntil: "load" });
|
|
await waitForArticle();
|
|
takeProblems();
|
|
const firstHop = destinations.find((entry) => entry.pathname.startsWith("/connectors/") && !entry.hash)
|
|
?? destinations[0];
|
|
await page.locator(`#article a[href="${firstHop.href}"]`).first().click();
|
|
await page.waitForFunction((expected) => location.pathname === expected, firstHop.pathname, { timeout: 15000 });
|
|
await assertRendered(`hop to ${firstHop.pathname}`, firstHop.pathname);
|
|
|
|
await page.goBack({ waitUntil: "load" });
|
|
await waitForArticle();
|
|
const backState = await articleState();
|
|
check("back returns to the overview route", backState.route === OVERVIEW, `actual ${backState.route}`);
|
|
check("back restores the overview heading", backState.h1 === overviewState.h1, `actual ${JSON.stringify(backState.h1)}`);
|
|
check("back does not leave a load failure", !backState.text.includes("Could not load"), backState.text.slice(0, 200));
|
|
assertClean("back");
|
|
|
|
await page.goForward({ waitUntil: "load" });
|
|
await waitForArticle();
|
|
const forwardState = await articleState();
|
|
check("forward returns to the destination", forwardState.route === firstHop.pathname, `actual ${forwardState.route}`);
|
|
check("forward does not leave a load failure", !forwardState.text.includes("Could not load"), forwardState.text.slice(0, 200));
|
|
assertClean("forward");
|
|
|
|
const reloadResponse = await page.reload({ waitUntil: "load" });
|
|
check("refresh responds 200", reloadResponse?.status() === 200, `status ${reloadResponse?.status()}`);
|
|
await assertRendered(`refresh ${firstHop.pathname}`, firstHop.pathname);
|
|
|
|
await context.close();
|
|
await browser.close();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
|
|
if (failures > 0) {
|
|
console.error(`\nClient navigation: ${failures} of ${checks} checks failed.`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`\nClient navigation: ${checks} checks passed.`);
|