Added the ability to bookmark the extension, along with some visual adjustments

This commit is contained in:
2026-10-30 14:48:00 -03:00
parent 00bcfdc42e
commit 9cdb46b6c2
39 changed files with 1566 additions and 137 deletions

View File

@@ -61,11 +61,13 @@ test("activity badge filters precede Status, are accessible and combine without
h.sr.set("B", { state: "missing" });
h.actions.set("B", { state: "matched" });
assert.equal(h.matches("unknown"), true);
h.click("sr", "missing");
h.click("sr", "matched");
assert.equal(h.button("sr", "matched").attributes["aria-pressed"], "true");
assert.equal(h.button("sr", "missing").attributes["aria-pressed"], "false");
assert.equal(h.matches("A"), true);
assert.equal(h.matches("B"), false);
assert.equal(h.matches("unknown"), false);
h.click("actions", "missing");
h.click("actions", "matched");
assert.equal(h.matches("A"), false, "SR and Action use AND");
h.actions.set("A", { state: "matched" });
assert.equal(h.matches("A"), true);
@@ -119,8 +121,8 @@ test("filter vectors retain their fixed slot without duplication when selection
test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => {
const h = setup();
h.click("sr", "matched");
h.click("actions", "matched");
h.click("sr", "missing");
h.click("actions", "missing");
for (const state of ["idle", "loading", "error", "matched"]) {
h.sr.set("A", { state });
h.actions.set("A", { state: "missing" });
@@ -215,7 +217,7 @@ test("activity filters compose with existing search, status, owner and customer
h.app.opportunitiesFilters.resetOwnerFilter(items);
h.sr.set("A", { state: "matched" });
h.sr.set("B", { state: "missing" });
h.click("sr", "missing");
h.click("sr", "matched");
const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber);
assert.deepEqual(results(), ["A"]);
h.app.opportunitiesFilterState.tableSearch = "Test B";

View File

@@ -0,0 +1,47 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const factory = vm.runInNewContext(fs.readFileSync('src/content/modal.js', 'utf8') + '\ncreateContentModal');
function setup(check) {
const hint = { hidden: true }, modal = { hidden: false }, timers = new Map();
let next = 0;
const app = factory({ MODAL_ID: 'modal', getShortcutBookmarkStatus: check,
environment: { window: {}, document: { getElementById: id => id === 'modal' ? modal : hint },
setTimeout(fn, delay) { const id = ++next; timers.set(id, { fn, delay }); return id; },
clearTimeout(id) { timers.delete(id); }
}
});
return { app, hint, modal, timers };
}
test('unbookmarked shortcut shows for 20 seconds and can show on reopening', async () => {
const h = setup(async () => ({ ok: true, bookmarked: false }));
await h.app.refreshBookmarkHint();
assert.equal(h.hint.hidden, false);
const timer = [...h.timers.values()][0];
assert.equal(timer.delay, 20000);
timer.fn();
assert.equal(h.hint.hidden, true);
await h.app.refreshBookmarkHint();
assert.equal(h.hint.hidden, false);
h.app.hideBookmarkHint();
assert.equal(h.timers.size, 0);
});
test('existing bookmark and failed checks never display the hint', async () => {
for (const check of [async () => ({ ok: true, bookmarked: true }), async () => ({ ok: false }), async () => { throw Error('unavailable'); }]) {
const h = setup(check);
await h.app.refreshBookmarkHint();
assert.equal(h.hint.hidden, true);
assert.equal(h.timers.size, 0);
}
});
test('closing or saving while the check is pending prevents late hints', async () => {
let resolve;
const h = setup(() => new Promise(done => { resolve = done; }));
const pending = h.app.refreshBookmarkHint();
h.app.hideBookmarkHint();
resolve({ ok: true, bookmarked: false });
await pending;
assert.equal(h.hint.hidden, true);
assert.equal(h.timers.size, 0);
});

View File

@@ -0,0 +1,64 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
function harness(bookmarks) {
let listener;
const context = vm.createContext({});
vm.runInContext(fs.readFileSync('src/background/messages.js', 'utf8'), context);
context.registerBackgroundMessages({
messageTypes: { addShortcutBookmark: 'bookmark', getBookmarkFolders: 'folders' },
runtimeApi: { bookmarks: { getTree: async () => [{ id: "0", children: [{ id: "1", parentId: "0", title: "Favorites" }] }], ...bookmarks }, runtime: { onMessage: { addListener(fn) { listener = fn; } } } }
});
return (message = { type: "bookmark", parentId: "1" }) => new Promise(resolve => {
assert.equal(listener(message, {}, resolve), true);
});
}
test('creates only the fixed extension shortcut, deduplicating concurrent requests', async () => {
const created = [];
const request = harness({ search: async () => [], create: async value => { created.push(value); } });
const results = await Promise.all([request(), request()]);
assert.equal(created.length, 1);
assert.equal(created[0].parentId, '1');
assert.equal(created[0].title, 'Opportunities Extension');
assert.equal(created[0].url, 'https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension');
assert.ok(results.every(result => result.ok));
});
test('does not duplicate an existing bookmark', async () => {
const request = harness({ search: async () => [{ id: '2', parentId: '1' }], create: async () => assert.fail('duplicate') });
assert.equal((await request()).existing, true);
});
test('reports API errors and allows retry', async () => {
let calls = 0;
const request = harness({ search: async () => {
if (++calls === 1) throw new Error('Permission denied');
return [{ id: '2', parentId: '1' }];
} });
assert.equal((await request()).error, 'Permission denied');
assert.equal((await request()).ok, true);
});
test('lists nested writable folders without bookmarks or managed folders', async () => {
const request = harness({ getTree: async () => [{ id: '0', children: [
{ id: '1', parentId: '0', title: 'Favorites', children: [
{ id: '2', parentId: '1', title: 'Work', children: [] },
{ id: '3', parentId: '1', title: 'Link', url: 'https://example.com' }
] },
{ id: '4', parentId: '0', title: 'Managed', unmodifiable: 'managed' }
] }] });
const result = await request({ type: 'folders' });
assert.deepEqual(JSON.parse(JSON.stringify(result.folders)), [
{ id: '1', label: 'Favorites' }, { id: '2', label: 'Favorites / Work' }
]);
});
test('rejects missing or unavailable destinations without creating a bookmark', async () => {
const request = harness({ create: async () => assert.fail('invalid destination') });
assert.equal((await request({ type: 'bookmark' })).ok, false);
assert.equal((await request({ type: 'bookmark', parentId: 'unknown' })).ok, false);
});

View File

@@ -40,9 +40,12 @@ test("distribution contracts: manifests, execution boundaries and deterministic
const base = {
manifest_version: 3, name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.9", permissions: ["cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
version: "1.0.9", permissions: ["bookmarks", "cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*", "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"],
content_scripts: [{
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
js: ["shortcut-intent.js"], all_frames: false, run_at: "document_start"
}, {
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
js: ["content.js"], all_frames: true, run_at: "document_idle"
}, {

76
tests/dialogDrag.test.cjs Normal file
View File

@@ -0,0 +1,76 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const factory = vm.runInNewContext(fs.readFileSync('src/content/dialog-drag.js', 'utf8') + '\ncreateDialogDrag');
function setup({ header = false, interactive = false, unrelated = false } = {}) {
const events = {}, winEvents = {}, attrs = {}, style = {};
const panel = { style, isConnected: true, full: false,
closest: selector => selector.includes('modal') ? {} : null,
querySelector: () => title,
setAttribute: (key, value) => attrs[key] = value,
removeAttribute: key => delete attrs[key],
getClientRects: () => [1],
getBoundingClientRect() {
const left = 200 + (parseFloat(style.left) || 0), top = 150 + (parseFloat(style.top) || 0);
return { left, top, width: this.full ? 1000 : 400, height: this.full ? 800 : 300 };
}
};
const title = { closest: () => panel, setPointerCapture() {}, hasPointerCapture: () => true, releasePointerCapture() {} };
const bar = { ...title, contains: node => !unrelated && node === title };
const target = { closest: selector => selector === 'header, h2, h3' ? (header ? bar : title) : interactive ? {} : null };
const document = { documentElement: { clientWidth: 1000, clientHeight: 800 },
addEventListener: (name, fn) => events[name] = fn, querySelectorAll: () => [panel] };
const window = { addEventListener: (name, fn) => winEvents[name] = fn };
factory({ environment: { document, window } }).install();
const event = (x, y) => ({ target, button: 0, pointerId: 1, clientX: x, clientY: y, preventDefault() {} });
return { panel, events, winEvents, attrs, style, document, event };
}
test('dragging permits 70 percent overflow in every direction and stops on release', () => {
const h = setup();
h.events.pointerdown(h.event(220, 160));
h.events.pointermove(h.event(2000, 2000));
assert.equal(h.panel.getBoundingClientRect().left, 880);
assert.equal(h.panel.getBoundingClientRect().top, 710);
h.events.pointermove(h.event(-2000, -2000));
assert.equal(h.panel.getBoundingClientRect().left, -280);
assert.equal(h.panel.getBoundingClientRect().top, -210);
h.events.pointerup(h.event(0, 0));
assert.equal(h.attrs['data-dialog-dragging'], undefined);
h.events.pointermove(h.event(400, 400));
assert.equal(h.panel.getBoundingClientRect().left, -280);
});
test('full-page panels do not drag', () => {
const h = setup(); h.panel.full = true;
h.events.pointerdown(h.event(220, 160));
h.events.pointermove(h.event(300, 200));
assert.equal(h.style.left, undefined);
});
test('resize keeps at least 30 percent of a moved panel inside each viewport axis', () => {
const h = setup();
h.events.pointerdown(h.event(220, 160));
h.events.pointermove(h.event(500, 400));
h.document.documentElement.clientWidth = 600;
h.document.documentElement.clientHeight = 500;
h.winEvents.resize();
assert.equal(h.panel.getBoundingClientRect().left, 480);
assert.equal(h.panel.getBoundingClientRect().top, 390);
assert.equal(h.attrs['data-dialog-dragging'], undefined);
});
test('empty title bar space starts dragging', () => {
const h = setup({ header: true });
h.events.pointerdown(h.event(220, 160));
h.events.pointermove(h.event(300, 200));
assert.equal(h.panel.getBoundingClientRect().left, 280);
assert.equal(h.panel.getBoundingClientRect().top, 190);
});
test('title bar controls and content headers do not start dragging', () => {
for (const options of [{ header: true, interactive: true }, { header: true, unrelated: true }]) {
const h = setup(options);
h.events.pointerdown(h.event(220, 160));
h.events.pointermove(h.event(300, 200));
assert.equal(h.style.left, undefined);
}
});

View File

@@ -0,0 +1,41 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
test('modal shortcuts require exactly Ctrl+Shift+D or Ctrl+Shift+F', () => {
let listener, debug = 0, favorites = 0;
const modal = { hidden: false };
const context = vm.createContext({});
vm.runInContext(fs.readFileSync('src/content/modal.js', 'utf8'), context);
const app = context.createContentModal({
MODAL_ID: 'modal', ESCAPE_LISTENER_KEY: 'keys',
toggleDebugPanel: () => debug++, openBookmarkPicker: () => favorites++,
environment: { window: {}, document: {
getElementById: () => modal,
addEventListener: (name, fn) => { if (name === 'keydown') listener = fn; }
} }
});
app.ensureEscapeKeyHandler();
function press(key, extra = {}) {
let prevented = false;
listener({ key, ctrlKey: true, shiftKey: true, preventDefault() { prevented = true; }, stopPropagation() {}, ...extra });
return prevented;
}
assert.equal(press('D'), true);
assert.equal(press('f'), true);
assert.equal(debug, 1);
assert.equal(favorites, 1);
for (const key of ['d', 'f']) {
assert.equal(press(key, { shiftKey: false }), false);
assert.equal(press(key, { ctrlKey: false }), false);
assert.equal(press(key, { altKey: true }), false);
assert.equal(press(key, { metaKey: true }), false);
press(key, { repeat: true });
}
modal.hidden = true;
assert.equal(press('d'), false);
assert.equal(press('f'), false);
assert.equal(debug, 1);
assert.equal(favorites, 1);
});

View File

@@ -9,7 +9,7 @@ const plain = value => JSON.parse(JSON.stringify(value));
const opportunity = (number, date) => ({ OptyNumber: number, Name: `<Opportunity ${number}>`, EffectiveDate: date,
ForecastGroup_c: "FORECAST", StatusCode: "OPEN", CustomerAccount: { PartyUniqueName: "Customer" } });
function setup(items = [opportunity("A", "2026-11-15")]) {
function setup(items = [opportunity("A", "2026-11-15")], extra = {}) {
let document;
class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
@@ -44,7 +44,7 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
createCopyButton: () => new Element("button"), createCloseIcon: () => new Element("svg"),
createViewIcon: view => { const icon = new Element("svg"); icon.view = view; return icon; },
createStageBadge: () => new Element("span"), createStatusBadge: () => new Element("span"),
opportunityUrl: "https://example.invalid/?puid=", modalId: "modal", closeSrPopover() {} });
opportunityUrl: "https://example.invalid/?puid=", modalId: "modal", closeSrPopover() {}, ...extra });
h.body = new Element("body"); h.table = new Element("table-surface"); host.append(h.body);
const walk = element => [element, ...element.children.flatMap(walk)];
h.nodes = () => walk(host);
@@ -54,6 +54,23 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
return h;
}
test("Booking and Workload update both badges when isolating and adding selections", () => {
const selected = new Set(["Booking", "Workload"]);
let renders = 0;
const h = setup([], { productTypeSelection: selected, onProductTypeChange: () => renders++ });
h.mount();
const booking = h.nodes().find(node => node.attributes["data-type"] === "Booking");
const workload = h.nodes().find(node => node.attributes["data-type"] === "Workload");
for (const [button, expected] of [[booking, [true,false]], [workload, [true,true]], [workload, [false,true]], [booking, [true,true]]]) {
button.click();
assert.equal(booking.attributes["aria-pressed"], String(expected[0]));
assert.equal(workload.attributes["aria-pressed"], String(expected[1]));
assert.equal(selected.has("Booking"), expected[0]);
assert.equal(selected.has("Workload"), expected[1]);
}
assert.equal(renders, 4);
});
test("calendar controls, accessibility labels and missing dates are English", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", null)]);
const { options } = h.activate();

View File

@@ -0,0 +1,29 @@
const {test}=require('node:test');const assert=require('node:assert/strict');const fs=require('node:fs');const vm=require('node:vm');
const factory=vm.runInNewContext(fs.readFileSync('src/content/fusion-page.js','utf8')+'\ncreateContentFusionPage');
for(const hash of ['#opportunities-extension','','#other'])test(`shortcut opens once only with explicit marker ${hash}`,()=>{
let opened=0;const location={hostname:'eeho.fa.us2.oraclecloud.com',pathname:'/hcmUI/faces/FuseWelcome',hash};
const api=factory({TILE_ID:'tile',ALLOWED_PATHS:['/hcmUI/faces/FuseWelcome'],environment:{document:{getElementById:()=>({})},window:{location}},openOpportunitiesModal:()=>opened++});
api.insertTile();api.insertTile();assert.equal(opened,hash==='#opportunities-extension'?1:0);
});
test('shortcut never opens on another origin',()=>{
const api=factory({TILE_ID:'tile',ALLOWED_PATHS:['/hcmUI/faces/FuseWelcome'],environment:{document:{getElementById:()=>({})},window:{location:{hostname:'example.invalid',pathname:'/hcmUI/faces/FuseWelcome',hash:'#opportunities-extension'}}},openOpportunitiesModal:()=>assert.fail()});api.insertTile();
});
test('startup opens shortcut before Sales or the insertion point exists, without clicks',()=>{
let opened=0;let observing=0;
const document={documentElement:{},getElementById:()=>null,querySelector:()=>null};
const window={location:{hostname:'eeho.fa.us2.oraclecloud.com',pathname:'/hcmUI/faces/FuseWelcome',hash:'#opportunities-extension'}};
const environment={document,window,MutationObserver:class{constructor(fn){this.fn=fn;}observe(){observing++;this.fn();}}};
const page=factory({TILE_ID:'tile',ALLOWED_PATHS:['/hcmUI/faces/FuseWelcome'],environment,openOpportunitiesModal:()=>opened++});
const start=vm.runInNewContext(fs.readFileSync('src/content/main.js','utf8')+'\nstartContentApplication');
start({environment,page,styles:{ensureExtensionStyles(){}},products:{ensureProductsBackgroundMessageHandler(){}}});
assert.equal(opened,1);assert.equal(observing,1);assert.equal(page.insertTile(),false);assert.equal(opened,1);
});
test('early capture survives Fusion querystring replacement and is consumed once',()=>{
const values=new Map();const storage={setItem:(k,v)=>values.set(k,v),getItem:k=>values.get(k),removeItem:k=>values.delete(k)};
const window={location:{href:'https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension'},sessionStorage:storage,addEventListener(){}};window.top=window;
vm.runInNewContext(fs.readFileSync('src/content/shortcut-intent.js','utf8'),{window,URL,Date});
window.location={hostname:'eeho.fa.us2.oraclecloud.com',pathname:'/hcmUI/faces/FuseWelcome',hash:'',search:'?_afrLoop=123'};
let opened=0;const options={TILE_ID:'tile',ALLOWED_PATHS:['/hcmUI/faces/FuseWelcome'],environment:{document:{getElementById:()=>({})},window},openOpportunitiesModal:()=>opened++};
factory(options).insertTile();factory(options).insertTile();assert.equal(opened,1);assert.equal(values.size,0);
});

View File

@@ -1,8 +1,11 @@
const {test}=require('node:test');const assert=require('node:assert/strict');const fs=require('node:fs');const vm=require('node:vm');
const factory=vm.runInNewContext(fs.readFileSync('src/services/fusion/opportunities.js','utf8')+'\ncreateFusionOpportunities');
for(const mode of ['success','failure','stale'])test(`SR precedes opportunities: ${mode}`,async()=>{
const order=[];let active=true;let page=0;
const api=factory({OPPORTUNITIES_PAGE_LIMIT:1,OPPORTUNITIES_QUERY_URL:'https://example.invalid',isCurrentPeriodRequest:()=>active,getOpportunityTypeValues:()=>new Set(['ALL']),logRequestStart:()=>({}),logRequestSuccess(){},logRequestFailure(){},readResponseBodyForDebug:async()=>'',resetDebugRequests:()=>order.push('reset'),loadServiceRequests:async()=>{order.push('sr');if(mode==='stale')active=false;if(mode==='failure')throw Error('SR failed');},environment:{fetch:async()=>{order.push('opportunities');page++;return {ok:true,json:async()=>({items:[{OptyNumber:String(page)}],hasMore:page===1,count:1,offset:page-1})};}}});
await api.requestOpportunitiesPage('token','CQ',{startCloseDate:'2026-09-01',endCloseDate:'2026-12-01'},0,[],1,1,'saved-search');
assert.deepEqual(order,mode==='stale'?['reset','sr']:['reset','sr','opportunities','opportunities']);
const factory=vm.runInNewContext(fs.readFileSync('src/features/opportunities/controller.js','utf8')+'\ncreateOpportunitiesController');
for(const mode of ['success','failure','stale','pending'])test(`opportunities are displayed before background SR: ${mode}`,async()=>{
const order=[];const state={periodRequestVersion:1};let complete;
const api=factory({opportunitiesQueryState:state,productsContentState:{},consumptionPlansState:{},consumptionTrendState:{},renderProductsProgress(){},setOpportunitiesTableState:next=>order.push(next.status),requestOpportunitiesPage:()=>{order.push('query');return new Promise(resolve=>complete=resolve);},loadServiceRequests:()=>{order.push('sr');if(mode==='failure')throw Error('SR failed');if(mode==='pending')return new Promise(()=>{});},requestOpptyProductsInBackground:()=>order.push('products')});
const request=api.requestOpportunities('token','CQ',{},1);
assert.deepEqual(order,['loading','query']);
if(mode==='stale')state.periodRequestVersion++;
complete([{OptyNumber:'A'}]);await request;
assert.deepEqual(order,mode==='stale'?['loading','query']:['loading','query','ready','sr','products']);
});