610 lines
42 KiB
JavaScript
610 lines
42 KiB
JavaScript
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,
|
|
tableSorting: factory("src/features/preferences/table-sorting.js", "createTableSorting")({ environment: { document, localStorage } }),
|
|
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("sorting Manage Time preserves notes, hours and deletion IDs and restores on reopening", async () => {
|
|
const deleted = [], saved = new Map();
|
|
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
|
|
const records = [
|
|
{ id: 7, srNumber: "SR002", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, notes: "Second", tag: "SERVICE" },
|
|
{ id: 8, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 3, notes: "First", tag: "SERVICE" }
|
|
];
|
|
const h = setup(undefined, undefined, undefined, async () => records, storage, undefined, async id => deleted.push(id));
|
|
h.app.open(); h.calls[0].resolve(week()); await flush();
|
|
h.find("Sort by Service Request").click();
|
|
const selects = () => h.nodes().filter(node => node.tag === "select" && node.attributes["aria-label"]?.startsWith("Service Request,"));
|
|
assert.equal(selects()[0].value, "SR001");
|
|
assert.equal(h.nodes().find(node => node.tag === "textarea" && node.attributes["aria-label"] === "Notes, row 2").value, "First");
|
|
assert.equal(h.find("Hours 12-OCT-2026, row 2").value, "3");
|
|
h.app.close(); h.app.open(); h.calls[1].resolve(week()); await flush();
|
|
assert.equal(selects()[0].value, "SR001");
|
|
h.find("Delete row 2").click(); h.find("Save").click(); await flush();
|
|
assert.deepEqual(deleted, ["8"]);
|
|
assert.equal(selects()[0].value, "SR002");
|
|
});
|
|
|
|
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 = "<b>Plain text</b>\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);
|
|
});
|
|
|
|
|
|
test("restricted profile cannot display or open Manage Time even if session is ready", () => {
|
|
const source = fs.readFileSync("src/features/manage-time/view.js", "utf8");
|
|
const factory = vm.runInNewContext(source + "\ncreateManageTime");
|
|
const element = () => ({ setAttribute() {}, append() {}, addEventListener() {} });
|
|
const app = factory({ allowsManageTime: () => false, environment: { document: {
|
|
createElement: element, createElementNS: element,
|
|
getElementById() { assert.fail("restricted profile must not open a modal"); }
|
|
} }, requestWeek() { assert.fail("restricted profile must not request weeks"); } });
|
|
const button = app.createButton();
|
|
app.setReady(true);
|
|
assert.equal(button.hidden, true);
|
|
assert.equal(button.disabled, true);
|
|
app.open();
|
|
});
|