Files
paperclip-docs/scripts/verify-client-navigation.mjs
T
39633c2217 docs: connectors catalog, operator-first third revision (#124)
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]>
2026-09-16 22:19:14 -10:00

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.`);