Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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.

61 changes: 41 additions & 20 deletions popup/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ document
if (webhook && webhook.customPayload) {
try {
// Create variable replacements map
const now = new Date();
const nowLocal = new Date(now.getTime() - now.getTimezoneOffset() * 60000);

const replacements = {
"{{tab.title}}": activeTab.title,
"{{tab.url}}": currentUrl,
Expand All @@ -172,30 +175,48 @@ 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.day}}": now.getUTCDate(),
"{{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