cb961cd2d9
The video-id regex only matched /watch?v=, youtu.be, /embed/, and /v/ forms, so youtube.com/live/<id> and youtube.com/shorts/<id> links were rejected with "Invalid YouTube URL". Add both forms to the server and frontend extractors (kept in sync) and cover them with tests. Ship as 0.2.159.
246 lines
7.3 KiB
JavaScript
246 lines
7.3 KiB
JavaScript
import { test, describe } from "node:test";
|
|
import { strict as assert } from "node:assert";
|
|
import {
|
|
sendEvent,
|
|
extractVideoId,
|
|
formatTime,
|
|
parseTimestampedTranscript,
|
|
safeText,
|
|
retryGemini,
|
|
} from "../util.js";
|
|
|
|
describe("extractVideoId", () => {
|
|
test("extracts from standard watch URL", () => {
|
|
assert.equal(extractVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
});
|
|
|
|
test("extracts from youtu.be short URL", () => {
|
|
assert.equal(extractVideoId("https://youtu.be/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
});
|
|
|
|
test("extracts from /embed/ URL", () => {
|
|
assert.equal(extractVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
});
|
|
|
|
test("extracts from /live/ URL (with ?si tracking param)", () => {
|
|
assert.equal(
|
|
extractVideoId("https://www.youtube.com/live/QEq1Fa-Br0U?si=CqlsUBpyTs_ksqi3"),
|
|
"QEq1Fa-Br0U"
|
|
);
|
|
});
|
|
|
|
test("extracts from /shorts/ URL", () => {
|
|
assert.equal(extractVideoId("https://www.youtube.com/shorts/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
});
|
|
|
|
test("accepts a bare 11-character id", () => {
|
|
assert.equal(extractVideoId("dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
});
|
|
|
|
test("returns null for non-YouTube URL", () => {
|
|
assert.equal(extractVideoId("https://example.com/video"), null);
|
|
});
|
|
|
|
test("returns null for empty / null input", () => {
|
|
assert.equal(extractVideoId(""), null);
|
|
assert.equal(extractVideoId(null), null);
|
|
assert.equal(extractVideoId(undefined), null);
|
|
});
|
|
|
|
test("returns null for too-short id", () => {
|
|
assert.equal(extractVideoId("dQw4w9W"), null);
|
|
});
|
|
});
|
|
|
|
describe("formatTime", () => {
|
|
test("formats sub-minute as M:SS", () => {
|
|
assert.equal(formatTime(0), "0:00");
|
|
assert.equal(formatTime(5), "0:05");
|
|
assert.equal(formatTime(59), "0:59");
|
|
});
|
|
|
|
test("formats minutes as M:SS", () => {
|
|
assert.equal(formatTime(60), "1:00");
|
|
assert.equal(formatTime(125), "2:05");
|
|
assert.equal(formatTime(3599), "59:59");
|
|
});
|
|
|
|
test("formats hours+ as H:MM:SS", () => {
|
|
assert.equal(formatTime(3600), "1:00:00");
|
|
assert.equal(formatTime(3661), "1:01:01");
|
|
assert.equal(formatTime(7325), "2:02:05");
|
|
});
|
|
|
|
test("floors fractional seconds", () => {
|
|
assert.equal(formatTime(60.9), "1:00");
|
|
});
|
|
});
|
|
|
|
describe("parseTimestampedTranscript", () => {
|
|
test("parses [M:SS] format", () => {
|
|
const entries = parseTimestampedTranscript(
|
|
"[0:00] Welcome\n[0:05] Today we're talking about\n[0:30] Topic two"
|
|
);
|
|
assert.equal(entries.length, 3);
|
|
assert.deepEqual(entries[0], { text: "Welcome", offset: 0, duration: 5 });
|
|
assert.equal(entries[1].offset, 5);
|
|
assert.equal(entries[1].duration, 25);
|
|
// Last entry gets default 15s duration
|
|
assert.equal(entries[2].offset, 30);
|
|
assert.equal(entries[2].duration, 15);
|
|
});
|
|
|
|
test("parses H:MM:SS format", () => {
|
|
const entries = parseTimestampedTranscript("[1:02:30] Hello");
|
|
assert.equal(entries[0].offset, 1 * 3600 + 2 * 60 + 30);
|
|
});
|
|
|
|
test("parses (M:SS) format", () => {
|
|
const entries = parseTimestampedTranscript("(0:00) Welcome");
|
|
assert.equal(entries.length, 1);
|
|
assert.equal(entries[0].text, "Welcome");
|
|
});
|
|
|
|
test("strips markdown bold markers", () => {
|
|
const entries = parseTimestampedTranscript("**[0:00]** Hello world");
|
|
assert.equal(entries[0].text, "Hello world");
|
|
});
|
|
|
|
test("ignores blank lines", () => {
|
|
const entries = parseTimestampedTranscript("\n[0:00] one\n\n[0:05] two\n");
|
|
assert.equal(entries.length, 2);
|
|
});
|
|
|
|
test("returns empty array for unparseable input", () => {
|
|
assert.deepEqual(parseTimestampedTranscript(""), []);
|
|
assert.deepEqual(parseTimestampedTranscript("no timestamps here"), []);
|
|
});
|
|
});
|
|
|
|
describe("safeText", () => {
|
|
test("returns .text when accessible", () => {
|
|
assert.equal(safeText({ text: "hello" }), "hello");
|
|
});
|
|
|
|
test("falls back to candidates[0].content.parts when .text throws", () => {
|
|
const fake = {
|
|
get text() { throw new Error("nope"); },
|
|
candidates: [{ content: { parts: [{ text: "from " }, { text: "parts" }] } }],
|
|
};
|
|
assert.equal(safeText(fake), "from parts");
|
|
});
|
|
|
|
test("returns empty string when neither path yields text", () => {
|
|
assert.equal(safeText({}), "");
|
|
assert.equal(safeText({ candidates: [] }), "");
|
|
});
|
|
|
|
test("does not throw on null / weird shapes", () => {
|
|
assert.equal(safeText({ candidates: [{ content: null }] }), "");
|
|
});
|
|
});
|
|
|
|
describe("retryGemini", () => {
|
|
test("returns successful result on first try", async () => {
|
|
const result = await retryGemini(async () => "ok", { retries: 3, delayMs: 1 });
|
|
assert.equal(result, "ok");
|
|
});
|
|
|
|
test("retries on 503 and eventually succeeds", async () => {
|
|
let attempts = 0;
|
|
const result = await retryGemini(
|
|
async () => {
|
|
attempts++;
|
|
if (attempts < 3) {
|
|
const err = new Error("temporary");
|
|
err.status = 503;
|
|
throw err;
|
|
}
|
|
return "ok";
|
|
},
|
|
{ retries: 5, delayMs: 1 }
|
|
);
|
|
assert.equal(result, "ok");
|
|
assert.equal(attempts, 3);
|
|
});
|
|
|
|
test("retries on network error patterns", async () => {
|
|
let attempts = 0;
|
|
const result = await retryGemini(
|
|
async () => {
|
|
attempts++;
|
|
if (attempts < 2) throw new Error("fetch failed");
|
|
return "ok";
|
|
},
|
|
{ retries: 3, delayMs: 1 }
|
|
);
|
|
assert.equal(result, "ok");
|
|
assert.equal(attempts, 2);
|
|
});
|
|
|
|
test("does NOT retry on non-retryable errors", async () => {
|
|
let attempts = 0;
|
|
await assert.rejects(
|
|
retryGemini(
|
|
async () => {
|
|
attempts++;
|
|
throw new Error("bad request: invalid argument");
|
|
},
|
|
{ retries: 5, delayMs: 1 }
|
|
)
|
|
);
|
|
assert.equal(attempts, 1);
|
|
});
|
|
|
|
test("throws after exhausting retries", async () => {
|
|
let attempts = 0;
|
|
await assert.rejects(
|
|
retryGemini(
|
|
async () => {
|
|
attempts++;
|
|
const err = new Error("still 503");
|
|
err.status = 503;
|
|
throw err;
|
|
},
|
|
{ retries: 3, delayMs: 1 }
|
|
)
|
|
);
|
|
assert.equal(attempts, 3);
|
|
});
|
|
|
|
test("calls log function on retries", async () => {
|
|
const logs = [];
|
|
await retryGemini(
|
|
async () => {
|
|
const err = new Error("503");
|
|
err.status = 503;
|
|
throw err;
|
|
},
|
|
{ retries: 2, delayMs: 1, label: "test", log: (msg) => logs.push(msg) }
|
|
).catch(() => {});
|
|
// retries: 2 → the loop logs twice: the "failed, retrying in …s" notice
|
|
// before attempt 2's wait, then "Retrying… (attempt 2/2)" at the top of
|
|
// attempt 2. (The test previously expected 1, written before the
|
|
// top-of-attempt retry line existed.)
|
|
assert.equal(logs.length, 2);
|
|
assert.match(logs[0], /test/);
|
|
assert.match(logs[0], /retrying/);
|
|
});
|
|
});
|
|
|
|
describe("sendEvent", () => {
|
|
test("writes a properly formatted SSE frame", () => {
|
|
let written = "";
|
|
const fakeRes = { write: (chunk) => { written += chunk; } };
|
|
sendEvent(fakeRes, "result", { foo: "bar" });
|
|
assert.equal(written, 'event: result\ndata: {"foo":"bar"}\n\n');
|
|
});
|
|
|
|
test("handles primitive data", () => {
|
|
let written = "";
|
|
const fakeRes = { write: (chunk) => { written += chunk; } };
|
|
sendEvent(fakeRes, "ping", null);
|
|
assert.equal(written, "event: ping\ndata: null\n\n");
|
|
});
|
|
});
|