feat: add opt-in browser domain activity tracking
This commit is contained in:
@@ -52,6 +52,7 @@ mkdir(chromiumDist);
|
||||
copy(path.join(root, 'chromium', 'manifest.json'), path.join(chromiumDist, 'manifest.json'));
|
||||
concat([
|
||||
path.join(shared, 'hostname.js'),
|
||||
path.join(shared, 'activity-tracker.js'),
|
||||
path.join(shared, 'protocol.js'),
|
||||
path.join(shared, 'api.js'),
|
||||
path.join(shared, 'queue.js'),
|
||||
@@ -65,7 +66,7 @@ copyLocalization(chromiumDist);
|
||||
const firefoxDist = path.join(dist, 'firefox');
|
||||
mkdir(firefoxDist);
|
||||
copy(path.join(root, 'firefox', 'manifest.json'), path.join(firefoxDist, 'manifest.json'));
|
||||
for (const name of ['hostname.js', 'protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
|
||||
for (const name of ['hostname.js', 'activity-tracker.js', 'protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
|
||||
copy(path.join(shared, name), path.join(firefoxDist, name));
|
||||
}
|
||||
copyPopup(firefoxDist);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
const assert = require('assert');
|
||||
|
||||
const hostname = require('../shared/hostname');
|
||||
const trackerApi = require('../shared/activity-tracker');
|
||||
|
||||
async function main() {
|
||||
const storage = trackerApi.createMemoryActivityStorage();
|
||||
const sent = [];
|
||||
let failSends = true;
|
||||
const tracker = new trackerApi.DomainActivityTracker(storage, async (batch) => {
|
||||
sent.push(JSON.parse(JSON.stringify(batch)));
|
||||
if (failSends) throw new Error('offline');
|
||||
return { status: 'accepted', batchId: batch.batchId };
|
||||
});
|
||||
|
||||
await tracker.initialize();
|
||||
await tracker.setEnabled(true);
|
||||
await tracker.setActiveHostname('example.com', true, 0);
|
||||
await tracker.checkpoint(600000);
|
||||
await tracker.flush(600000);
|
||||
|
||||
let state = await tracker.getState();
|
||||
assert.equal(state.pendingBatches.length, 1);
|
||||
assert.equal(state.pendingBatches[0].entries[0].durationSeconds, 600);
|
||||
const immutableFirstBatch = JSON.stringify(sent[0]);
|
||||
|
||||
await tracker.setActiveHostname('example.com', true, 900000);
|
||||
await tracker.flush(900000);
|
||||
state = await tracker.getState();
|
||||
assert.equal(state.pendingBatches.length, 2, 'new activity must not mutate sent batch A');
|
||||
assert.equal(state.pendingBatches[1].entries[0].durationSeconds, 300);
|
||||
|
||||
failSends = false;
|
||||
await tracker.retryPending();
|
||||
state = await tracker.getState();
|
||||
assert.equal(state.pendingBatches.length, 0);
|
||||
assert.ok(state.acknowledgedIds.length >= 2);
|
||||
assert.equal(JSON.stringify(sent[1]), immutableFirstBatch, 'retry must resend an immutable batch A payload');
|
||||
|
||||
const clockTracker = new trackerApi.DomainActivityTracker(trackerApi.createMemoryActivityStorage(), async () => ({ status: 'accepted' }));
|
||||
await clockTracker.initialize();
|
||||
await clockTracker.setEnabled(true);
|
||||
await clockTracker.setActiveHostname('example.com', true, 0);
|
||||
await clockTracker.checkpoint(8 * 60 * 60 * 1000);
|
||||
state = await clockTracker.getState();
|
||||
assert.equal(state.activeAccumulator['example.com'].durationMs, 0, 'sleep-sized gap must be discarded');
|
||||
await clockTracker.checkpoint(8 * 60 * 60 * 1000 + 5 * 60 * 1000);
|
||||
state = await clockTracker.getState();
|
||||
assert.equal(state.activeAccumulator['example.com'].durationMs, 300000);
|
||||
await clockTracker.checkpoint(1000000);
|
||||
state = await clockTracker.getState();
|
||||
assert.equal(state.activeAccumulator['example.com'].durationMs, 0, 'clock rollback must reset the ambiguous interval');
|
||||
|
||||
assert.equal(trackerApi.isExcludedHostname('video.youtube.com', ['youtube.com']), true);
|
||||
assert.equal(trackerApi.isExcludedHostname('youtube.com.evil.test', ['youtube.com']), false);
|
||||
assert.equal(trackerApi.isExcludedHostname(hostname.normalizeHostnameV1('пример.рф'), ['пример.рф']), true);
|
||||
|
||||
await tracker.setEnabled(false);
|
||||
state = await tracker.getState();
|
||||
assert.deepEqual(state.activeAccumulator, {});
|
||||
assert.deepEqual(state.pendingBatches, []);
|
||||
assert.equal(JSON.stringify(state).includes('https://'), false);
|
||||
console.log('browser activity tracker tests passed');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -49,7 +49,13 @@ const browser = {
|
||||
onMessage: { addListener(listener) { messageListener = listener; } },
|
||||
},
|
||||
i18n: { getUILanguage() { return 'en-US'; } },
|
||||
tabs: { query() { return Promise.resolve([]); } },
|
||||
tabs: {
|
||||
query() { return Promise.resolve([{ id: 7, windowId: 1, url: 'https://example.com/private-path', title: 'Private title' }]); },
|
||||
get() { return Promise.resolve({ id: 7, windowId: 1, url: 'https://example.com/private-path', title: 'Private title' }); },
|
||||
onActivated: { addListener() {} },
|
||||
onUpdated: { addListener() {} },
|
||||
onRemoved: { addListener() {} },
|
||||
},
|
||||
};
|
||||
|
||||
function fetchCatalog(url) {
|
||||
@@ -69,7 +75,7 @@ const context = vm.createContext({
|
||||
clearTimeout,
|
||||
});
|
||||
context.globalThis = context;
|
||||
for (const file of ['protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
|
||||
for (const file of ['hostname.js', 'activity-tracker.js', 'protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
|
||||
vm.runInContext(fs.readFileSync(path.join(root, 'shared', file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
|
||||
@@ -92,6 +98,22 @@ function sendMessage(message) {
|
||||
'Отправить ссылку в Верстак',
|
||||
]);
|
||||
|
||||
const trackingState = await sendMessage({
|
||||
type: 'verstak.capture',
|
||||
action: 'saveSettings',
|
||||
settings: {
|
||||
receiverUrl: state.settings.receiverUrl,
|
||||
receiverToken: state.settings.receiverToken,
|
||||
language: 'en',
|
||||
passiveActivityEnabled: true,
|
||||
passiveActivityExcludedDomains: [],
|
||||
},
|
||||
});
|
||||
assert.strictEqual(trackingState.settings.passiveActivityEnabled, true);
|
||||
assert.ok(trackingState.activityState.activeAccumulator['example.com']);
|
||||
assert.equal(JSON.stringify(trackingState.activityState).includes('private-path'), false);
|
||||
assert.equal(JSON.stringify(trackingState.activityState).includes('Private title'), false);
|
||||
|
||||
const nextState = await sendMessage({
|
||||
type: 'verstak.capture',
|
||||
action: 'saveSettings',
|
||||
|
||||
@@ -35,10 +35,12 @@ const elements = {};
|
||||
'receiver-token-input',
|
||||
'file-input',
|
||||
'pending-count',
|
||||
'activity-pending-count',
|
||||
'status-dot',
|
||||
'subtitle',
|
||||
'receiver-label',
|
||||
'pending-label',
|
||||
'activity-pending-label',
|
||||
'url-label',
|
||||
'capture-page',
|
||||
'capture-file',
|
||||
@@ -53,6 +55,11 @@ const elements = {};
|
||||
'language-ru-option',
|
||||
'save-settings',
|
||||
'context-menu-hint',
|
||||
'passive-activity-enabled',
|
||||
'passive-activity-label',
|
||||
'passive-activity-disclosure',
|
||||
'passive-activity-exclusions-label',
|
||||
'passive-activity-exclusions',
|
||||
].forEach((id) => {
|
||||
elements[id] = new Element();
|
||||
});
|
||||
@@ -63,6 +70,8 @@ const initialState = {
|
||||
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
|
||||
receiverToken: 'persisted-token',
|
||||
language: 'system',
|
||||
passiveActivityEnabled: false,
|
||||
passiveActivityExcludedDomains: ['youtube.com'],
|
||||
},
|
||||
pendingCount: 0,
|
||||
status: {},
|
||||
@@ -112,6 +121,8 @@ async function flush() {
|
||||
assert.strictEqual(elements['receiver-input'].value, initialState.settings.receiverUrl);
|
||||
assert.strictEqual(elements['receiver-token-input'].value, initialState.settings.receiverToken);
|
||||
assert.strictEqual(elements['language-select'].value, 'system');
|
||||
assert.strictEqual(elements['passive-activity-enabled'].checked, false);
|
||||
assert.strictEqual(elements['passive-activity-exclusions'].value, 'youtube.com');
|
||||
assert.strictEqual(elements['capture-page'].textContent, 'Отправить страницу');
|
||||
assert.strictEqual(elements['receiver-state'].textContent, 'Неизвестно');
|
||||
assert.strictEqual(document.documentElement.lang, 'ru');
|
||||
@@ -129,9 +140,13 @@ async function flush() {
|
||||
assert.strictEqual(savedSettings.language, 'en');
|
||||
assert.strictEqual(savedSettings.receiverUrl, initialState.settings.receiverUrl);
|
||||
assert.strictEqual(savedSettings.receiverToken, initialState.settings.receiverToken);
|
||||
assert.strictEqual(savedSettings.passiveActivityEnabled, false);
|
||||
assert.deepStrictEqual(Array.from(savedSettings.passiveActivityExcludedDomains), ['youtube.com']);
|
||||
|
||||
elements['receiver-input'].value = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures';
|
||||
elements['receiver-token-input'].value = 'new-token';
|
||||
elements['passive-activity-enabled'].checked = true;
|
||||
elements['passive-activity-exclusions'].value = 'youtube.com\nx.com';
|
||||
elements['save-settings'].click();
|
||||
await flush();
|
||||
|
||||
@@ -139,6 +154,8 @@ async function flush() {
|
||||
assert.strictEqual(savedSettings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures');
|
||||
assert.strictEqual(savedSettings.receiverToken, 'new-token');
|
||||
assert.strictEqual(savedSettings.language, 'en');
|
||||
assert.strictEqual(savedSettings.passiveActivityEnabled, true);
|
||||
assert.deepStrictEqual(Array.from(savedSettings.passiveActivityExcludedDomains), ['youtube.com', 'x.com']);
|
||||
console.log('browser extension popup localization/settings tests passed');
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
@@ -52,6 +52,20 @@ const fetchOk = (url, options) => {
|
||||
return Promise.resolve({ status: 202, json: () => Promise.resolve({ status: 'accepted' }) });
|
||||
};
|
||||
|
||||
const activityBatch = {
|
||||
schemaVersion: 1,
|
||||
batchId: 'activity-batch-id',
|
||||
createdAt: '2026-07-12T10:05:00.000Z',
|
||||
source: 'verstak-browser-extension',
|
||||
entries: [{
|
||||
hostname: 'example.com',
|
||||
startedAt: '2026-07-12T10:00:00.000Z',
|
||||
endedAt: '2026-07-12T10:05:00.000Z',
|
||||
durationSeconds: 300,
|
||||
url: 'https://must-not-be-sent.example'
|
||||
}]
|
||||
};
|
||||
|
||||
globalThis.VerstakBrowser.sendCapture('http://127.0.0.1:47731/api/browser-inbox/v1/captures', 'token', page, fetchOk)
|
||||
.then((result) => {
|
||||
assert.equal(result.status, 'accepted');
|
||||
@@ -59,6 +73,17 @@ globalThis.VerstakBrowser.sendCapture('http://127.0.0.1:47731/api/browser-inbox/
|
||||
assert.equal(request.options.headers['X-Verstak-Receiver-Token'], 'token');
|
||||
assert.equal(JSON.parse(request.options.body).captureId, 'test-capture-id');
|
||||
})
|
||||
.then(() => {
|
||||
return globalThis.VerstakBrowser.sendActivityBatch('http://127.0.0.1:47731/api/browser-inbox/v1/captures', 'token', activityBatch, fetchOk)
|
||||
.then((result) => {
|
||||
assert.equal(result.status, 'accepted');
|
||||
assert.equal(request.url, 'http://127.0.0.1:47731/api/browser-activity/v1/batches');
|
||||
const body = JSON.parse(request.options.body);
|
||||
assert.equal(body.batchId, 'activity-batch-id');
|
||||
assert.equal(body.entries[0].hostname, 'example.com');
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(body.entries[0], 'url'), false);
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
const queue = new queueApi.CaptureQueue(queueApi.createMemoryStorage());
|
||||
return queue.enqueue(page)
|
||||
|
||||
Reference in New Issue
Block a user