Skip to content
Closed
11 changes: 10 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

144 changes: 144 additions & 0 deletions __tests__/lib/share-card.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
copyOrDownloadCard,
downloadCard,
shareCardNative,
shareCardToastMessage,
} from "@/lib/share-card";

const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic header

function makeBlob(): Blob {
return new Blob([PNG_BYTES], { type: "image/png" });
}

describe("lib/share-card", () => {
beforeEach(() => {
// jsdom doesn't ship URL.createObjectURL / revokeObjectURL — mock them so
// the download-fallback path doesn't throw out of its try block.
Object.defineProperty(URL, "createObjectURL", {
configurable: true, writable: true, value: vi.fn().mockReturnValue("blob:mock"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true, writable: true, value: vi.fn(),
});
});

afterEach(() => {
vi.restoreAllMocks();
delete (globalThis as { ClipboardItem?: unknown }).ClipboardItem;
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
});

it("returns 'clipboard' when navigator.clipboard.write succeeds", async () => {
const write = vi.fn().mockResolvedValue(undefined);
(globalThis as { ClipboardItem?: unknown }).ClipboardItem = class {
constructor(public items: Record<string, Blob>) {}
};
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { write },
});

const result = await copyOrDownloadCard(makeBlob(), "x.png");

expect(result).toBe("clipboard");
expect(write).toHaveBeenCalledTimes(1);
});

it("falls back to 'download' when clipboard.write rejects", async () => {
const write = vi.fn().mockRejectedValue(new Error("denied"));
(globalThis as { ClipboardItem?: unknown }).ClipboardItem = class {
constructor(public items: Record<string, Blob>) {}
};
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { write },
});
const click = vi.fn();
const realCreate = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag) => {
const node = realCreate(tag);
if (tag === "a") Object.defineProperty(node, "click", { value: click });
return node;
});

const result = await copyOrDownloadCard(makeBlob(), "x.png");

expect(result).toBe("download");
expect(click).toHaveBeenCalledTimes(1);
});

it("falls back to 'download' when ClipboardItem is undefined", async () => {
// ClipboardItem absent, navigator.clipboard absent — function should go
// straight to the download fallback.
const click = vi.fn();
const realCreate = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag) => {
const node = realCreate(tag);
if (tag === "a") Object.defineProperty(node, "click", { value: click });
return node;
});

const result = await copyOrDownloadCard(makeBlob(), "x.png");

expect(result).toBe("download");
expect(click).toHaveBeenCalledTimes(1);
});

it("toast copy is method-specific", () => {
expect(shareCardToastMessage("native")).toMatch(/attached/);
expect(shareCardToastMessage("clipboard")).toMatch(/copied/);
expect(shareCardToastMessage("download")).toMatch(/downloaded/);
expect(shareCardToastMessage("failed")).toMatch(/couldn/i);
});

it("downloadCard triggers an anchor click and returns true", () => {
const click = vi.fn();
const realCreate = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag) => {
const node = realCreate(tag);
if (tag === "a") Object.defineProperty(node, "click", { value: click });
return node;
});

const ok = downloadCard(makeBlob(), "x.png");

expect(ok).toBe(true);
expect(click).toHaveBeenCalledTimes(1);
});

it("shareCardNative returns false when navigator.share is unavailable", async () => {
const originalShare = navigator.share;
Object.defineProperty(navigator, "share", { configurable: true, value: undefined });
try {
const ok = await shareCardNative(makeBlob(), "x.png", "hello");
expect(ok).toBe(false);
} finally {
Object.defineProperty(navigator, "share", { configurable: true, value: originalShare });
}
});

it("shareCardNative returns true when navigator.share resolves", async () => {
const share = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "share", { configurable: true, value: share });
Object.defineProperty(navigator, "canShare", { configurable: true, value: () => true });

const ok = await shareCardNative(makeBlob(), "x.png", "hello");

expect(ok).toBe(true);
expect(share).toHaveBeenCalledTimes(1);
});

it("shareCardNative returns false on AbortError (user cancelled)", async () => {
const abort = Object.assign(new Error("cancelled"), { name: "AbortError" });
const share = vi.fn().mockRejectedValue(abort);
Object.defineProperty(navigator, "share", { configurable: true, value: share });
Object.defineProperty(navigator, "canShare", { configurable: true, value: () => true });

const ok = await shareCardNative(makeBlob(), "x.png", "hello");

expect(ok).toBe(false);
});
});
12 changes: 9 additions & 3 deletions app/audit/_components/audit-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { deriveFindings } from "@/src/audit/findings";
import { usePostHog } from "@/contexts/PostHogContext";

