// Loads the GENERATED Apps Script files (apps-script/*/Code.js) in a sandbox with // small fakes of the Google services, and runs the documented setup tests against // them. This checks the bundle and the adapters; it is not a run inside Google. import { readFileSync } from "node:fs"; import vm from "node:vm"; import { describe, expect, it } from "vitest"; type Cell = string | Date; class FakeSheet { data: Cell[][] = []; constructor(public name: string, public book: FakeBook) {} setName(n: string) { this.name = n; return this; } getName() { return this.name; } getParent() { return this.book; } appendRow(r: Cell[]) { this.data.push([...r]); return this; } getLastRow() { return this.data.length; } getLastColumn() { return Math.max(0, ...this.data.map((r) => r.length)); } getDataRange() { return this.getRange(1, 1, Math.max(1, this.getLastRow()), Math.max(1, this.getLastColumn())); } getRange(row: number, col: number, nr = 1, nc = 1) { return new FakeRange(this, row, col, nr, nc); } } class FakeRange { constructor(public s: FakeSheet, public row: number, public col: number, public nr: number, public nc: number) {} getValues() { const out: Cell[][] = []; for (let i = 0; i < this.nr; i++) { const r = this.s.data[this.row - 1 + i] ?? []; out.push(Array.from({ length: this.nc }, (_, j) => r[this.col - 1 + j] ?? "")); } return out; } setValue(v: Cell) { while (this.s.data.length < this.row) this.s.data.push([]); this.s.data[this.row - 1][this.col - 1] = v; return this; } setNumberFormat() { return this; } copyTo(target: FakeRange) { const vals = this.getValues(); vals.forEach((r, i) => r.forEach((v, j) => target.s.getRange(target.row + i, target.col + j).setValue(v))); } sort(col: number) { const block = this.s.data.slice(this.row - 1, this.row - 1 + this.nr); block.sort((a, b) => String(a[col - 1]).localeCompare(String(b[col - 1]))); this.s.data.splice(this.row - 1, this.nr, ...block); } } class FakeBook { sheets: FakeSheet[] = []; constructor(public id: string, public title: string) { this.sheets.push(new FakeSheet("Sheet1", this)); } getSheets() { return this.sheets; } getSheetByName(n: string) { return this.sheets.find((s) => s.name === n) ?? null; } insertSheet(n: string) { const s = new FakeSheet(n, this); this.sheets.push(s); return s; } getId() { return this.id; } getUrl() { return `https://docs.google.com/spreadsheets/d/${this.id}`; } getSpreadsheetTimeZone() { return "America/New_York"; } } function gas(opts: { user?: string } = {}) { const books = new Map(); const props = new Map(); const outbox: { to: string; subject: string; body: string }[] = []; let triggers: { id: string; handler: string }[] = []; let locked = false; const logs: string[] = []; const order: string[] = []; const mail = { fail: false }; const ctx = { console: { log: (s: string) => logs.push(s) }, Logger: { log: (s: string) => logs.push(s) }, SpreadsheetApp: { create: (title: string) => { const b = new FakeBook(`book${books.size + 1}`, title); books.set(b.id, b); return b; }, openById: (id: string) => { const b = books.get(id); if (!b) throw new Error("not found"); return b; }, flush: () => { order.push("flush"); if ((ctx as { flushFails?: boolean }).flushFails) throw new Error("flush failed"); }, }, PropertiesService: { getScriptProperties: () => ({ getProperty: (k: string) => props.get(k) ?? null, setProperty: (k: string, v: string) => props.set(k, v), setProperties: (o: Record) => Object.entries(o).forEach(([k, v]) => props.set(k, v)), deleteProperty: (k: string) => props.delete(k), }), }, MailApp: { sendEmail: (m: { to: string; subject: string; body: string }) => { if (mail.fail) throw new Error("Service invoked too many times"); order.push("mail"); outbox.push(m); }, getRemainingDailyQuota: () => 100 - outbox.length, }, LockService: { getScriptLock: () => ({ tryLock: () => (locked ? false : (locked = true)), releaseLock: () => { locked = false; } }), }, ScriptApp: { getProjectTriggers: () => triggers.map((t) => ({ getUniqueId: () => t.id, getHandlerFunction: () => t.handler })), deleteTrigger: (t: { getUniqueId: () => string }) => { triggers = triggers.filter((x) => x.id !== t.getUniqueId()); }, newTrigger: (handler: string) => { const b = { timeBased: () => b, everyMinutes: () => b, everyDays: () => b, atHour: () => b, create: () => { triggers.push({ id: `t${triggers.length + Math.random()}`, handler }); } }; return b; }, getAuthorizationInfo: () => ({ getAuthorizationStatus: () => "NOT_REQUIRED", getAuthorizationUrl: () => "" }), AuthMode: { FULL: "FULL" }, AuthorizationStatus: { REQUIRED: "REQUIRED" }, }, Session: { getEffectiveUser: () => ({ getEmail: () => opts.user ?? "tester@example.com" }), getScriptTimeZone: () => "America/New_York" }, Utilities: { formatDate: (d: Date, _tz: string, f: string) => (f === "yyyy-MM-dd" ? d.toISOString().slice(0, 10) : d.toISOString()), }, }; vm.createContext(ctx); return { ctx: ctx as unknown as Record unknown>, raw: ctx as Record, books, props, outbox, logs, order, mail, triggers: () => triggers }; } const load = (g: ReturnType, p: string) => vm.runInContext(readFileSync(`apps-script/${p}/Code.js`, "utf8"), g.ctx); describe("the generated Apps Script bundle (sandboxed fakes, not Google)", () => { it("runs the documented setup tests with the expected results", () => { const g = gas(); load(g, "sender-demo"); g.ctx.createDemoWorkbooks(); const log = g.ctx.setupTests() as string[]; const line = (p: string) => log.find((l) => l.startsWith(p)) ?? ""; expect(line("T1")).toContain("sent=0 baselined=3 wouldSend=tester+maya@example.com"); expect(line("T2")).toContain("sent=tester+maya@example.com"); expect(line("T3")).toContain("sent=0 mayaCopies=2"); expect(line("T4 next pass")).toContain("sent=0 grace=needs-review/uncertain-send"); expect(line("T5")).toContain("outcome=failed"); expect(line("T6")).toContain("second created=none; triggers now=runPass"); // Maya once, Grace once (the interrupted send really went out), nobody else. expect(g.outbox.map((m) => m.to)).toEqual(["tester+maya@example.com", "tester+grace@example.com"]); expect(g.outbox[0].subject).toBe("Welcome to Mentella Health, Maya"); }); it("refuses to run the setup tests if any address is not the running account's own", () => { const g = gas(); load(g, "sender-demo"); g.ctx.createDemoWorkbooks(); const hiring = g.books.get(g.props.get("HIRING_BOOK_ID")!)!.getSheetByName("Hiring")!; hiring.appendRow(["", "Someone Real", "Hired", "2026-10-05", "someone@elsewhere.com", "KR"]); expect(() => g.ctx.setupTests()).toThrow(/Refusing to run/); expect(g.outbox).toEqual([]); }); it("the checking project reads the same workbooks and reports a stopped routine", () => { const g = gas(); load(g, "sender-demo"); g.ctx.createDemoWorkbooks(); g.ctx.setupTests(); // Same fake Google, second project (in Google it would be another project and another account). load(g, "monitor"); g.props.set("TECHNICAL_OWNER", "Technical owner"); g.props.set("HIRING_COORDINATOR", "Hiring coordinator"); g.props.set("ALERT_TO", "tester+alerts@example.com"); g.props.set("STALE_AFTER_MINUTES", "-1"); // everything is stale: the routine counts as stopped const before = g.outbox.length; const r = g.ctx.runCheck() as { status: string; emailed: string[] }; expect(r.emailed).toContain("routine-stopped"); expect(r.emailed).toContain("uncertain-send"); const mails = g.outbox.slice(before); expect(mails.every((m) => m.to === "tester+alerts@example.com")).toBe(true); expect(mails.find((m) => m.subject.includes("stopped"))?.body).toContain("What to do:"); // Second check: nothing new is emailed. const again = g.ctx.runCheck() as { emailed: string[] }; expect(again.emailed).toEqual([]); }); function monitorAfterSetup() { const g = gas(); load(g, "sender-demo"); g.ctx.createDemoWorkbooks(); g.ctx.setupTests(); load(g, "monitor"); g.props.set("TECHNICAL_OWNER", "Technical owner"); g.props.set("HIRING_COORDINATOR", "Hiring coordinator"); g.props.set("ALERT_TO", "tester+alerts@example.com"); g.props.set("SUMMARY_TO", "tester+summary@example.com"); g.props.set("STALE_AFTER_MINUTES", "-1"); return g; } it("A01: a note that fails to send is sent at the next check", () => { const g = monitorAfterSetup(); g.mail.fail = true; const r1 = g.ctx.runCheck() as { emailed: string[]; failed: string[] }; expect(r1.emailed).toEqual([]); expect(r1.failed.length).toBeGreaterThan(0); g.mail.fail = false; const r2 = g.ctx.runCheck() as { emailed: string[] }; expect(r2.emailed).toContain("routine-stopped"); }); it("A02: the morning summary does not use up an alert meant for its recipient", () => { const g = monitorAfterSetup(); g.ctx.morningSummary(); const r = g.ctx.runCheck() as { emailed: string[] }; expect(r.emailed).toContain("routine-stopped"); expect(g.outbox.filter((m) => m.to === "tester+alerts@example.com").length).toBeGreaterThan(0); }); it("A03: a lower-case header is found when writing the reference, as when reading", () => { const g = gas(); load(g, "sender-demo"); g.ctx.createDemoWorkbooks(); const hiring = g.books.get(g.props.get("HIRING_BOOK_ID")!)!.getSheetByName("Hiring")!; hiring.data[0] = hiring.data[0].map((h) => String(h).toLowerCase()); g.ctx.runPass(); const ids = hiring.data.slice(1).map((r) => r[0]); expect(ids.every((x) => String(x).startsWith("A-"))).toBe(true); }); it("the journal is flushed before the mail call; if the flush fails, no letter is sent", () => { const g = gas(); load(g, "sender-demo"); g.ctx.createDemoWorkbooks(); g.props.set("MODE", "live"); g.ctx.runPass(); expect(g.order.indexOf("flush")).toBeLessThan(g.order.indexOf("mail")); const h = gas(); load(h, "sender-demo"); h.ctx.createDemoWorkbooks(); h.props.set("MODE", "live"); h.raw.flushFails = true; expect(() => h.ctx.runPass()).toThrow(); expect(h.outbox).toEqual([]); }); });