const assert = require("node:assert/strict"); const { test } = require("node:test"); const fs = require("node:fs"); const vm = require("node:vm"); const { loadBackground } = require("./support/characterization.cjs"); const factory = (file, name) => new vm.Script(fs.readFileSync(file, "utf8") + `\n${name};`).runInNewContext(); const dates = factory("src/shared/time-dates.js", "createTimeDates")(); const week = () => Array.from({ length: 7 }, (_, index) => ({ Id: 2927 + index, WeekId: 419, Date: `${10 + index}-OCT-2026`, EndDate: "16-OCT-2026" })); const flush = () => new Promise(resolve => setImmediate(resolve)); test("time dates use English API dates and calendar-day arithmetic across month/year boundaries", () => { assert.equal(dates.toApi("2026-10-16"), "16-OCT-2026"); assert.equal(dates.shift("2026-12-28", 7), "2027-01-04"); assert.equal(dates.shift("2026-03-03", -7), "2026-02-24"); assert.equal(dates.fromApi("10-OCT-2026").getUTCDay(), 6); assert.equal(dates.fromApi("31-FEB-2026"), null); assert.throws(() => dates.toApi("2026-02-30")); }); for (const family of ["chrome", "firefox"]) test(`${family}: week details use Session, include cookies and preserve debug response`, async () => { const calls = []; const app = loadBackground({ family, fetch: async (url, options) => { calls.push({ url, options }); return new Response(JSON.stringify(week()), { headers: { "content-type": "application/json" } }); } }); const result = await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "2026-10-16" }); assert.equal(result.ok, true); assert.equal(result.items.length, 7); assert.match(calls[0].url, /oalset_timeentrymobile\/live;profile=PROD\/.*weekDetails\?dateValue=16-OCT-2026$/); assert.equal(calls[0].options.headers.Authorization, "Session"); assert.equal(calls[0].options.credentials, "include"); assert.equal(calls[0].options.method, "GET"); assert.equal(result.debugRequests[0].name, "requestWeekDetails"); assert.match(result.debugRequests[0].responseBody, /10-OCT-2026/); assert.equal((await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "invalid" })).ok, false); assert.equal(calls.length, 1); }); test("week errors return status and debug logs without masking failure", async () => { const app = loadBackground({ fetch: async () => new Response("Unauthorized", { status: 401 }) }); const result = await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "2026-10-16" }); assert.equal(result.ok, false); assert.equal(result.httpStatus, 401); assert.equal(result.debugRequests.length, 1); }); function setup(requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => []) { let document; class Element { constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; } set textContent(value) { this.text = value; this.children = []; } get textContent() { return this.text + this.children.map(child => child.textContent).join(""); } append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } } replaceChildren(...children) { this.children = []; this.append(...children); } setAttribute(key, value) { this.attributes[key] = value; } addEventListener(key, fn) { this.listeners[key] = fn; } focus() { document.activeElement = this; } remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; } click() { if (!this.disabled) this.listeners.click?.(); } } const host = new Element("host"); document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag), getElementById: () => host }; const calls = []; const searchableSelect = factory("src/features/opportunities/searchable-select.js", "createSearchableSelect")({ environment: { document } }); const app = factory("src/features/manage-time/view.js", "createManageTime")({ MODAL_ID: "host", environment: { document }, searchableSelect, dates, requestServices, requestExpand, requestTaskTypes, requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) }); app.setReady(true); const walk = el => [el, ...el.children.flatMap(walk)]; const nodes = () => walk(host); const find = label => nodes().find(el => el.attributes["aria-label"] === label); return { app, calls, nodes, find, host }; } test("Service Request tooltips use expanded fields in order and keep description separate", async () => { const keys = ["SrNumber", "AccountPartyUniqueName", "OptyName_c", "OptyNumber_c", "OptyOwner_c", "OptyStatus_c", "ServiceNew_c", "ReportedByPartyUniqueName", "CountryText_c", "StatusCdMeaning", "AssigneePersonName", "ProblemDescription"]; const record = Object.fromEntries(keys.map(key => [key, key === "SrNumber" ? "SR001" : `Value ${key}`])); record.ProblemDescription = "Plain text\nSecond line"; const h = setup(async () => [{ srNumber: "SR001", srTitle: "Example" }], async () => [record]); h.app.open(); h.calls[0].resolve(week()); await flush(); const input = h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === "Service Request, row 1"); input.click(); const list = input.parent.children[1], tooltip = input.parent.children[2]; list.children[0].listeners.mouseenter(); assert.equal(tooltip.hidden, false); const fields = tooltip.children[0].children; assert.deepEqual(fields.map(field => field.children[0].textContent), ["SR number", "Account", "Opportunity", "Opty number", "Opty owner", "Opty status", "Service", "Requested by", "Country", "Status", "Service lead", "Description"]); assert.deepEqual(fields.map(field => field.children[1].textContent), keys.map(key => record[key])); assert.equal(fields[11].className, "oe-time-sr-description"); assert.equal(fields[11].children[1].textContent, record.ProblemDescription); assert.equal(fields[11].children[1].children.length, 0); input.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} }); assert.equal(tooltip.hidden, true); assert.equal(h.nodes().filter(el => el.attributes.role === "tooltip").length, 3); }); test("Manage Time autocompletes search and select SR, dependent Activity and filtered Task Type", async () => { const h = setup(async () => [{ srNumber: "SR001", srTitle: "Customer migration" }], async () => [{ SrNumber: "SR001", SRActivities_c: "Account Plan;Technical Support" }], async () => [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Configuration", tags_c: "SERVICE" }, { lookUpCode_c: "TRAIN", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }]); h.app.open(); h.calls[0].resolve(week()); await flush(); const combo = label => h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === label); function choose(label, query) { const input = combo(label); input.focus(); input.value = query; input.listeners.input(); const list = input.parent.children[1]; assert.equal(list.hidden, false); assert.equal(list.children.length, 1); input.listeners.keydown({ key: "Enter", preventDefault() {} }); } assert.equal(h.find("Service Request, row 1").hidden, true); assert.equal(combo("Activity, row 1").disabled, true); choose("Service Request, row 1", "sr001"); assert.equal(combo("Service Request, row 1").value, "Customer migration (SR001)"); assert.equal(h.find("Service Request, row 1").value, "SR001"); assert.equal(combo("Activity, row 1").disabled, false); choose("Activity, row 1", "technical"); choose("Task Type, row 1", "config"); assert.equal(h.find("Task Type, row 1").value, "CONFIG"); h.find("Copy row 1").click(); assert.equal(combo("Activity, row 4").value, "Technical Support"); h.find("Add (Non service)").click(); choose("Task Type, row 5", "train"); const ids = h.nodes().filter(el => el.attributes.role === "combobox").map(el => el.id); assert.equal(new Set(ids).size, ids.length); }); for (const family of ["chrome", "firefox"]) test(`${family}: Task Type lookup uses the exact endpoint, Session and logs its response`, async () => { const app = loadBackground({ family, fetch: async (url, options) => { assert.match(url, /oalset_timeentrymobile\/live;profile=PROD\/.*\/crm\/lookups\?lookupType=SCTA_TASK_TYPE&type=CUSTOM$/); assert.equal(options.method, "GET"); assert.equal(options.credentials, "include"); assert.equal(options.headers.Authorization, "Session"); return new Response(JSON.stringify({ items: [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config" }] })); } }); const result = await app.dispatch({ type: "opportunitiesExtension.requestLookupTaskType" }); assert.equal(result.ok, true); assert.equal(result.items[0].lookUpCode_c, "CONFIG"); assert.equal(result.debugRequests[0].name, "requestLookupTaskType"); }); test("Task Types load once per opening and retain selected codes when copying or navigating weeks", async () => { let count = 0; const h = setup(undefined, undefined, async () => { count++; return [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }]; }); h.app.open(); h.calls[0].resolve(week()); await flush(); const select = h.find("Task Type, row 1"); assert.equal(select.disabled, false); assert.equal(select.children[1].value, "CONFIG"); assert.equal(select.children[1].textContent, "Config"); select.value = "CONFIG"; select.listeners.change(); h.find("Copy row 1").click(); assert.equal(h.find("Task Type, row 4").value, "CONFIG"); h.find("Next week").click(); h.calls[1].resolve(week()); await flush(); assert.equal(h.find("Task Type, row 1").value, "CONFIG"); assert.equal(count, 1); }); test("Task Types match the exact row tag, including new and copied non-service rows", async () => { const h = setup(undefined, undefined, async () => [ { lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }, { lookUpCode_c: "TRAINING", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }, { lookUpCode_c: "OTHER", lookUpMeaning_c: "Other", tags_c: "OTHER" }, { lookUpCode_c: "UNTAGGED", lookUpMeaning_c: "Untagged" } ]); h.app.open(); h.calls[0].resolve(week()); await flush(); assert.deepEqual(h.find("Task Type, row 1").children.map(item => item.value), ["", "CONFIG"]); h.find("Add (Non service)").click(); const select = h.find("Task Type, row 4"); assert.deepEqual(select.children.map(item => item.value), ["", "TRAINING"]); select.value = "TRAINING"; select.listeners.change(); h.find("Copy row 4").click(); assert.equal(h.find("Task Type, row 5").value, "TRAINING"); assert.deepEqual(h.find("Task Type, row 5").children.map(item => item.value), ["", "TRAINING"]); }); test("Task Type stays disabled when no options match the row tag", async () => { const h = setup(undefined, undefined, async () => [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }]); h.app.open(); h.calls[0].resolve(week()); await flush(); h.find("Add (Non service)").click(); assert.equal(h.find("Task Type, row 4").disabled, true); assert.equal(h.find("Task Type, row 4").children.length, 1); }); test("Task Type failure can be retried and late responses cannot alter a closed modal", async () => { let resolve; let count = 0; const h = setup(undefined, undefined, () => { if (++count === 1) return Promise.reject(new Error("Lookup unavailable")); return new Promise(done => { resolve = done; }); }); h.app.open(); h.calls[0].resolve(week()); await flush(); assert.equal(h.find("Task Type, row 1").disabled, true); assert.equal(h.find("Retry Task Types").hidden, false); h.find("Retry Task Types").click(); h.app.close(); resolve([]); await flush(); assert.equal(h.host.children.length, 0); }); test("modal loads today, navigates seven days, ignores stale results and preserves drafts within the modal", async () => { const h = setup(); h.app.open(); assert.equal(h.calls[0].date, dates.today()); const date = h.find("Reference date"); date.value = "2026-10-16"; date.listeners.change(); h.calls[1].resolve(week()); await flush(); assert.equal(h.nodes().filter(el => el.tag === "th").length, 13); assert.equal(h.nodes().filter(el => el.type === "number").length, 21); assert.equal(h.find("Save").disabled, true); const hours = h.find("Hours 10-OCT-2026, row 1"); hours.value = "2"; hours.valueAsNumber = 2; hours.listeners.input(); assert.equal(hours.step, "1"); assert.ok(h.nodes().some(el => el.tag === "strong" && el.textContent === "2 hrs")); h.find("Copy row 1").click(); assert.equal(h.nodes().filter(el => el.type === "number").length, 28); h.find("Next week").click(); assert.equal(h.calls[2].date, "2026-10-23"); h.find("Previous week").click(); assert.equal(h.calls[3].date, "2026-10-16"); h.calls[3].resolve(week()); await flush(); assert.equal(h.find("Hours 10-OCT-2026, row 1").value, "2"); h.calls[2].reject(new Error("Stale error")); h.calls[0].resolve([]); await flush(); assert.ok(!h.host.textContent.includes("Stale error")); h.app.close(); assert.equal(h.host.children.length, 0); }); test("Manage Time is disabled until session ready, and activities correlate with selected SR", async () => { const h = setup(async () => [{ srNumber: "SR1" }, { srNumber: "SR2" }], async () => [ { SrNumber: "SR1", SRActivities_c: "Design;Sizing, Quoting or Configuration;Design" }, { SrNumber: "SR2", SRActivities_c: "Review, Workshop" }, { SrNumber: "non-service-sr", SRActivities_c: "Training;Meeting" } ]); h.app.setReady(false); const trigger = h.app.createButton(); assert.equal(trigger.disabled, true); h.app.open(); assert.equal(h.calls.length, 0); h.app.setReady(true); trigger.click(); h.calls[0].resolve(week()); await flush(); let select = h.find("Service Request, row 1"); select.value = "SR1"; select.listeners.change(); let activity = h.find("Activity, row 1"); assert.deepEqual(activity.children.map(el => el.textContent), ["Activity", "Design", "Sizing, Quoting or Configuration"]); activity.value = "Design"; activity.listeners.change(); select = h.find("Service Request, row 1"); select.value = "SR2"; select.listeners.change(); activity = h.find("Activity, row 1"); assert.equal(activity.value, ""); assert.deepEqual(activity.children.map(el => el.textContent), ["Activity", "Review", "Workshop"]); h.find("Add (Non service)").click(); assert.equal(h.find("Activity, row 4").children[1].value, "Training"); }); for (const count of [0, 49, 50, 51, 100, 101]) test(`expansion batches ${count} unique SRs and appends non-service exactly once`, async () => { const queries = []; const app = loadBackground({ fetch: async (url, options) => { queries.push(new URL(url).searchParams.get("q")); assert.equal(options.headers["vb-proxy-header-rest-framework-version"], "3"); assert.equal(options.headers.Authorization, "Session"); assert.equal(options.credentials, "include"); return new Response(JSON.stringify({ items: [] })); } }); const numbers = Array.from({ length: count }, (_, i) => `SR${i + 1}`); const result = await app.dispatch({ type: "opportunitiesExtension.requestServiceRequestExpand", srNumbers: [...numbers, ...numbers] }); assert.equal(result.ok, true); assert.equal(queries.length, Math.floor(count / 50) + 1); const batches = queries.map(query => [...query.matchAll(/'([^']+)'/g)].map(match => match[1])); assert.deepEqual(batches.flat(), [...numbers, "non-service-sr"]); assert.ok(batches.every(batch => batch.length <= 50)); }); test("modal supports retry and ignores completion after close", async () => { const h = setup(); h.app.open(); h.calls[0].reject(new Error("Session expired")); await flush(); assert.equal(h.find("Retry").hidden, false); assert.ok(h.host.textContent.includes("Session expired")); h.find("Retry").click(); h.app.close(); h.calls[1].resolve(week()); await flush(); assert.equal(h.host.children.length, 0); }); test("active SRs load once per modal and notes preserve text, character count and collapse state", async () => { const calls = []; const h = setup(async weekId => { calls.push(weekId); return [{ srNumber: "SR1", srTitle: "Customer service" }]; }); h.app.open(); h.calls[0].resolve(week()); await flush(); const select = h.find("Service Request, row 1"); assert.equal(select.disabled, false); assert.equal(select.children[1].value, "SR1"); assert.equal(select.children[1].textContent, "Customer service (SR1)"); select.value = "SR1"; select.listeners.change(); h.find("Notes, row 1").click(); const notes = h.nodes().find(el => el.tag === "textarea"); assert.equal(notes.maxLength, 2000); notes.value = "Hello"; notes.listeners.input(); assert.ok(h.host.textContent.includes("1995 characters left")); h.find("Collapse notes, row 1").click(); assert.equal(h.find("Notes, row 1").attributes["aria-expanded"], "false"); h.find("Copy row 1").click(); assert.equal(h.find("Service Request, row 4").value, "SR1"); h.find("Next week").click(); h.calls[1].resolve(week()); await flush(); assert.deepEqual(calls, [419]); h.app.close(); h.app.open(); h.calls[2].resolve(week()); await flush(); assert.deepEqual(calls, [419, 419]); }); for (const family of ["chrome", "firefox"]) test(`${family}: resource response is cached across concurrent calls and worker restarts`, async () => { const calls = [], sessionStorage = {}; const fetch = async (url, options) => { calls.push({ url, options }); return new Response(JSON.stringify(url.includes("resourceUsers?") ? { resourcePartyId: 123, partyName: "Test User" } : [{ srNumber: "SR1", srTitle: "Test" }]), { headers: { "content-type": "application/json" } }); }; const message = { type: "opportunitiesExtension.requestResourceUser", emailAddress: "test@example.invalid" }; const app = loadBackground({ family, fetch, sessionStorage }); const results = await Promise.all([app.dispatch(message), app.dispatch(message)]); assert.equal(results[0].resource.resourcePartyId, 123); assert.equal(calls.length, 1); const restarted = loadBackground({ family, fetch, sessionStorage }); assert.equal((await restarted.dispatch(message)).resource.partyName, "Test User"); assert.equal(calls.length, 1); const active = await restarted.dispatch({ ...message, type: "opportunitiesExtension.requestActiveServiceRequests", weekId: 419 }); assert.equal(active.ok, true); assert.equal(active.items[0].srNumber, "SR1"); assert.equal(calls.length, 2); assert.match(calls[1].url, /activeServiceRequests\?resourceId=123&weekId=419$/); assert.equal(calls[1].options.headers.Authorization, "Session"); await restarted.dispatch({ ...message, emailAddress: "other@example.invalid" }); assert.equal(calls.length, 3); });