Initial commit: code as received (Create/Anything export)
Insole-production time tracker exported from the Create/Anything AI platform. Baseline snapshot before any reverse-engineering or cleanup. - apps/mobile: Expo Router app (iOS/Android/web), the only workspace - publisher/: standalone OpenNext/AWS deploy tooling for the web side - Backend (/api/tasks, /api/logs + DB) lives remotely, not in this repo
This commit is contained in:
428
apps/mobile/__create/testflight-logger.test.ts
Normal file
428
apps/mobile/__create/testflight-logger.test.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
let mockSendLogsToRemote: jest.Mock;
|
||||
let mockGetItem: jest.Mock;
|
||||
let mockSetItem: jest.Mock;
|
||||
let mockRemoveItem: jest.Mock;
|
||||
let mockFileStore: Record<string, string>;
|
||||
let capturedErrorHandler: ((error: Error, isFatal?: boolean) => void) | null;
|
||||
let originalErrorUtils: unknown;
|
||||
|
||||
const CRASH_FILE = "/doc/testflight_crash_logs.json";
|
||||
|
||||
const STORAGE_KEY = "testflight_logger_pending_logs";
|
||||
let originalDev: boolean;
|
||||
|
||||
function setDevMode(value: boolean) {
|
||||
(globalThis as Record<string, unknown>).__DEV__ = value;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalDev = (globalThis as Record<string, unknown>).__DEV__ as boolean;
|
||||
jest.resetModules();
|
||||
jest.useFakeTimers();
|
||||
process.env.EXPO_PUBLIC_CREATE_ENV = "PRODUCTION";
|
||||
|
||||
mockSendLogsToRemote = jest.fn().mockResolvedValue({ success: true });
|
||||
mockGetItem = jest.fn().mockResolvedValue(null);
|
||||
mockSetItem = jest.fn().mockResolvedValue(undefined);
|
||||
mockRemoveItem = jest.fn().mockResolvedValue(undefined);
|
||||
mockFileStore = {};
|
||||
|
||||
capturedErrorHandler = null;
|
||||
originalErrorUtils = (globalThis as Record<string, unknown>).ErrorUtils;
|
||||
(globalThis as Record<string, unknown>).ErrorUtils = {
|
||||
getGlobalHandler: () => () => {},
|
||||
setGlobalHandler: (handler: (error: Error, isFatal?: boolean) => void) => {
|
||||
capturedErrorHandler = handler;
|
||||
},
|
||||
};
|
||||
|
||||
jest.doMock("./report-error-to-remote", () => ({
|
||||
sendLogsToRemote: mockSendLogsToRemote,
|
||||
}));
|
||||
|
||||
jest.doMock("@react-native-async-storage/async-storage", () => ({
|
||||
getItem: mockGetItem,
|
||||
setItem: mockSetItem,
|
||||
removeItem: mockRemoveItem,
|
||||
}));
|
||||
|
||||
jest.doMock("expo-file-system", () => {
|
||||
class MockFile {
|
||||
uri: string;
|
||||
constructor(directory: string, name: string) {
|
||||
this.uri = `${directory}/${name}`;
|
||||
}
|
||||
get exists() {
|
||||
return Object.prototype.hasOwnProperty.call(mockFileStore, this.uri);
|
||||
}
|
||||
create() {
|
||||
if (!(this.uri in mockFileStore)) mockFileStore[this.uri] = "";
|
||||
}
|
||||
delete() {
|
||||
delete mockFileStore[this.uri];
|
||||
}
|
||||
write(content: string) {
|
||||
mockFileStore[this.uri] = content;
|
||||
}
|
||||
textSync() {
|
||||
return mockFileStore[this.uri] ?? "";
|
||||
}
|
||||
}
|
||||
return { File: MockFile, Paths: { document: "/doc" } };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
setDevMode(originalDev);
|
||||
delete process.env.EXPO_PUBLIC_CREATE_ENV;
|
||||
(globalThis as Record<string, unknown>).ErrorUtils = originalErrorUtils;
|
||||
});
|
||||
|
||||
function loadModule() {
|
||||
return require("./testflight-logger") as typeof import("./testflight-logger");
|
||||
}
|
||||
|
||||
describe("initTestFlightLogger", () => {
|
||||
it("is a no-op when __DEV__ is true", async () => {
|
||||
setDevMode(true);
|
||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
|
||||
expect(getTestFlightLogger()).toBeNull();
|
||||
expect(mockSendLogsToRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when EXPO_PUBLIC_CREATE_ENV is DEVELOPMENT", async () => {
|
||||
setDevMode(false);
|
||||
process.env.EXPO_PUBLIC_CREATE_ENV = "DEVELOPMENT";
|
||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
|
||||
expect(getTestFlightLogger()).toBeNull();
|
||||
});
|
||||
|
||||
it("activates when not in dev mode", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
const logger = getTestFlightLogger();
|
||||
expect(logger).not.toBeNull();
|
||||
expect(logger).toHaveProperty("logError");
|
||||
});
|
||||
|
||||
it("only creates one instance on multiple calls", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const first = getTestFlightLogger();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const second = getTestFlightLogger();
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("console patching", () => {
|
||||
it("intercepts console.log and buffers the message", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.log("test message");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
||||
const logMessage = logs.find(
|
||||
(l: Record<string, string>) => l.message === "test message",
|
||||
);
|
||||
expect(logMessage).toBeDefined();
|
||||
expect(logMessage.level).toBe("log");
|
||||
expect(logMessage.source).toBe("TEST_FLIGHT");
|
||||
});
|
||||
|
||||
it("intercepts console.error", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.error("bad thing");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
||||
const errorLog = logs.find(
|
||||
(l: Record<string, string>) => l.message === "bad thing",
|
||||
);
|
||||
expect(errorLog).toBeDefined();
|
||||
expect(errorLog.level).toBe("error");
|
||||
});
|
||||
|
||||
it("serializes non-string arguments as JSON", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.log("count:", { x: 1 });
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
||||
const entry = logs.find((l: Record<string, string>) =>
|
||||
l.message.includes("count:"),
|
||||
);
|
||||
expect(entry.message).toBe('count: {"x":1}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("logError", () => {
|
||||
it("immediately flushes error entries", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
const logger = getTestFlightLogger()!;
|
||||
logger.logError("critical failure");
|
||||
|
||||
// Should flush without waiting for the 5s interval
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
||||
expect(logs[0].message).toBe("critical failure");
|
||||
expect(logs[0].level).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buffering and flushing", () => {
|
||||
it("flushes buffer every 5 seconds", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.log("entry 1");
|
||||
|
||||
// Not flushed yet at 3 seconds
|
||||
await jest.advanceTimersByTimeAsync(3_000);
|
||||
expect(mockSendLogsToRemote).not.toHaveBeenCalled();
|
||||
|
||||
// Flushed at 5 seconds
|
||||
await jest.advanceTimersByTimeAsync(2_000);
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("auto-flushes when buffer reaches 50 entries", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
console.log(`entry ${i}`);
|
||||
}
|
||||
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistence and retry", () => {
|
||||
it("persists logs to AsyncStorage when flush fails", async () => {
|
||||
mockSendLogsToRemote.mockResolvedValue({ success: false });
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.log("will fail");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(mockSetItem).toHaveBeenCalledWith(
|
||||
STORAGE_KEY,
|
||||
expect.stringContaining("will fail"),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores persisted logs on startup and re-sends them", async () => {
|
||||
const persistedLogs = [
|
||||
{
|
||||
message: "old log",
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
level: "error",
|
||||
source: "TEST_FLIGHT",
|
||||
sessionId: "old-session",
|
||||
},
|
||||
];
|
||||
mockGetItem.mockResolvedValueOnce(JSON.stringify(persistedLogs));
|
||||
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockRemoveItem).toHaveBeenCalledWith(STORAGE_KEY);
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalledWith(persistedLogs);
|
||||
});
|
||||
|
||||
it("re-persists restored logs if resend also fails", async () => {
|
||||
const persistedLogs = [
|
||||
{
|
||||
message: "stubborn log",
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
level: "error",
|
||||
source: "TEST_FLIGHT",
|
||||
sessionId: "old-session",
|
||||
},
|
||||
];
|
||||
mockGetItem
|
||||
.mockResolvedValueOnce(JSON.stringify(persistedLogs))
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
mockSendLogsToRemote.mockResolvedValueOnce({ success: false });
|
||||
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockSetItem).toHaveBeenCalledWith(
|
||||
STORAGE_KEY,
|
||||
expect.stringContaining("stubborn log"),
|
||||
);
|
||||
});
|
||||
|
||||
it("caps persisted entries at 200", async () => {
|
||||
const existingLogs = Array.from({ length: 195 }, (_, i) => ({
|
||||
message: `existing ${i}`,
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
level: "log",
|
||||
source: "TEST_FLIGHT",
|
||||
sessionId: "s",
|
||||
}));
|
||||
|
||||
mockSendLogsToRemote.mockResolvedValue({ success: false });
|
||||
mockGetItem
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(JSON.stringify(existingLogs));
|
||||
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
console.log(`new ${i}`);
|
||||
}
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
const setItemCalls = mockSetItem.mock.calls;
|
||||
const lastCall = setItemCalls[setItemCalls.length - 1];
|
||||
const saved = JSON.parse(lastCall[1]);
|
||||
expect(saved.length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("crash persistence", () => {
|
||||
it("synchronously snapshots the buffer to the crash file on a fatal error", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.log("breadcrumb before crash");
|
||||
capturedErrorHandler!(new Error("startup boom"), true);
|
||||
|
||||
const crashFile = mockFileStore[CRASH_FILE];
|
||||
expect(crashFile).toContain("startup boom");
|
||||
expect(crashFile).toContain("breadcrumb before crash");
|
||||
});
|
||||
|
||||
it("does not snapshot the crash file for a non-fatal error", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
capturedErrorHandler!(new Error("recoverable"), false);
|
||||
|
||||
expect(mockFileStore[CRASH_FILE]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("captures the in-flight batch when a fatal error triggers auto-flush", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
// One short of the auto-flush threshold; the fatal error is the 50th
|
||||
// entry, so addEntry's auto-flush empties `buffer` before
|
||||
// persistBufferSync runs.
|
||||
for (let i = 0; i < 49; i++) {
|
||||
console.log(`entry ${i}`);
|
||||
}
|
||||
capturedErrorHandler!(new Error("boundary crash"), true);
|
||||
|
||||
const crashFile = mockFileStore[CRASH_FILE];
|
||||
expect(crashFile).toContain("boundary crash");
|
||||
expect(crashFile).toContain("entry 0");
|
||||
});
|
||||
|
||||
it("ships crash-file logs on the next startup and clears the file", async () => {
|
||||
mockFileStore[CRASH_FILE] = JSON.stringify([
|
||||
{
|
||||
message: "[FATAL] crashed last run",
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
level: "error",
|
||||
source: "TEST_FLIGHT",
|
||||
sessionId: "prev",
|
||||
},
|
||||
]);
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
||||
const sent = mockSendLogsToRemote.mock.calls[0][0];
|
||||
expect(
|
||||
sent.some(
|
||||
(entry: Record<string, string>) =>
|
||||
entry.message === "[FATAL] crashed last run",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(mockFileStore[CRASH_FILE]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user