feat: cleanup and improve detection delay on kitten plugin

This commit is contained in:
2026-08-24 13:29:02 +09:30
parent d47658ed59
commit 6688a53acc
11 changed files with 111 additions and 274 deletions
-2
View File
@@ -14,8 +14,6 @@ export default {
'/node_modules/(?!(color|color-string|color-convert|color-name)/)', '/node_modules/(?!(color|color-string|color-convert|color-name)/)',
], ],
moduleNameMapper: { moduleNameMapper: {
'\\.png$': '<rootDir>/src/test/mocks/assetStub.ts',
'\\?inline$': '<rootDir>/src/test/mocks/inlineStub.ts',
'^@/(.*)$': '<rootDir>/src/$1', '^@/(.*)$': '<rootDir>/src/$1',
'^color$': '<rootDir>/src/test/mocks/color.ts', '^color$': '<rootDir>/src/test/mocks/color.ts',
'^webextension-polyfill$': '<rootDir>/src/test/mocks/webextension-polyfill.ts', '^webextension-polyfill$': '<rootDir>/src/test/mocks/webextension-polyfill.ts',
+49 -47
View File
@@ -53,9 +53,8 @@ if (document.childNodes[1]) {
init(); init();
} }
// The 404 page is a bare document with no second childNode, so it must be // Standalone 404 documents never pass the SEQTA SPA gate above.
// booted unconditionally (outside the gate above). It self-guards on title. void bootErrorPage();
bootErrorPage();
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
window.addEventListener("unhandledrejection", (event) => { window.addEventListener("unhandledrejection", (event) => {
@@ -142,65 +141,68 @@ async function init() {
} }
} }
// The 404 page is a standalone document that never passes the SPA gate above, function shouldBootKitten404(): boolean {
// so the normal plugin path never boots there; start just the classic kitten if (IsSEQTAPage || document.getElementById("container")) return false;
// 404 plugin so it renders without any SPA work. return /404|not found/i.test(document.title);
}
/** Hide the rebranded 404 before the kitten card mounts (removed after boot). */
function inject404FlashHide(): void {
if (document.getElementById("bsplus-404-flash-hide")) return;
const style = document.createElement("style");
style.id = "bsplus-404-flash-hide";
style.textContent =
"html,body{background:#333!important}.message{display:none!important}";
(document.head ?? document.documentElement).appendChild(style);
}
async function bootErrorPage() { async function bootErrorPage() {
if (IsSEQTAPage) return; if (IsSEQTAPage) return;
// Runs at document_start, so <title> and body are not available yet; wait const storagePromise = browser.storage.local.get([
// for the DOM before testing the 404 title.
await new Promise<void>((resolve) => {
if (document.readyState !== "loading") {
resolve();
return;
}
document.addEventListener("DOMContentLoaded", () => resolve(), {
once: true,
});
});
const is404Page =
/404/.test(document.title) || /not found/i.test(document.title);
if (!is404Page) return;
const stored = await browser.storage.local.get([
"onoff", "onoff",
"plugin.error-page-kitten.settings", "plugin.error-page-kitten.settings",
]); ]);
if ((stored.onoff ?? true) === false) return;
const kittenEnabled = (
stored["plugin.error-page-kitten.settings"] as
| { enabled?: boolean }
| undefined
)?.enabled;
if (kittenEnabled === false) return;
hideRebranded404(); const watchTitle = () => {
if (shouldBootKitten404()) inject404FlashHide();
};
watchTitle();
if (document.readyState === "loading") {
document.addEventListener("readystatechange", watchTitle);
}
if (document.readyState === "loading") {
await new Promise<void>((resolve) => {
document.addEventListener("DOMContentLoaded", () => resolve(), { once: true });
});
}
document.removeEventListener("readystatechange", watchTitle);
if (!shouldBootKitten404()) return;
const stored = await storagePromise;
if ((stored.onoff ?? true) === false) return;
const kittenSettings = stored["plugin.error-page-kitten.settings"] as
| { enabled?: boolean }
| undefined;
if (kittenSettings?.enabled === false) return;
try { try {
const { pluginManager } = await import("@/plugins/index"); const { mountErrorPageKitten } = await import(
await pluginManager.startPlugin("error-page-kitten"); "@/plugins/built-in/errorPageKitten"
);
mountErrorPageKitten();
document.getElementById("bsplus-404-flash-hide")?.remove();
} catch (error) { } catch (error) {
// Restore the rebranded page so the user is not left with a blank document. document.getElementById("bsplus-404-flash-hide")?.remove();
document document
.querySelectorAll<HTMLElement>(".bsplus-kitten-404-hidden") .querySelectorAll(".bsplus-kitten-404-hidden")
.forEach((el) => { .forEach((el) => el.classList.remove("bsplus-kitten-404-hidden"));
el.classList.remove("bsplus-kitten-404-hidden");
el.style.display = "";
});
console.error("[BetterSEQTA+] Failed to boot 404 page:", error); console.error("[BetterSEQTA+] Failed to boot 404 page:", error);
} }
} }
function hideRebranded404() {
const message = document.querySelector<HTMLElement>(".message");
if (message) {
message.classList.add("bsplus-kitten-404-hidden");
message.style.display = "none";
}
}
function replaceIcons() { function replaceIcons() {
document document
.querySelectorAll<HTMLLinkElement>('link[rel*="icon"]') .querySelectorAll<HTMLLinkElement>('link[rel*="icon"]')
@@ -3,65 +3,33 @@
*/ */
/// <reference types="jest" /> /// <reference types="jest" />
import type { PluginAPI } from "@/plugins/core/types"; import type { PluginAPI } from "@/plugins/core/types";
import errorPageKittenPlugin from "./index";
jest.mock("@/lib/extensionAssetUrl", () => ({
resolveExtensionAssetUrl: (url: string) =>
`chrome-extension://test/${String(url).replace(/^\/+/, "")}`,
}));
jest.mock("@/resources/error-page/kitten.png", () => ({
__esModule: true,
default: "/resources/error-page/kitten.png",
}));
jest.mock("./styles.css?inline", () => ({
__esModule: true,
default: "/* test */",
}));
import { mountErrorPageKitten } from "./index";
const REBRANDED_404_BODY = ` const REBRANDED_404_BODY = `
<div class="message"> <div class="message">
<h1>Page not found</h1> <h1>Page not found</h1>
<p>We can't find the page you're looking for.</p> <p>We can't find the page you're looking for.</p>
<p>It may have been moved, deleted or the link might be out of date.</p>
<p class="error-ref">Ref: 404</p>
</div> </div>
`; `;
const api = {
settings: { loaded: Promise.resolve() },
} as unknown as PluginAPI<{}>;
async function startPlugin(): Promise<() => void> {
const cleanup = (await errorPageKittenPlugin.run(api)) ?? (() => {});
return cleanup;
}
function getCard(): HTMLElement | null { function getCard(): HTMLElement | null {
// The card container shares its class with <html>, so scope to body.
return document.body.querySelector<HTMLElement>(".bsplus-kitten-404"); return document.body.querySelector<HTMLElement>(".bsplus-kitten-404");
} }
function expectKittenRendered(): void {
const card = getCard();
expect(card).not.toBeNull();
expect(document.documentElement.classList).toContain("bsplus-kitten-404");
expect(card?.querySelector("h1")?.textContent).toBe("404 Not Found");
// The original document's title.
expect(document.title).toBe("404 Not Found");
// The reference page's exact vendor-prefixed banner CSS is shipped at runtime.
const stripeStyle = Array.from(
document.head.querySelectorAll("style"),
).find((s) => s.textContent?.includes("-webkit-repeating-linear-gradient"));
expect(stripeStyle?.textContent).toContain(
"repeating-linear-gradient(45deg",
);
const img = card?.querySelector(".kitten img") as HTMLImageElement | null;
expect(img?.getAttribute("alt")).toBeNull();
expect(img?.src).toContain("kitten");
const flickrLink = card?.querySelector(
".attrib a",
) as HTMLAnchorElement | null;
expect(flickrLink?.textContent).toBe("by storyvillegirl");
expect(flickrLink?.href).toBe(
"http://www.flickr.com/photos/bibbit/2756165489/",
);
const seqtaLink = card?.querySelector("a[href='http://www.seqta.com.au']");
expect(seqtaLink?.textContent).toBe("SEQTA");
// Original rebranded message is hidden.
expect(
document.querySelector(".message")?.classList.contains(
"bsplus-kitten-404-hidden",
),
).toBe(true);
}
describe("errorPageKitten", () => { describe("errorPageKitten", () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ""; document.body.innerHTML = "";
@@ -69,47 +37,24 @@ describe("errorPageKitten", () => {
document.documentElement.classList.remove("bsplus-kitten-404"); document.documentElement.classList.remove("bsplus-kitten-404");
}); });
it("renders the classic kitten 404 card over the rebranded page", async () => { it("mounts the classic kitten 404 card", () => {
document.title = "Page not found"; document.title = "Page not found";
document.body.innerHTML = REBRANDED_404_BODY; document.body.innerHTML = REBRANDED_404_BODY;
const cleanup = await startPlugin(); const cleanup = mountErrorPageKitten();
expectKittenRendered(); const card = getCard();
expect(card).not.toBeNull();
expect(document.documentElement.classList).toContain("bsplus-kitten-404");
expect(card?.querySelector("h1")?.textContent).toBe("404 Not Found");
expect(document.title).toBe("404 Not Found");
expect(card?.querySelector(".kitten img")?.getAttribute("src")).toContain("kitten");
expect(
document.querySelector(".message")?.classList.contains("bsplus-kitten-404-hidden"),
).toBe(true);
cleanup(); cleanup();
expect(getCard()).toBeNull(); expect(getCard()).toBeNull();
expect(document.title).toBe("Page not found"); expect(document.title).toBe("Page not found");
expect(
Array.from(document.head.querySelectorAll("style")).some((s) =>
s.textContent?.includes("-webkit-repeating-linear-gradient"),
),
).toBe(false);
expect(
document.documentElement.classList.contains("bsplus-kitten-404"),
).toBe(false);
expect(
document
.querySelector(".message")
?.classList.contains("bsplus-kitten-404-hidden"),
).toBe(false);
});
it("does not render inside the SPA (no .message, has #container)", async () => {
document.title = "SEQTA";
document.body.innerHTML = '<div id="container"></div>';
await startPlugin();
expect(getCard()).toBeNull();
});
it("does not render on an unrelated page", async () => {
document.title = "Home";
document.body.innerHTML = '<div id="app"></div>';
await startPlugin();
expect(getCard()).toBeNull();
}); });
}); });
+33 -107
View File
@@ -4,72 +4,43 @@ import kittenPng from "@/resources/error-page/kitten.png";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
const KITTEN_IMG = resolveExtensionAssetUrl(kittenPng); const KITTEN_IMG = resolveExtensionAssetUrl(kittenPng);
const KITTEN_CLASS = "bsplus-kitten-404"; const ROOT_CLASS = "bsplus-kitten-404";
// The reference page's exact h1 background declarations, in the original const CARD_HTML = `<h1>404 Not Found</h1>
// order. Each engine keeps the last declaration it understands (-webkit in <p>Sorry — the resource you are looking for could not be found.</p>
// Blink, -moz in Gecko, the legacy standard otherwise), so shipping them <p>If you believe you are seeing this message in error, please contact your IT department.</p>
// byte-for-byte reproduces the reference banner on every engine. Lives in a <div class="kitten">
// JS string because the CSS minifier drops vendor-prefixed declarations. <p>I did find this picture of a kitten for you, though!</p>
const H1_STRIPE_CSS = <img src="${KITTEN_IMG}" alt="">
"html.bsplus-kitten-404 .bsplus-kitten-404>h1{background-image:-moz-repeating-linear-gradient(135deg, rgba(0,0,0,0), rgba(0,0,0,0) 8px, rgba(0,0,0,0.05) 8px, rgba(0,0,0,0.05) 16px), -moz-linear-gradient(top, rgba(0,0,0,0), rgba(0,0,0,0.1));background-image:-webkit-repeating-linear-gradient(135deg, rgba(0,0,0,0), rgba(0,0,0,0) 8px, rgba(0,0,0,0.05) 8px, rgba(0,0,0,0.05) 16px), -webkit-linear-gradient(top, rgba(0,0,0,0), rgba(0,0,0,0.1));background-image:-o-repeating-linear-gradient(135deg, rgba(0,0,0,0), rgba(0,0,0,0) 8px, rgba(0,0,0,0.05) 8px, rgba(0,0,0,0.05) 16px), -o-linear-gradient(top, rgba(0,0,0,0), rgba(0,0,0,0.1));background-image:repeating-linear-gradient(45deg, rgba(0,0,0,0), rgba(0,0,0,0) 8px, rgba(0,0,0,0.05) 8px, rgba(0,0,0,0.05) 16px), linear-gradient(top, rgba(0,0,0,0), rgba(0,0,0,0.1))}"; <div class="attrib">CC-BY-SA <a href="http://www.flickr.com/photos/bibbit/2756165489/">by storyvillegirl</a></div>
</div>
<a href="http://www.seqta.com.au">SEQTA</a>`;
// The 404 page (legacy and rebranded) is a standalone document built around a /** Boot path in SEQTA.ts gates when this runs; keep render-only here. */
// single `.message` block; the real app mounts a `#container`. Require the export function mountErrorPageKitten(): () => void {
// former and rule out the latter so the plugin never renders inside the app. const styleEl = document.createElement("style");
function is404Page(): boolean { styleEl.textContent = styles;
if (document.getElementById("container")) return false; document.head.appendChild(styleEl);
const heading = document.querySelector(".message > h1");
const text = heading?.textContent ?? document.title; const originalTitle = document.title;
return /not found/i.test(text) || /404/.test(document.title); document.querySelector(".message")?.classList.add("bsplus-kitten-404-hidden");
} document.documentElement.classList.add(ROOT_CLASS);
document.title = "404 Not Found";
function buildCard(): HTMLElement {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = KITTEN_CLASS; card.className = ROOT_CLASS;
card.innerHTML = CARD_HTML;
document.body.appendChild(card);
const h1 = document.createElement("h1"); return () => {
h1.textContent = "404 Not Found"; styleEl.remove();
card.appendChild(h1); document.documentElement.classList.remove(ROOT_CLASS);
document
const p1 = document.createElement("p"); .querySelectorAll(".bsplus-kitten-404-hidden")
p1.textContent = .forEach((el) => el.classList.remove("bsplus-kitten-404-hidden"));
"Sorry — the resource you are looking for could not be found."; document.title = originalTitle;
card.appendChild(p1); card.remove();
};
const p2 = document.createElement("p");
p2.textContent =
"If you believe you are seeing this message in error, please contact your IT department.";
card.appendChild(p2);
const kitten = document.createElement("div");
kitten.className = "kitten";
const kittenLine = document.createElement("p");
kittenLine.textContent = "I did find this picture of a kitten for you, though!";
kitten.appendChild(kittenLine);
const img = document.createElement("img");
img.src = KITTEN_IMG;
kitten.appendChild(img);
const attrib = document.createElement("div");
attrib.className = "attrib";
attrib.append("CC-BY-SA ");
const link = document.createElement("a");
link.href = "http://www.flickr.com/photos/bibbit/2756165489/";
link.textContent = "by storyvillegirl";
attrib.appendChild(link);
kitten.appendChild(attrib);
card.appendChild(kitten);
const seqtaLink = document.createElement("a");
seqtaLink.href = "http://www.seqta.com.au";
seqtaLink.textContent = "SEQTA";
card.appendChild(seqtaLink);
return card;
} }
const errorPageKittenPlugin: Plugin = { const errorPageKittenPlugin: Plugin = {
@@ -81,52 +52,7 @@ const errorPageKittenPlugin: Plugin = {
disableToggle: true, disableToggle: true,
defaultEnabled: true, defaultEnabled: true,
styles, styles,
run: async () => mountErrorPageKitten(),
run: async (_api) => {
let card: HTMLElement | null = null;
let hasBuilt = false;
let originalTitle = document.title;
let stripeStyle: HTMLStyleElement | null = null;
const render = () => {
if (!is404Page()) return;
document.querySelector(".message")?.classList.add("bsplus-kitten-404-hidden");
if (hasBuilt) return;
const body = document.body;
if (!body) return;
hasBuilt = true;
document.documentElement.classList.add(KITTEN_CLASS);
document.title = "404 Not Found";
// Appended after the manager-injected styles so these same-specificity
// declarations win over the compiled baseline.
stripeStyle = document.createElement("style");
stripeStyle.textContent = H1_STRIPE_CSS;
document.head.appendChild(stripeStyle);
card = buildCard();
body.appendChild(card);
};
// Body may not be parsed yet at document_start; render now and again on
// DOM ready as a safety net.
render();
document.addEventListener("DOMContentLoaded", render, { once: true });
return () => {
document.removeEventListener("DOMContentLoaded", render);
document.documentElement.classList.remove(KITTEN_CLASS);
document
.querySelectorAll(".bsplus-kitten-404-hidden")
.forEach((el) => el.classList.remove("bsplus-kitten-404-hidden"));
document.title = originalTitle;
stripeStyle?.remove();
stripeStyle = null;
card?.remove();
card = null;
};
},
}; };
export default errorPageKittenPlugin; export default errorPageKittenPlugin;
@@ -1,14 +0,0 @@
import { defineLazyPlugin } from "../../core/dynamicLoader";
import styles from "./styles.css?inline";
export default defineLazyPlugin({
id: "error-page-kitten",
name: "Classic 404 Page",
description: "Brings back SEQTA's old kitten 404 page",
version: "1.0.0",
settings: {},
disableToggle: true,
defaultEnabled: true,
styles,
loader: () => import("./index"),
});
@@ -1,10 +1,5 @@
/* Classic SEQTA 404 page, restored one-to-one over the rebranded document. /* Classic SEQTA 404 page — scoped under html.bsplus-kitten-404 so it never leaks into the SPA. */
Scoped under html.bsplus-kitten-404 so it can never leak into the SPA;
the card element itself carries the .bsplus-kitten-404 class. */
/* The rebranded page styles <body> (Arial / flex / 25vh top / line-height
1.5 / letter-spacing). line-height and letter-spacing are reset because
the rebranded body sets them and the classic page uses browser defaults. */
html.bsplus-kitten-404 { html.bsplus-kitten-404 {
background: #333; background: #333;
} }
@@ -20,7 +15,6 @@ html.bsplus-kitten-404 body {
letter-spacing: normal; letter-spacing: normal;
} }
/* Hide the rebranded page content while the classic card is shown. */
html.bsplus-kitten-404 .bsplus-kitten-404-hidden { html.bsplus-kitten-404 .bsplus-kitten-404-hidden {
display: none !important; display: none !important;
} }
@@ -38,10 +32,6 @@ html.bsplus-kitten-404 .bsplus-kitten-404 {
html.bsplus-kitten-404 .bsplus-kitten-404 > h1 { html.bsplus-kitten-404 .bsplus-kitten-404 > h1 {
background: #612; background: #612;
/* Valid modern baseline; the reference's exact vendor-prefixed declarations
are injected at runtime (H1_STRIPE_CSS in index.ts), which win over this.
The vendor-prefixed lines can't live in this file because the CSS
minifier drops them. */
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
135deg, 135deg,
rgba(0, 0, 0, 0), rgba(0, 0, 0, 0),
-3
View File
@@ -26,9 +26,6 @@ interface StorageChange<T = any> {
/** Phased plugin startup: critical UI first, light DOM next, heavy plugins last. */ /** Phased plugin startup: critical UI first, light DOM next, heavy plugins last. */
const PLUGIN_START_PHASES: readonly string[][] = [ const PLUGIN_START_PHASES: readonly string[][] = [
// 404 page boot is a tiny standalone path; start it first so the kitten
// card renders before any SPA work. A no-op on normal SEQTA pages.
["error-page-kitten"],
["themes", "animated-background"], ["themes", "animated-background"],
[ [
"timetable", "timetable",
+2 -2
View File
@@ -16,7 +16,7 @@ import messageFoldersPluginLazy from "./built-in/messageFolders/lazy";
import enhancedNavigationPluginLazy from "./built-in/enhancedNavigation/lazy"; import enhancedNavigationPluginLazy from "./built-in/enhancedNavigation/lazy";
import globalSearchPluginLazy from "./built-in/globalSearch/lazy"; import globalSearchPluginLazy from "./built-in/globalSearch/lazy";
import gradeAnalyticsPluginLazy from "./built-in/gradeAnalytics/lazy"; import gradeAnalyticsPluginLazy from "./built-in/gradeAnalytics/lazy";
import errorPageKittenPluginLazy from "./built-in/errorPageKitten/lazy"; import errorPageKittenPlugin from "./built-in/errorPageKitten";
// Initialize plugin manager // Initialize plugin manager
const pluginManager = PluginManager.getInstance(); const pluginManager = PluginManager.getInstance();
@@ -37,7 +37,7 @@ pluginManager.registerPlugin(messageFoldersPluginLazy);
pluginManager.registerPlugin(enhancedNavigationPluginLazy); pluginManager.registerPlugin(enhancedNavigationPluginLazy);
pluginManager.registerPlugin(globalSearchPluginLazy); pluginManager.registerPlugin(globalSearchPluginLazy);
pluginManager.registerPlugin(gradeAnalyticsPluginLazy); pluginManager.registerPlugin(gradeAnalyticsPluginLazy);
pluginManager.registerPlugin(errorPageKittenPluginLazy); pluginManager.registerPlugin(errorPageKittenPlugin);
export async function initializePlugins(): Promise<void> { export async function initializePlugins(): Promise<void> {
await pluginManager.startAllPlugins(); await pluginManager.startAllPlugins();
-2
View File
@@ -1,2 +0,0 @@
// Jest stub for Vite asset imports (png, etc.): Vite returns a URL string.
export default "/resources/error-page/kitten.png";
-2
View File
@@ -1,2 +0,0 @@
// Jest stub for Vite's `?inline` css imports: returns a CSS string.
export default "/* test inline css */";
-3
View File
@@ -32,9 +32,6 @@ export default {
storage: { local, onChanged }, storage: { local, onChanged },
runtime: { runtime: {
sendMessage: jest.fn(async () => undefined), sendMessage: jest.fn(async () => undefined),
getURL: jest.fn(
(path: string) => `chrome-extension://test/${String(path).replace(/^\/+/, "")}`,
),
}, },
}; };