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 () => [], requestEntries = async () => [], localStorage, requestSave, requestDelete) { 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 = [], notifications = []; 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, localStorage }, searchableSelect, dates, requestServices, requestExpand, requestTaskTypes, requestEntries, requestSave, requestDelete, notify: (message, isError = false) => notifications.push({ message, isError }), 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, notifications }; } test("persisted deletions wait for Save, stay scoped to their week and allow a deletion-only save", async () => { const deleted = []; const h = setup(undefined, undefined, undefined, async () => [{ id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, tag: "SERVICE" }], undefined, undefined, async id => deleted.push(id)); h.app.open(); h.calls[0].resolve(week()); await flush(); h.find("Delete row 1").click(); assert.deepEqual(deleted, []); assert.equal(h.find("Save").disabled, false); h.find("Next week").click(); const otherWeek = week().map(day => ({ ...day, Date: dates.toApi(dates.shift(dates.fromApi(day.Date).toISOString().slice(0, 10), 7)), EndDate: "23-OCT-2026", WeekId: 420 })); h.calls[1].resolve(otherWeek); await flush(); assert.equal(h.find("Save").disabled, true); h.find("Previous week").click(); h.calls[2].resolve(week()); await flush(); assert.equal(h.find("Service Request, row 1"), undefined); h.find("Save").click(); await flush(); assert.deepEqual(deleted, ["7"]); assert.equal(h.find("Save").disabled, true); assert.deepEqual(h.notifications, [{ message: "1 time entry deleted.", isError: false }]); }); test("deleting new rows is local; failed deletions retain only unfinished IDs", async () => { const deleted = []; let fail = true; const h = setup(undefined, undefined, undefined, async () => [7, 8].map(id => ({ id, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, tag: "SERVICE" })), undefined, undefined, async id => { deleted.push(id); if (id === "8" && fail) throw new Error("HTTP 403"); }); h.app.open(); h.calls[0].resolve(week()); await flush(); h.find("Add").click(); h.find("Delete row 3").click(); assert.equal(h.find("Save").disabled, true); assert.deepEqual(deleted, []); h.find("Delete row 1").click(); h.find("Delete row 1").click(); h.find("Save").click(); h.find("Save").click(); await flush(); assert.deepEqual(deleted, ["7", "8"]); assert.equal(h.find("Save").disabled, false); assert.deepEqual(h.notifications, [{ message: "1 deleted. HTTP 403", isError: true }]); fail = false; h.find("Save").click(); await flush(); assert.deepEqual(deleted, ["7", "8", "8"]); assert.equal(h.find("Save").disabled, true); }); for (const family of ["chrome", "firefox"]) test(`${family}: DELETE uses the saved ID, identical write headers and a narrow Origin rule`, async () => { const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com"; const calls = []; const app = loadBackground({ family, fetch: async (url, options) => { calls.push({ url, options }); return new Response(null, { status: 204 }); } }); app.responseHeaderListeners[0].listener({ url: `${origin}/ic/builder/rt/oalset_timeentrymobile/live/test`, incognito: false, responseHeaders: [{ name: "x-appbuilder-client-id", value: "delete-client" }] }); const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesDelete", id: 9339403 }); assert.equal(result.ok, true); assert.equal(calls.length, 1); assert.match(calls[0].url, /\/mgmt\/timeEntries\/9339403$/); assert.equal(calls[0].options.method, "DELETE"); assert.equal(calls[0].options.body, undefined); assert.deepEqual({ ...calls[0].options.headers }, { Accept: "application/json", "Content-Type": "application/json", Authorization: "Session", Origin: origin, "x-appbuilder-client-id": "delete-client" }); const rule = app.headerRules[0].addRules[0]; assert.equal(rule.id, 7205); assert.deepEqual(rule.condition.requestMethods, ["delete"]); assert.equal(new RegExp(rule.condition.regexFilter).test(calls[0].url), true); assert.equal(new RegExp(rule.condition.regexFilter).test(calls[0].url + "0"), false); const invalid = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesDelete", id: "../other" }); assert.equal(invalid.ok, false); assert.equal(calls.length, 1); }); test("Save validates service rows, posts once, then puts changes with the returned id", async () => { const calls = []; let finish; const h = setup(undefined, undefined, undefined, undefined, undefined, (method, items) => { calls.push({ method, items }); return new Promise(resolve => { finish = () => resolve(items.map(item => ({ ...item, id: 123 }))); }); }); h.app.open(); h.calls[0].resolve(week()); await flush(); assert.equal(h.find("Save").disabled, true); for (const [label, value] of [["Service Request", "SR001"], ["Activity", "Solution Design"], ["Task Type", "PREP"]]) { const input = h.find(`${label}, row 1`); input.value = value; input.listeners.change(); assert.equal(h.find("Save").disabled, true); } const hours = h.find("Hours 12-OCT-2026, row 1"); hours.value = "2"; hours.valueAsNumber = 2; hours.listeners.input(); assert.equal(h.find("Save").disabled, false); h.find("Save").click(); h.find("Save").click(); assert.equal(calls.length, 1); assert.equal(calls[0].method, "POST"); assert.equal(calls[0].items[0].mondayTaskHours, 2); assert.equal(calls[0].items[0].saturdayTaskHours, 0); assert.equal(calls[0].items[0].id, undefined); assert.equal(calls[0].items[0].weekId, 419); finish(); await flush(); assert.equal(h.find("Save").disabled, true); assert.deepEqual(h.notifications, [{ message: "1 time entry saved.", isError: false }]); const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1"); notes.value = "Updated"; notes.listeners.input(); h.find("Save").click(); assert.equal(calls[1].method, "PUT"); assert.equal(calls[1].items[0].id, 123); finish(); await flush(); assert.equal(h.find("Save").disabled, true); assert.equal(h.notifications.length, 2); assert.equal(h.notifications[1].isError, false); }); test("mixed saves retain successful PUTs when POST fails and never repost a saved row without an id", async () => { const calls = []; const h = setup(undefined, undefined, undefined, async () => [{ id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", tag: "SERVICE", mondayTaskHours: 2 }], undefined, async (method, items) => { calls.push({ method, items }); if (method === "POST" && calls.length === 2) throw new Error("Post failed"); return null; }); h.app.open(); h.calls[0].resolve(week()); await flush(); const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1"); notes.value = "Changed"; notes.listeners.input(); h.find("Copy row 1").click(); const copiedSr = h.find("Service Request, row 2"); copiedSr.value = "SR002"; copiedSr.listeners.change(); const copiedActivity = h.find("Activity, row 2"); copiedActivity.value = "Design"; copiedActivity.listeners.change(); h.find("Save").click(); await flush(); assert.deepEqual(calls.map(call => call.method), ["PUT", "POST"]); assert.equal(h.find("Save").disabled, false); h.find("Save").click(); await flush(); assert.deepEqual(calls.map(call => call.method), ["PUT", "POST", "POST"]); const copyNotes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 2"); copyNotes.value = "Further edit"; copyNotes.listeners.input(); assert.equal(h.find("Save").disabled, true); assert.ok(h.nodes().some(node => node.attributes.role === "alert" && node.textContent.includes("IDs could not be confirmed"))); }); test("Non Service saves without activity and failed saves keep edited values", async () => { const calls = []; const h = setup(undefined, undefined, undefined, undefined, undefined, async (method, items) => { calls.push({ method, items }); throw new Error("HTTP 403"); }); h.app.open(); h.calls[0].resolve(week()); await flush(); h.find("Add (Non service)").click(); const type = h.find("Task Type, row 4"); type.value = "TRAIN"; type.listeners.change(); const hours = h.find("Hours 10-OCT-2026, row 4"); hours.value = "3"; hours.valueAsNumber = 3; hours.listeners.input(); assert.equal(h.find("Save").disabled, false); hours.value = "25"; hours.valueAsNumber = 25; hours.listeners.input(); assert.equal(hours.value, "3"); assert.equal(h.find("Save").disabled, false); assert.deepEqual(h.notifications.at(-1), { message: "Enter hours between 0 and 24.", isError: true }); hours.value = "3"; hours.valueAsNumber = 3; hours.listeners.input(); h.find("Save").click(); await flush(); assert.equal(calls[0].items[0].tag, "NON_SERVICE"); assert.equal(calls[0].items[0].srNumber, "non-service-sr"); assert.equal(calls[0].items[0].activityCode, ""); assert.equal(h.find("Hours 10-OCT-2026, row 4").value, "3"); assert.equal(h.find("Save").disabled, false); assert.ok(h.nodes().some(node => node.attributes.role === "alert" && node.textContent.includes("403"))); }); for (const method of ["POST", "PUT"]) test(`time entry ${method} uses Session and the current resource with a JSON array`, async () => { let posted; const app = loadBackground({ fetch: async (url, options) => { if (url.includes("resourceUsers?")) return new Response(JSON.stringify({ resourcePartyId: 123 }), { headers: { "x-appbuilder-client-id": "time-save-client" } }); assert.match(url, /\/mgmt\/timeEntries$/); assert.equal(options.method, method); assert.equal(options.headers.Authorization, "Session"); assert.equal(options.headers["Content-Type"], "application/json"); assert.equal(options.headers.Origin, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com"); assert.equal(options.headers["x-appbuilder-client-id"], "time-save-client"); posted = JSON.parse(options.body); return new Response(null, { status: 204 }); } }); const item = { id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", weekId: 419, tag: "SERVICE", mondayTaskHours: 2, tuesdayTaskHours: 0, wednesdayTaskHours: 0, thursdayTaskHours: 0, fridayTaskHours: 0, saturdayTaskHours: 0, sundayTaskHours: 0 }; const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesSave", method, items: [item], emailAddress: "test@oracle.com" }); assert.equal(result.ok, true); assert.equal(posted[0].resourceId, "123"); assert.equal(posted[0].id, method === "PUT" ? 7 : undefined); const rule = app.headerRules.find(update => update.addRules.some(rule => rule.id === 7204)).addRules[0]; assert.deepEqual(rule.condition.requestMethods, ["post", "put"]); assert.equal(rule.condition.initiatorDomains.length, 1); assert.equal(rule.action.requestHeaders[0].value, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com"); assert.equal(new RegExp(rule.condition.regexFilter).test("https://example.com/mgmt/timeEntries"), false); }); test("time entry headers reuse IDs captured from Time Entry responses and session storage", async () => { const stored = {}; const runtimeApi = { runtime: { getURL: () => "chrome-extension://test/" }, storage: { session: { set: async values => Object.assign(stored, values), get: async () => stored } }, declarativeNetRequest: { updateSessionRules: async () => {} } }; const createHeaders = new vm.Script(fs.readFileSync("src/auth/sr-request-headers.js", "utf8") + "\ncreateSrRequestHeaders;").runInNewContext({ URL }); const headers = createHeaders({ runtimeApi }); const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries"; headers.observeResponseHeaders(`${url}/weekDetails?dateValue=16-OCT-2026`, [{ name: "x-appbuilder-client-id", value: "time-entry-client" }]); assert.equal((await headers.timeEntriesHeaders(url))["x-appbuilder-client-id"], "time-entry-client"); const restarted = createHeaders({ runtimeApi }); assert.equal((await restarted.timeEntriesHeaders(url))["x-appbuilder-client-id"], "time-entry-client"); }); test("hour inputs accept inclusive 0..24 and reject out-of-range or nonnumeric values", async () => { const h = setup(); h.app.open(); h.calls[0].resolve(week()); await flush(); const input = h.find("Hours 10-OCT-2026, row 1"); for (const value of [0, 24]) { input.value = String(value); input.valueAsNumber = value; input.listeners.input(); assert.equal(input.value, String(value)); } for (const value of [-1, 25, 100, Infinity, NaN]) { input.value = String(value); input.valueAsNumber = value; input.listeners.input(); assert.equal(input.value, "24"); assert.equal(h.notifications.at(-1).isError, true); } input.value = ""; input.valueAsNumber = NaN; input.listeners.input(); assert.equal(input.value, ""); }); test("time entry writes reject out-of-range hours before making a network request", async () => { let calls = 0; const app = loadBackground({ fetch: async () => { calls++; throw new Error("Unexpected fetch"); } }); for (const value of [-1, 25]) { const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesSave", method: "POST", items: [{ srNumber: "SR001", activityCode: "Design", taskType: "PREP", tag: "SERVICE", weekId: 419, saturdayTaskHours: value, sundayTaskHours: 0, mondayTaskHours: 1, tuesdayTaskHours: 0, wednesdayTaskHours: 0, thursdayTaskHours: 0, fridayTaskHours: 0 }] }); assert.equal(result.ok, false); } assert.equal(calls, 0); }); test("duplicate SR selection restores the previous selection and highlights the existing row", async () => { const h = setup(async () => [{ srNumber: "SR001", srTitle: "One" }, { srNumber: "SR002", srTitle: "Two" }]); h.app.open(); h.calls[0].resolve(week()); await flush(); function select(row, value) { const input = h.find(`Service Request, row ${row}`); input.value = value; input.listeners.change(); } select(1, "SR001"); select(2, "SR002"); select(2, "SR001"); assert.equal(h.find("Service Request, row 2").value, "SR002"); const highlighted = h.nodes().filter(el => el.className === "oe-time-duplicate-row"); assert.equal(highlighted.length, 1); assert.equal(h.find("Service Request, row 1").parent.parent, highlighted[0]); assert.equal(h.notifications.at(-1).isError, true); highlighted[0].listeners.animationend(); assert.equal(highlighted[0].className, ""); h.find("Delete row 1").click(); select(1, "SR001"); assert.equal(h.find("Service Request, row 1").value, "SR001"); const css = fs.readFileSync("src/features/manage-time/styles.css", "utf8"); assert.match(css, /oe-time-duplicate-fade 5\.5s/); assert.match(css, /90\.909%/); }); test("reference date persists manual and weekly changes across modal and page reopening", () => { const saved = new Map(); const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) }; const h = setup(undefined, undefined, undefined, undefined, storage); h.app.open(); const date = h.find("Reference date"); date.value = "2026-10-09"; date.listeners.change(); h.app.close(); h.app.open(); assert.equal(h.find("Reference date").value, "2026-10-09"); h.find("Next week").click(); const next = setup(undefined, undefined, undefined, undefined, storage); next.app.open(); assert.equal(next.calls[0].date, "2026-10-16"); const invalid = next.find("Reference date"); invalid.value = ""; invalid.listeners.change(); next.app.close(); next.app.open(); assert.equal(next.find("Reference date").value, "2026-10-16"); }); test("invalid stored date falls back to today and unavailable storage retains the in-memory date", () => { const h = setup(undefined, undefined, undefined, undefined, { getItem: () => "invalid", setItem() { throw new Error("Blocked"); } }); h.app.open(); assert.equal(h.calls[0].date, dates.today()); const date = h.find("Reference date"); date.value = "2026-10-09"; date.listeners.change(); h.app.close(); h.app.open(); assert.equal(h.find("Reference date").value, "2026-10-09"); }); test("loading renders three skeleton rows until entries resolve, then restores empty input rows", async () => { let resolveEntries; const h = setup(undefined, undefined, undefined, () => new Promise(resolve => { resolveEntries = resolve; })); h.app.open(); const skeletons = () => h.nodes().filter(el => el.className === "oe-time-skeleton-row"); assert.equal(skeletons().length, 3); assert.ok(skeletons().every(row => row.children.length === 13)); assert.equal(h.find("Weekly time entries").attributes["aria-hidden"], "true"); assert.equal(h.find("Add").disabled, true); h.calls[0].resolve(week()); await flush(); assert.equal(skeletons().length, 3); resolveEntries([]); await flush(); assert.equal(skeletons().length, 0); assert.equal(h.find("Weekly time entries").attributes["aria-hidden"], "false"); assert.ok(h.find("Service Request, row 3")); assert.equal(h.find("Add").disabled, false); }); test("time entry errors allow retry and stale weeks cannot replace current entries", async () => { const pending = []; const h = setup(undefined, undefined, undefined, weekId => new Promise((resolve, reject) => pending.push({ weekId, resolve, reject }))); h.app.open(); h.calls[0].resolve(week()); await flush(); pending[0].reject(new Error("Time entries unavailable")); await flush(); assert.equal(h.find("Retry").hidden, false); assert.equal(h.find("Add").disabled, true); h.find("Retry").click(); h.calls[1].resolve(week()); await flush(); h.find("Next week").click(); h.calls[2].resolve(week().map(day => ({ ...day, WeekId: 420 }))); await flush(); pending[2].resolve([{ srNumber: "SR002", activityCode: "Current", tag: "SERVICE" }]); await flush(); pending[1].resolve([{ srNumber: "SR001", activityCode: "Stale", tag: "SERVICE" }]); await flush(); assert.equal(h.find("Service Request, row 1").value, "SR002"); assert.equal(h.find("Activity, row 1").value, "Current"); }); test("saved entries hydrate selections, weekday hours and notes, preserving drafts", async () => { const calls = []; const h = setup(undefined, undefined, async () => [{ lookUpCode_c: "PREP", lookUpMeaning_c: "Preparation", tags_c: "SERVICE" }], async weekId => { calls.push(weekId); return [{ id: 9339403, srNumber: "SR0001502709", taskType: "PREP", activityCode: "Solution Design", tag: "SERVICE", mondayTaskHours: 2, saturdayTaskHours: 3, sundayTaskHours: 0, notes: "Saved notes" }, { id: 2, srNumber: "non-service-sr", taskType: "TRAIN", activityCode: "Training", tag: "NON_SERVICE", fridayTaskHours: 4 }]; }); h.app.open(); h.calls[0].resolve(week()); await flush(); assert.deepEqual(calls, [419]); assert.equal(h.find("Service Request, row 1").value, "SR0001502709"); assert.equal(h.find("Activity, row 1").value, "Solution Design"); assert.equal(h.find("Task Type, row 1").value, "PREP"); assert.equal(h.find("Hours 12-OCT-2026, row 1").value, "2"); assert.equal(h.find("Hours 10-OCT-2026, row 1").value, "3"); assert.equal(h.find("Hours 11-OCT-2026, row 1").value, "0"); assert.equal(h.find("Hours 16-OCT-2026, row 2").value, "4"); assert.ok(h.find("Non Service, row 2")); const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1"); assert.equal(notes.value, "Saved notes"); notes.value = "Edited notes"; notes.listeners.input(); h.find("Next week").click(); h.calls[1].resolve(week()); await flush(); assert.equal(calls.length, 1); assert.equal(h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1").value, "Edited notes"); }); for (const family of ["chrome", "firefox"]) test(`${family}: time entries use the resource party, selected week, Session and ascending created date`, async () => { const calls = []; const app = loadBackground({ family, fetch: async (url, options) => { calls.push({ url, options }); return new Response(JSON.stringify(url.includes("resourceUsers?") ? { resourcePartyId: 123 } : [{ id: 7, srNumber: "SR001" }])); } }); const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntries", weekId: 418, emailAddress: "test@oracle.com" }); assert.equal(result.ok, true); const call = calls.find(call => call.url.includes("timeEntries?")); assert.match(call.url, /timeEntries\?resourceId=123&sortBy=createdDate&sortOrder=ASC&weekId=418$/); assert.equal(call.options.headers.Authorization, "Session"); assert.equal(call.options.credentials, "include"); assert.equal(result.items[0].id, 7); }); test("autocomplete reopening highlights its saved option and filtering does not select another value", async () => { const h = setup(undefined, undefined, async () => [ { lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }, { lookUpCode_c: "PREP", lookUpMeaning_c: "Preparation", tags_c: "SERVICE" } ], async () => [{ srNumber: "SR001", taskType: "PREP", tag: "SERVICE" }]); h.app.open(); h.calls[0].resolve(week()); await flush(); const input = h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === "Task Type, row 1"); input.click(); const list = input.parent.children[1]; assert.equal(list.children[0].attributes["aria-selected"], "false"); assert.equal(list.children[1].attributes["aria-selected"], "true"); assert.equal(input.attributes["aria-activedescendant"], list.children[1].id); input.value = "Config"; input.listeners.input(); assert.equal(list.children[0].attributes["aria-selected"], "false"); assert.equal(h.find("Task Type, row 1").value, "PREP"); input.listeners.blur(); input.click(); assert.equal(list.children[1].attributes["aria-selected"], "true"); }); 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, ""); assert.equal(combo("Service Request, row 4").value, ""); 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 === "2h")); 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, ""); 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); });