import { IdentitySection } from "./identity-section";
import { ShareDock } from "./share-dock";
import { StrengthsSection } from "./strengths-section";
import { ScoreSection } from "./score-section";
import { FindingsSection } from "./findings-section";
Expand Down Expand Up @@ -296,9 +297,6 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize
sessions={result.transcripts.scanned}
window={scopeWindow}
seed={project}
score={score}
grade={grade}
missing={missing}
/>
<StrengthsSection
strengths={strengths}
Expand All @@ -318,6 +316,14 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize
</div>
<ReportFooter cachedAt={cachedAt} />
</div>
<ShareDock
frameRef={identityFrameRef}
archetypeKey={classification.archetype}
seed={project}
score={score}
grade={grade}
missing={missing}
/>
</div>
);
}
Expand Down
159 changes: 3 additions & 156 deletions app/audit/_components/identity-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,41 +17,11 @@
* Exposes a `frameRef` forwarded onto the `.archetype-frame` element so
* the ShowOff "make poster" action can capture it via html2canvas.
*/
import React, { forwardRef, useMemo, useState } from "react";
import React, { forwardRef, useMemo } from "react";
import { ARCHETYPES, pickArchetypeVariant, type ArchetypeKey } from "@/src/audit/archetypes";
import { type Grade } from "@/src/audit/scoring";
import { usePostHog } from "@/contexts/PostHogContext";
import { Sigil } from "./sigil";

const SITE_URL = "https://failproof.ai";
const X_INTENT = (text: string) =>
`https://x.com/intent/tweet?text=${encodeURIComponent(text)}`;
const LI_INTENT = (text: string) =>
`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(SITE_URL)}&summary=${encodeURIComponent(text)}`;

function buildXTemplate(score: number, archetypeName: string, grade: Grade, missing: number): string {
const gradeLines: Record<Grade, string> = {
S: "every prescribed policy live. running at peak. this is what secure looks like.",
A: `${missing} polic${missing === 1 ? "y" : "ies"} from elite tier. almost there.`,
B: `solid baseline. ${missing} policy gap${missing === 1 ? "" : "s"} to close before i'm comfortable.`,
C: `${missing} prescribed polic${missing === 1 ? "y" : "ies"} between here and the next tier. they're named. they're waiting.`,
D: `${missing} prescribed polic${missing === 1 ? "y" : "ies"} unaddressed. agents without guardrails aren't ready for prod.`,
F: `exposure is real. ${missing} polic${missing === 1 ? "y" : "ies"} away from stable ground — starting today.`,
};
return `just audited my AI agent with failproofai ✦\n\narchetype: ${archetypeName.toLowerCase()} · ${score}/100 · ${grade} tier\n${gradeLines[grade]}\n\nrun yours → ${SITE_URL}`;
}

function buildLinkedInTemplate(score: number, archetypeName: string, grade: Grade, missing: number): string {
// "every key policy is live" is only true when the audit returned no
// unenabled prescribed policies. A-grade with a non-zero `missing` count
// is the "almost there but still has gaps" state — softer copy.
const cleanRun = grade === "S" || (grade === "A" && missing === 0);
const verdict = cleanRun
? `${score}/100 — ${grade} tier. every key policy is live. the audit confirmed what good looks like.`
: `${score}/100 — ${grade} tier. ${missing} prescribed polic${missing === 1 ? "y" : "ies"} uncovered — each one is a real attack surface.`;
return `We ran a failproofai security audit on our AI agent stack.\n\n${verdict}\n\nArchetype: ${archetypeName.toLowerCase()}. failproofai maps your agent\'s behavior pattern, identifies the exposure, and prescribes the exact policies to close it.\n\nFree. Open-source. 30 seconds to run: ${SITE_URL}`;
}

