mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: harden against three production Sentry crashes (#328)
Three production crashes from the snapotter/node Sentry project.
feature-status (NODE-12): a valid-JSON-but-wrong-shape installed.json
crashed boot via Object.keys(data.bundles). readInstalled() now
normalizes any unusable shape to { bundles: {} }, and the boot recovery
call is wrapped so cleanup can never fatal startup.
image-viewer (NODE-15/17/18): drag-to-pan read .x off an undefined
use-gesture memo on pointerUp or a pinch-into-pan. A guarded pure helper
(resolvePanStart) now falls back to the live pan offset.
Fastify (NODE-14): raised pluginTimeout to 60s so slow self-hosted boots
do not fatal at @fastify/static.
This commit is contained in:
@@ -120,6 +120,61 @@ describe("installed.json management", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for NODE-12 (Sentry): installed.json that is *valid JSON* but
|
||||
// whose shape lacks a usable `bundles` object (e.g. "{}", '{"bundles":null}',
|
||||
// a top-level array, or an older format) used to crash at boot with
|
||||
// "Cannot convert undefined or null to object" via Object.keys(data.bundles) in
|
||||
// recoverInterruptedInstalls, `bundleId in data.bundles` in isFeatureInstalled,
|
||||
// and installed.bundles[...] in getFeatureStates. readInstalled() must coerce
|
||||
// any unusable shape to { bundles: {} } so these never throw.
|
||||
describe("malformed installed.json shape (NODE-12 regression)", () => {
|
||||
const BAD_SHAPES: Array<[string, string]> = [
|
||||
["object with no bundles key", JSON.stringify({})],
|
||||
["bundles is null", JSON.stringify({ bundles: null })],
|
||||
["bundles is an array", JSON.stringify({ bundles: [] })],
|
||||
["bundles is a string", JSON.stringify({ bundles: "nope" })],
|
||||
["top-level array", JSON.stringify([{ ocr: {} }])],
|
||||
["top-level number", JSON.stringify(42)],
|
||||
["top-level null", JSON.stringify(null)],
|
||||
["unrelated shape", JSON.stringify({ version: 2, installed: ["ocr"] })],
|
||||
];
|
||||
|
||||
for (const [label, contents] of BAD_SHAPES) {
|
||||
it(`recoverInterruptedInstalls does not throw when installed.json is ${label}`, () => {
|
||||
// A present manifest is what drives the Object.keys(data.bundles) loop.
|
||||
writeTestManifest({ "background-removal": { models: [] } });
|
||||
writeFileSync(installedPath, contents);
|
||||
mod.invalidateCache();
|
||||
expect(() => mod.recoverInterruptedInstalls()).not.toThrow();
|
||||
});
|
||||
|
||||
it(`isFeatureInstalled returns false (no throw) when installed.json is ${label}`, () => {
|
||||
writeFileSync(installedPath, contents);
|
||||
mod.invalidateCache();
|
||||
expect(() => mod.isFeatureInstalled("background-removal")).not.toThrow();
|
||||
expect(mod.isFeatureInstalled("background-removal")).toBe(false);
|
||||
});
|
||||
|
||||
it(`getFeatureStates reports all not_installed (no throw) when installed.json is ${label}`, () => {
|
||||
writeFileSync(installedPath, contents);
|
||||
mod.invalidateCache();
|
||||
expect(() => mod.getFeatureStates()).not.toThrow();
|
||||
expect(mod.getFeatureStates().every((s) => s.status === "not_installed")).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it("still reads a valid bundles object after rejecting bad shapes", () => {
|
||||
writeFileSync(
|
||||
installedPath,
|
||||
JSON.stringify({
|
||||
bundles: { ocr: { version: "1.0.0", installedAt: "2026-01-01T00:00:00.000Z", models: [] } },
|
||||
}),
|
||||
);
|
||||
mod.invalidateCache();
|
||||
expect(mod.isFeatureInstalled("ocr")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cache behavior", () => {
|
||||
it("isFeatureInstalled reads from cache on second call", () => {
|
||||
mod.markInstalled("ocr", "1.0.0", []);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePanStart } from "@/components/common/image-viewer-drag";
|
||||
|
||||
describe("resolvePanStart (ImageViewer drag-to-pan)", () => {
|
||||
it("starts from a copy of the current pan offset on the first frame", () => {
|
||||
const panOffset = { x: 10, y: 20 };
|
||||
const start = resolvePanStart(true, undefined, panOffset);
|
||||
expect(start).toEqual({ x: 10, y: 20 });
|
||||
// Must be a copy, not the live state object, so accumulating movement
|
||||
// does not mutate the committed offset.
|
||||
expect(start).not.toBe(panOffset);
|
||||
});
|
||||
|
||||
it("reuses memo on subsequent frames so the drag accumulates from one anchor", () => {
|
||||
const memo = { x: 5, y: 6 };
|
||||
expect(resolvePanStart(false, memo, { x: 0, y: 0 })).toBe(memo);
|
||||
});
|
||||
|
||||
// Regression for NODE-15 / NODE-17 / NODE-18 (Sentry): a non-first frame can
|
||||
// arrive with memo never set: on pointerUp, or when a concurrent pinch flips
|
||||
// the viewer into actual-size (pan) mode mid-gesture. The old handler read
|
||||
// `memo.x` directly and threw "Cannot read properties of undefined (reading
|
||||
// 'x')" across Chrome/Safari/Firefox. It must fall back to the live offset.
|
||||
it("falls back to the current pan offset when memo is missing on a non-first frame", () => {
|
||||
const panOffset = { x: 3, y: 4 };
|
||||
expect(() => resolvePanStart(false, undefined, panOffset)).not.toThrow();
|
||||
expect(resolvePanStart(false, undefined, panOffset)).toEqual({ x: 3, y: 4 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user