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
90 changes: 90 additions & 0 deletions src/__tests__/exportMissingFrames.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { NextRequest } from "next/server";
import { exportReadme } from "@/lib/exportAssets";

const rateLimitMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/rateLimit", () => ({ rateLimit: rateLimitMock, getClientIp: () => "1.2.3.4" }));

type Handler = typeof import("../app/api/export-site/route").POST;
let POST: Handler;

beforeEach(async () => {
vi.clearAllMocks();
rateLimitMock.mockResolvedValue({ allowed: true });
({ POST } = await import("../app/api/export-site/route"));
});

async function loaderSource(): Promise<string> {
const res = await POST(
new Request("https://scrollcraft.space/api/export-site", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sections: [{ heading: "Section 1", scrollHeight: 1000 }], siteName: "Clip", frameCount: 12, fps: 24 }),
}) as unknown as NextRequest
);
expect(res.status).toBe(200);
const { html } = await res.json();
const start = html.indexOf("function showMissingFrames()");
const end = html.indexOf("// Load only the set actually being drawn");
expect(start, "the missing-frames notice is gone").toBeGreaterThan(-1);
expect(end).toBeGreaterThan(start);
return html.slice(start, end);
}

/**
* The exported loader, run against images that either all fail or all load.
*
* Measured in Chrome: the same export opened with its frames folder beside it painted
* its background, and opened alone, as Windows does when index.html is double-clicked
* inside the ZIP, stayed pure black with 12 frame requests failing and nothing on the
* page to say why.
*/
async function run(source: string, frames: "missing" | "present") {
const appended: { id: string; role: string | null; text: string }[] = [];
const document = {
getElementById: (id: string) => appended.find((n) => n.id === id) ?? null,
createElement: () => {
const attrs: Record<string, string> = {};
const node = { id: "", style: { cssText: "" }, textContent: "", setAttribute: (k: string, v: string) => { attrs[k] = v; } };
return new Proxy(node, { get: (t, k) => (k === "attrs" ? attrs : (t as Record<string | symbol, unknown>)[k]) });
},
body: {
appendChild: (n: { id: string; textContent: string; attrs: Record<string, string> }) =>
appended.push({ id: n.id, role: n.attrs.role ?? null, text: n.textContent }),
},
};
class FakeImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
decoding = "";
decode() { return Promise.resolve(); }
set src(_v: string) { queueMicrotask(() => (frames === "missing" ? this.onerror?.() : this.onload?.())); }
}
const preloadSet = new Function("document", "Image", "drawFrame", "currentFrame", `${source}; return preloadSet;`)(
document, FakeImage, () => {}, 0
);
preloadSet(12, "frames", new Array(12), true);
for (let i = 0; i < 10; i++) await Promise.resolve();
return appended;
}

describe("an export opened without its frames says why", () => {
it("shows a notice when every frame fails to load", async () => {
const shown = await run(await loaderSource(), "missing");
expect(shown).toHaveLength(1);
expect(shown[0].role).toBe("alert");
expect(shown[0].text).toMatch(/extract the whole ZIP first/);
});

it("stays out of the way when the frames are there", async () => {
expect(await run(await loaderSource(), "present")).toHaveLength(0);
});
});

describe("the README tells the owner how to open the export", () => {
it("says to extract the ZIP, and no longer claims opening the extracted page fails", () => {
const readme = exportReadme("Site", false, "https://scrollcraft.space");
expect(readme).toMatch(/Extract the whole ZIP before opening anything/);
expect(readme).not.toMatch(/Do \*\*not\*\* open `index\.html` by double-clicking it/);
});
});
18 changes: 17 additions & 1 deletion src/app/api/export-site/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,11 +483,26 @@ export async function POST(req: NextRequest) {
`}

${styleSpec ? "" : `
// Every keyframe failing means the frames folder is not beside this page. On Windows,
// opening index.html from inside the ZIP does exactly that: it copies the one file
// somewhere temporary and leaves the frames behind. Say so instead of showing a black
// page with nothing to go on.
function showMissingFrames() {
if (document.getElementById('sc-missing-frames')) return;
var box = document.createElement('div');
box.id = 'sc-missing-frames';
box.setAttribute('role', 'alert');
box.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:9999;max-width:min(560px,calc(100% - 32px));padding:16px 20px;border-radius:12px;background:#111827;color:#f9fafb;border:1px solid rgba(255,255,255,0.18);font:15px/1.5 system-ui,-apple-system,sans-serif;box-shadow:0 20px 50px rgba(0,0,0,0.5)';
box.textContent = 'The background did not load: the frames folder is not next to this page. If you opened index.html from inside the ZIP, extract the whole ZIP first, then open index.html from the extracted folder.';
document.body.appendChild(box);
}

function preloadSet(count, folder, target, isPrimary) {
var STEP = 5;
var keyframes = [];
for (var i = 0; i < count; i += STEP) keyframes.push(i);
var settled = 0; // counts successes + failures so the chain never hangs
var failed = 0;

function loadFrame(idx) {
var img = new Image();
Expand All @@ -510,6 +525,7 @@ export async function POST(req: NextRequest) {
function advance() {
settled++;
if (settled === keyframes.length) {
if (isPrimary && failed === keyframes.length) { showMissingFrames(); return; }
for (var j = 0; j < count; j++) {
if (j % STEP !== 0) loadFrame(j);
}
Expand All @@ -525,7 +541,7 @@ export async function POST(req: NextRequest) {
if (img.decode) { img.decode().then(put).catch(put); } else { put(); }
advance();
};
img.onerror = advance; // count failure so we don't hang
img.onerror = function() { failed++; advance(); }; // count failure so we don't hang
});
}

Expand Down
5 changes: 3 additions & 2 deletions src/lib/exportAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,9 @@ npx serve .
\`\`\`
Then open the address it prints.

Do **not** open \`index.html\` by double-clicking it. Browsers block a page loaded over
\`file://\` from reading its own neighbouring files, so the background will not appear.
Extract the whole ZIP before opening anything. Opening \`index.html\` from inside the ZIP
copies only that one file somewhere temporary, so the page loads without its \`frames\`
folder and the background stays black. The page tells you when that has happened.

## What is in here

Expand Down
Loading