interface Props {
archetypeKey: ArchetypeKey;
secondaryKey: ArchetypeKey;
Expand All @@ -61,117 +31,19 @@ interface Props {
window: string;
/** Stable seed for variant selection (project name is the natural fit). */
seed: string;
score: number;
grade: Grade;
missing: number;
}

export const IdentitySection = forwardRef<HTMLDivElement, Props>(function IdentitySection(
{ archetypeKey, secondaryKey, toolCalls, sessions, window, seed, score, grade, missing }: Props,
{ archetypeKey, secondaryKey, toolCalls, sessions, window, seed }: Props,
frameRef,
) {
// `pickArchetypeVariant` re-hashes the seed string via djb2 + 4 mix
// passes per axis. Deterministic over (archetypeKey, seed) so memoize
// — the share buttons toggle `downloadState` which rerenders us 4×.
// passes per axis. Deterministic over (archetypeKey, seed) so memoize.
const archetype = useMemo(
() => pickArchetypeVariant(archetypeKey, seed),
[archetypeKey, seed],
);
const secondary = secondaryKey !== archetypeKey ? ARCHETYPES[secondaryKey] : null;
const { capture } = usePostHog();
const [downloadState, setDownloadState] = useState<"idle" | "busy" | "done" | "error">("idle");

const captureCard = async (): Promise<boolean> => {
const node = typeof frameRef === "function" ? null : frameRef?.current;
if (!node) return false;
node.classList.add("capturing");
try {
if (typeof document !== "undefined" && document.fonts?.ready) await document.fonts.ready;
await new Promise<void>((r) => requestAnimationFrame(() => r()));
const html2canvas = (await import("html2canvas")).default;
const canvas = await html2canvas(node, {
backgroundColor: "#0e0e11",
scale: 2,
logging: false,
useCORS: true,
});
await new Promise<void>((resolve) => {
canvas.toBlob((blob) => {
if (!blob) { resolve(); return; }
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `failproofai-identity-${grade.toLowerCase()}-${score}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
resolve();
}, "image/png");
});
return true;
} finally {
node.classList.remove("capturing");
}
};

const handleDownload = async () => {
if (downloadState === "busy") return;
capture("audit_card_download_clicked", {
score,
grade,
missing_policies: missing,
});
setDownloadState("busy");
try {
const captured = await captureCard();
capture("audit_card_capture_completed", {
trigger: "download",
status: captured ? "success" : "no_frame",
});
setDownloadState("done");
setTimeout(() => setDownloadState("idle"), 2000);
} catch {
capture("audit_card_capture_completed", {
trigger: "download",
status: "error",
});
setDownloadState("error");
setTimeout(() => setDownloadState("idle"), 2000);
}
};

const handleShareX = async () => {
const text = buildXTemplate(score, archetype.name, grade, missing);
capture("audit_card_share_clicked", {
channel: "x",
score,
grade,
missing_policies: missing,
});
const captured = await captureCard().catch(() => false);
capture("audit_card_capture_completed", {
trigger: "share_x",
status: captured ? "success" : "error",
});
globalThis.open(X_INTENT(text), "_blank", "noopener,noreferrer");
};

const handleShareLI = async () => {
const text = buildLinkedInTemplate(score, archetype.name, grade, missing);
capture("audit_card_share_clicked", {
channel: "linkedin",
score,
grade,
missing_policies: missing,
});
const captured = await captureCard().catch(() => false);
capture("audit_card_capture_completed", {
trigger: "share_linkedin",
status: captured ? "success" : "error",
});
globalThis.open(LI_INTENT(text), "_blank", "noopener,noreferrer");
};

return (
<section className="identity" data-screen-label="01 Identity">
Expand Down Expand Up @@ -248,31 +120,6 @@ export const IdentitySection = forwardRef<HTMLDivElement, Props>(function Identi

<Sigil archetypeKey={archetypeKey} />
</div>

<div className="identity-share-btns">
<button type="button" className="identity-share-btn" onClick={handleShareX}>
<span className="isb-glyph" aria-hidden="true">x</span>
<span className="isb-text">share on x</span>
</button>
<button type="button" className="identity-share-btn" onClick={handleShareLI}>
<span className="isb-glyph" aria-hidden="true">in</span>
<span className="isb-text">share on linkedin</span>
</button>
<button
type="button"
className="identity-share-btn"
onClick={handleDownload}
disabled={downloadState === "busy"}
>
<span className="isb-glyph" aria-hidden="true">↓</span>
<span className="isb-text">
{downloadState === "busy" ? "rendering…"
: downloadState === "done" ? "downloaded ✓"
: downloadState === "error" ? "render failed"
: "download audit-card"}
</span>
</button>
</div>
</div>
</section>
);
Expand Down
Loading