Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 48 additions & 20 deletions popup/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ document
if (webhook && webhook.customPayload) {
try {
// Create variable replacements map
const now = new Date();
// Create a new Date object with local time values but in UTC, to easily extract local date/time parts.
const nowLocal = new Date(now.getTime() - now.getTimezoneOffset() * 60000);

const replacements = {
"{{tab.title}}": activeTab.title,
"{{tab.url}}": currentUrl,
Expand All @@ -172,30 +176,54 @@ document
"{{platform.arch}}": platformInfo.arch || "unknown",
"{{platform.os}}": platformInfo.os || "unknown",
"{{platform.version}}": platformInfo.version,
"{{triggeredAt}}": new Date().toISOString(),
"{{identifier}}": webhook.identifier || ""
"{{identifier}}": webhook.identifier || "",
// Legacy variable
"{{triggeredAt}}": now.toISOString(),
// New DateTime variables (UTC)
"{{now.iso}}": now.toISOString(),
"{{now.date}}": now.toISOString().slice(0, 10),
"{{now.time}}": now.toISOString().slice(11, 19),
"{{now.unix}}": Math.floor(now.getTime() / 1000),
"{{now.unix_ms}}": now.getTime(),
"{{now.year}}": now.getUTCFullYear(),
"{{now.month}}": now.getUTCMonth() + 1,
"{{now.local.iso}}": (() => {
const offsetMinutes = now.getTimezoneOffset();
const sign = offsetMinutes > 0 ? "-" : "+";
const hours = ("0" + Math.floor(Math.abs(offsetMinutes / 60))).slice(-2);
const minutes = ("0" + Math.abs(offsetMinutes % 60)).slice(-2);
return nowLocal.toISOString().slice(0, -1) + sign + hours + ":" + minutes;
})(),
"{{now.hour}}": now.getUTCHours(),
"{{now.minute}}": now.getUTCMinutes(),
"{{now.second}}": now.getUTCSeconds(),
"{{now.millisecond}}": now.getUTCMilliseconds(),
// New DateTime variables (local)
"{{now.local.iso}}": nowLocal.toISOString().slice(0, -1) + (now.getTimezoneOffset() > 0 ? "-" : "+") + ("0" + Math.abs(now.getTimezoneOffset() / 60)).slice(-2) + ":" + ("0" + Math.abs(now.getTimezoneOffset() % 60)).slice(-2),
"{{now.local.date}}": nowLocal.toISOString().slice(0, 10),
"{{now.local.time}}": nowLocal.toISOString().slice(11, 19),
};

// Replace placeholders in custom payload
let customPayloadStr = webhook.customPayload;
Object.entries(replacements).forEach(([placeholder, value]) => {
// Handle different types of values
// For string values in JSON, we need to handle them differently based on context
// If the placeholder is inside quotes in the JSON, we should not add quotes again
const isPlaceholderInQuotes = customPayloadStr.match(new RegExp(`"[^"]*${placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^"]*"`, 'g'));

const replaceValue = typeof value === 'string'
? (isPlaceholderInQuotes ? value.replace(/"/g, '\\"') : `"${value.replace(/"/g, '\\"')}"`)
: (value === undefined ? 'null' : JSON.stringify(value));
// Parse the custom payload as JSON
let customPayload = JSON.parse(webhook.customPayload);

customPayloadStr = customPayloadStr.replace(
new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
replaceValue
);
});
// Recursively replace placeholders
const replacePlaceholders = (obj) => {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
if (typeof obj[key] === 'string') {
const placeholder = obj[key];
if (replacements.hasOwnProperty(placeholder)) {
obj[key] = replacements[placeholder];
}
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
replacePlaceholders(obj[key]);
}
}
}
};

// Parse the resulting JSON
const customPayload = JSON.parse(customPayloadStr);
replacePlaceholders(customPayload);

// Use the custom payload instead of the default one
payload = customPayload;
Expand Down
59 changes: 55 additions & 4 deletions tests/popup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,25 @@ describe('popup script', () => {
expect(fetchMock.mock.calls[0][0]).toBe('https://hook.test');
});

test('uses custom payload when available', async () => {
const customPayload = '{"message": "Custom message with {{tab.title}}"}';
test('uses custom payload with all datetime variables', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 The test currently only covers the UTC timezone. To make the test more robust, you should consider mocking the timezone to a non-UTC value (e.g., UTC+2). This will ensure that the local date/time variables are calculated correctly for all users.

Here is an example of how you could modify the test:

test('uses custom payload with all datetime variables', async () => {
    const customPayload = JSON.stringify({
      "triggeredAt": "{{triggeredAt}}",
      "now.iso": "{{now.iso}}",
      "now.date": "{{now.date}}",
      "now.time": "{{now.time}}",
      "now.unix": "{{now.unix}}",
      "now.unix_ms": "{{now.unix_ms}}",
      "now.year": "{{now.year}}",
      "now.month": "{{now.month}}",
      "now.day": "{{now.day}}",
      "now.hour": "{{now.hour}}",
      "now.minute": "{{now.minute}}",
      "now.second": "{{now.second}}",
      "now.millisecond": "{{now.millisecond}}",
      "now.local.iso": "{{now.local.iso}}",
      "now.local.date": "{{now.local.date}}",
      "now.local.time": "{{now.local.time}}",
    });
    const hook = {
      id: '1',
      label: 'Send',
      url: 'https://hook.test',
      customPayload: customPayload
    };
    global.browser.storage.local.get.mockResolvedValue({ webhooks: [hook] });
    global.browser.tabs.query.mockResolvedValue([{
      id: 1,
      url: 'https://example.com',
      title: 'Test Page',
      status: 'complete'
    }]);

    // Mock Date and Timezone
    const mockDate = new Date('2025-08-07T10:20:30.123Z');
    const OriginalDate = global.Date;
    global.Date = jest.fn((...args) => {
      if (args.length) {
        return new OriginalDate(...args);
      }
      return mockDate;
    });
    global.Date.now = jest.fn(() => mockDate.getTime());
    global.Date.toISOString = jest.fn(() => mockDate.toISOString());
    jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-120); // Mock timezone to UTC+2

    require('../popup/popup.js');
    document.dispatchEvent(new dom.window.Event('DOMContentLoaded'));
    await new Promise(setImmediate);

    const sendButton = document.getElementById('send-button-1');
    sendButton.click();

    await new Promise(setImmediate);

    expect(fetchMock).toHaveBeenCalled();

    const fetchOptions = fetchMock.mock.calls[0][1];
    const sentPayload = JSON.parse(fetchOptions.body);

    const expectedPayload = {
      "triggeredAt": "2025-08-07T10:20:30.123Z",
      "now.iso": "2025-08-07T10:20:30.123Z",
      "now.date": "2025-08-07",
      "now.time": "10:20:30",
      "now.unix": Math.floor(mockDate.getTime() / 1000),
      "now.unix_ms": mockDate.getTime(),
      "now.year": 2025,
      "now.month": 8,
      "now.day": 7,
      "now.hour": 10,
      "now.minute": 20,
      "now.second": 30,
      "now.millisecond": 123,
      "now.local.iso": "2025-08-07T12:20:30.123+02:00",
      "now.local.date": "2025-08-07",
      "now.local.time": "12:20:30"
    };

    expect(sentPayload).toEqual(expectedPayload);

    // Restore Date mock and timezone mock
    global.Date = OriginalDate;
    jest.restoreAllMocks();
  });

const customPayload = JSON.stringify({
"triggeredAt": "{{triggeredAt}}",
"now.iso": "{{now.iso}}",
"now.date": "{{now.date}}",
"now.time": "{{now.time}}",
"now.unix": "{{now.unix}}",
"now.unix_ms": "{{now.unix_ms}}",
"now.year": "{{now.year}}",
"now.month": "{{now.month}}",
"now.day": "{{now.day}}",
"now.hour": "{{now.hour}}",
"now.minute": "{{now.minute}}",
"now.second": "{{now.second}}",
"now.millisecond": "{{now.millisecond}}",
"now.local.iso": "{{now.local.iso}}",
"now.local.date": "{{now.local.date}}",
"now.local.time": "{{now.local.time}}",
});
const hook = {
id: '1',
label: 'Send',
Expand All @@ -91,6 +108,18 @@ describe('popup script', () => {
status: 'complete'
}]);

// Mock Date
const mockDate = new Date('2025-08-07T10:20:30.123Z');
const OriginalDate = global.Date;
global.Date = jest.fn((...args) => {
if (args.length) {
return new OriginalDate(...args);
}
return mockDate;
});
global.Date.now = jest.fn(() => mockDate.getTime());
global.Date.toISOString = jest.fn(() => mockDate.toISOString());

require('../popup/popup.js');
document.dispatchEvent(new dom.window.Event('DOMContentLoaded'));
await new Promise(setImmediate);
Expand All @@ -102,10 +131,32 @@ describe('popup script', () => {

expect(fetchMock).toHaveBeenCalled();

// Check that the custom payload was used with the placeholder replaced
const fetchOptions = fetchMock.mock.calls[0][1];
const sentPayload = JSON.parse(fetchOptions.body);
expect(sentPayload).toEqual({ message: 'Custom message with Test Page' });

const expectedPayload = {
"triggeredAt": "2025-08-07T10:20:30.123Z",
"now.iso": "2025-08-07T10:20:30.123Z",
"now.date": "2025-08-07",
"now.time": "10:20:30",
"now.unix": Math.floor(mockDate.getTime() / 1000),
"now.unix_ms": mockDate.getTime(),
"now.year": 2025,
"now.month": 8,
"now.day": 7,
"now.hour": 10,
"now.minute": 20,
"now.second": 30,
"now.millisecond": 123,
"now.local.iso": "2025-08-07T10:20:30.123+00:00",
"now.local.date": "2025-08-07",
"now.local.time": "10:20:30"
};

expect(sentPayload).toEqual(expectedPayload);

// Restore Date mock
global.Date = OriginalDate;
});

test('filters webhooks based on urlFilter', async () => {
Expand Down
Loading