427 lines
12 KiB
TypeScript
427 lines
12 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { assert } from 'chai';
|
|
import * as sinon from 'sinon';
|
|
import { EventEmitter } from 'events';
|
|
import { v4 as uuid } from 'uuid';
|
|
|
|
import { ReleaseNotesFetcher } from '../../services/releaseNotesFetcher';
|
|
import * as durations from '../../util/durations';
|
|
import { generateAci } from '../../types/ServiceId';
|
|
import { saveNewMessageBatcher } from '../../util/messageBatcher';
|
|
import type { WebAPIType } from '../../textsecure/WebAPI';
|
|
import type { CIType } from '../../CI';
|
|
import type { ConversationModel } from '../../models/conversations';
|
|
import { strictAssert } from '../../util/assert';
|
|
|
|
const waitUntil = (
|
|
condition: () => boolean,
|
|
timeoutMs = 5000
|
|
): Promise<void> => {
|
|
return new Promise((resolve, reject) => {
|
|
const startTime = Date.now();
|
|
const intervalMs = 10;
|
|
|
|
const intervalId = setInterval(() => {
|
|
if (condition()) {
|
|
clearInterval(intervalId);
|
|
resolve();
|
|
} else if (Date.now() - startTime > timeoutMs) {
|
|
clearInterval(intervalId);
|
|
reject(new Error('waitUntil timeout'));
|
|
}
|
|
}, intervalMs);
|
|
});
|
|
};
|
|
|
|
describe('ReleaseNotesFetcher', () => {
|
|
const NEXT_FETCH_TIME_STORAGE_KEY = 'releaseNotesNextFetchTime';
|
|
const PREVIOUS_MANIFEST_HASH_STORAGE_KEY = 'releaseNotesPreviousManifestHash';
|
|
const VERSION_WATERMARK_STORAGE_KEY = 'releaseNotesVersionWatermark';
|
|
const FETCH_INTERVAL = 3 * durations.DAY;
|
|
|
|
type TestSetupOptions = {
|
|
// Storage values
|
|
storedVersionWatermark?: string;
|
|
storedPreviousManifestHash?: string;
|
|
storedNextFetchTime?: number;
|
|
|
|
// Version configuration
|
|
currentVersion?: string;
|
|
noteVersion?: string;
|
|
isNewVersion?: boolean;
|
|
|
|
// Server responses
|
|
isOnline?: boolean;
|
|
manifestHash?: string;
|
|
manifestAnnouncements?: Array<{
|
|
uuid: string;
|
|
desktopMinVersion: string;
|
|
ctaId: string;
|
|
link: string;
|
|
}>;
|
|
releaseNote?: {
|
|
uuid: string;
|
|
title: string;
|
|
body: string;
|
|
bodyRanges: Array<{ start: number; length: number; style: string }>;
|
|
};
|
|
|
|
// Conversation behavior
|
|
conversationIsBlocked?: boolean;
|
|
|
|
// Timing
|
|
now?: number;
|
|
};
|
|
|
|
let sandbox = sinon.createSandbox();
|
|
let clock: sinon.SinonFakeTimers | undefined;
|
|
let originalTextsecureServer: WebAPIType | undefined;
|
|
let originalSignalCI: CIType | undefined;
|
|
|
|
async function setupTest(options: TestSetupOptions = {}) {
|
|
sandbox = sinon.createSandbox();
|
|
|
|
// Reset conversation controller for clean state
|
|
window.ConversationController.reset();
|
|
await window.ConversationController.load();
|
|
|
|
const {
|
|
storedVersionWatermark = '1.36.0',
|
|
storedPreviousManifestHash,
|
|
storedNextFetchTime,
|
|
currentVersion = '1.36.0',
|
|
noteVersion = '1.37.0',
|
|
isNewVersion = false,
|
|
isOnline = true,
|
|
manifestHash = 'abc123',
|
|
manifestAnnouncements,
|
|
releaseNote,
|
|
conversationIsBlocked = false,
|
|
now = 1621500000000,
|
|
} = options;
|
|
|
|
clock = sinon.useFakeTimers({ now });
|
|
|
|
const events = new EventEmitter();
|
|
const fakeNoteUuid = uuid();
|
|
|
|
let savedClockTime = now;
|
|
|
|
// Timer utilities
|
|
const pauseFakeTimer = () => {
|
|
if (clock) {
|
|
savedClockTime = clock.now;
|
|
clock.restore();
|
|
clock = undefined;
|
|
}
|
|
};
|
|
|
|
const resumeFakeTimer = () => {
|
|
if (!clock) {
|
|
clock = sinon.useFakeTimers({ now: savedClockTime });
|
|
}
|
|
};
|
|
|
|
// Create fake conversation
|
|
const fakeConversation = {
|
|
isBlocked: sandbox.stub().returns(conversationIsBlocked),
|
|
onNewMessage: sandbox.stub().resolves(),
|
|
getServiceId: sandbox.stub().returns(generateAci()),
|
|
set: sandbox.stub(),
|
|
throttledUpdateUnread: sandbox.stub(),
|
|
id: 'fake-signal-conversation-id',
|
|
};
|
|
|
|
// Stub global methods
|
|
sandbox
|
|
.stub(window.ConversationController, 'getOrCreateSignalConversation')
|
|
.resolves(fakeConversation as unknown as ConversationModel);
|
|
|
|
sandbox.stub(window.MessageCache, 'register').callsFake(message => message);
|
|
|
|
// Save original values before modifying
|
|
originalTextsecureServer = window.textsecure.server;
|
|
originalSignalCI = window.SignalCI;
|
|
|
|
// Initialize textsecure.server if needed
|
|
if (!window.textsecure.server) {
|
|
window.textsecure.server = {} as unknown as WebAPIType;
|
|
}
|
|
|
|
// Stub server methods
|
|
const serverStubs = {
|
|
isOnline: sandbox.stub().returns(isOnline),
|
|
getReleaseNotesManifestHash: sandbox.stub().resolves(manifestHash),
|
|
getReleaseNotesManifest: sandbox.stub().resolves({
|
|
announcements: manifestAnnouncements || [
|
|
{
|
|
uuid: fakeNoteUuid,
|
|
desktopMinVersion: noteVersion,
|
|
ctaId: 'test-cta',
|
|
link: 'https://signal.org',
|
|
},
|
|
],
|
|
}),
|
|
getReleaseNoteHash: sandbox.stub().resolves('note-hash-1'),
|
|
getReleaseNote: sandbox.stub().resolves(
|
|
releaseNote || {
|
|
uuid: fakeNoteUuid,
|
|
title: 'New Release',
|
|
body: 'This is the body text of the release note',
|
|
bodyRanges: [{ start: 0, length: 4, style: 'bold' }],
|
|
}
|
|
),
|
|
getReleaseNoteImageAttachment: sandbox.stub().resolves({
|
|
imageData: new Uint8Array([1, 2, 3]),
|
|
contentType: 'image/png',
|
|
}),
|
|
};
|
|
|
|
sandbox.stub(window.textsecure, 'server').value(serverStubs);
|
|
|
|
// Stub other globals
|
|
sandbox.stub(window.SignalContext, 'getI18nLocale').returns('en-US');
|
|
sandbox.stub(window, 'getVersion').returns(currentVersion);
|
|
|
|
sandbox.stub(window.Signal, 'Migrations').value({
|
|
writeNewAttachmentData: sandbox
|
|
.stub()
|
|
.resolves({ path: 'path/to/attachment' }),
|
|
processNewAttachment: sandbox.stub().resolves({
|
|
path: 'processed/path',
|
|
contentType: 'image/png',
|
|
size: 123,
|
|
}),
|
|
});
|
|
|
|
// Mock Whisper events
|
|
const fakeWhisperEvents = new EventEmitter();
|
|
sandbox.stub(window.Whisper, 'events').value(fakeWhisperEvents);
|
|
|
|
// Mock saveNewMessageBatcher
|
|
sandbox.stub(saveNewMessageBatcher, 'add').resolves();
|
|
|
|
// Mock SignalCI
|
|
window.SignalCI =
|
|
window.SignalCI ||
|
|
({
|
|
handleEvent: sandbox.stub(),
|
|
} as unknown as CIType);
|
|
|
|
// Helper to run fetcher and wait for completion
|
|
const runFetcherAndWaitForCompletion = async () => {
|
|
resumeFakeTimer();
|
|
await ReleaseNotesFetcher.init(events, isNewVersion);
|
|
|
|
strictAssert(
|
|
clock,
|
|
'fake timer should be initialized to start release notes fetcher'
|
|
);
|
|
// Fast-forward to trigger the run
|
|
await clock.nextAsync();
|
|
|
|
pauseFakeTimer();
|
|
// Wait for SignalCI.handleEvent to be called
|
|
const signalCI = window.SignalCI as unknown as {
|
|
handleEvent: sinon.SinonStub;
|
|
};
|
|
await waitUntil(() => signalCI.handleEvent.called, 1000);
|
|
resumeFakeTimer();
|
|
};
|
|
|
|
// Storage setup helper
|
|
const setupStorage = async () => {
|
|
// Set up storage values
|
|
await window.storage.put('chromiumRegistrationDone', '');
|
|
|
|
if (storedVersionWatermark !== undefined) {
|
|
await window.textsecure.storage.put(
|
|
VERSION_WATERMARK_STORAGE_KEY,
|
|
storedVersionWatermark
|
|
);
|
|
} else {
|
|
await window.textsecure.storage.remove(VERSION_WATERMARK_STORAGE_KEY);
|
|
}
|
|
|
|
if (storedPreviousManifestHash !== undefined) {
|
|
await window.textsecure.storage.put(
|
|
PREVIOUS_MANIFEST_HASH_STORAGE_KEY,
|
|
storedPreviousManifestHash
|
|
);
|
|
} else {
|
|
await window.textsecure.storage.remove(
|
|
PREVIOUS_MANIFEST_HASH_STORAGE_KEY
|
|
);
|
|
}
|
|
|
|
if (storedNextFetchTime !== undefined) {
|
|
await window.textsecure.storage.put(
|
|
NEXT_FETCH_TIME_STORAGE_KEY,
|
|
storedNextFetchTime
|
|
);
|
|
} else {
|
|
await window.textsecure.storage.remove(NEXT_FETCH_TIME_STORAGE_KEY);
|
|
}
|
|
};
|
|
|
|
// Helper functions to get current storage values
|
|
const getCurrentHash = () => {
|
|
return window.textsecure.storage.get(PREVIOUS_MANIFEST_HASH_STORAGE_KEY);
|
|
};
|
|
|
|
const getCurrentWatermark = () => {
|
|
return window.textsecure.storage.get(VERSION_WATERMARK_STORAGE_KEY);
|
|
};
|
|
|
|
return {
|
|
// Core objects
|
|
sandbox,
|
|
clock,
|
|
events,
|
|
fakeConversation,
|
|
serverStubs,
|
|
|
|
// Constants
|
|
NEXT_FETCH_TIME_STORAGE_KEY,
|
|
PREVIOUS_MANIFEST_HASH_STORAGE_KEY,
|
|
VERSION_WATERMARK_STORAGE_KEY,
|
|
FETCH_INTERVAL,
|
|
|
|
// Test data
|
|
fakeNoteUuid,
|
|
now,
|
|
|
|
// Helper functions
|
|
pauseFakeTimer,
|
|
resumeFakeTimer,
|
|
runFetcherAndWaitForCompletion,
|
|
setupStorage,
|
|
getCurrentHash,
|
|
getCurrentWatermark,
|
|
};
|
|
}
|
|
|
|
afterEach(async () => {
|
|
// Reset static state
|
|
ReleaseNotesFetcher.initComplete = false;
|
|
|
|
// Restore all stubs and timers
|
|
sandbox.restore();
|
|
clock?.restore();
|
|
|
|
// Restore original global values (even if they were undefined)
|
|
window.textsecure.server = originalTextsecureServer as WebAPIType;
|
|
window.SignalCI = originalSignalCI as CIType;
|
|
|
|
// Reset storage state
|
|
await window.storage.fetch();
|
|
|
|
// Reset conversation controller for next test
|
|
window.ConversationController.reset();
|
|
});
|
|
|
|
describe('#run', () => {
|
|
it('initializes version watermark if not set', async () => {
|
|
const {
|
|
setupStorage,
|
|
runFetcherAndWaitForCompletion,
|
|
getCurrentWatermark,
|
|
} = await setupTest({
|
|
storedVersionWatermark: undefined,
|
|
});
|
|
|
|
await setupStorage();
|
|
await runFetcherAndWaitForCompletion();
|
|
|
|
const storedWatermark = getCurrentWatermark();
|
|
|
|
assert.strictEqual(storedWatermark, '1.36.0');
|
|
});
|
|
|
|
it('fetches manifest when hash changes', async () => {
|
|
const {
|
|
setupStorage,
|
|
runFetcherAndWaitForCompletion,
|
|
serverStubs,
|
|
getCurrentHash,
|
|
} = await setupTest({
|
|
storedPreviousManifestHash: 'old-hash',
|
|
manifestHash: 'new-hash-123',
|
|
});
|
|
|
|
await setupStorage();
|
|
await runFetcherAndWaitForCompletion();
|
|
|
|
sinon.assert.calledOnce(serverStubs.getReleaseNotesManifest);
|
|
|
|
assert.strictEqual(getCurrentHash(), 'new-hash-123');
|
|
});
|
|
|
|
it('does not fetch when hash is the same', async () => {
|
|
const {
|
|
setupStorage,
|
|
runFetcherAndWaitForCompletion,
|
|
serverStubs,
|
|
getCurrentHash,
|
|
} = await setupTest({
|
|
storedPreviousManifestHash: 'hash',
|
|
manifestHash: 'hash',
|
|
});
|
|
|
|
await setupStorage();
|
|
await runFetcherAndWaitForCompletion();
|
|
|
|
sinon.assert.notCalled(serverStubs.getReleaseNotesManifest);
|
|
|
|
assert.strictEqual(getCurrentHash(), 'hash');
|
|
});
|
|
|
|
// Flaky in CI, TODO(yash): DESKTOP-8877
|
|
it.skip('forces a manifest fetch for a new version', async () => {
|
|
const {
|
|
setupStorage,
|
|
runFetcherAndWaitForCompletion,
|
|
serverStubs,
|
|
getCurrentHash,
|
|
} = await setupTest({
|
|
storedPreviousManifestHash: 'hash',
|
|
manifestHash: 'hash',
|
|
isNewVersion: true,
|
|
});
|
|
|
|
await setupStorage();
|
|
await runFetcherAndWaitForCompletion();
|
|
|
|
sinon.assert.calledOnce(serverStubs.getReleaseNotesManifest);
|
|
|
|
assert.strictEqual(getCurrentHash(), 'hash');
|
|
});
|
|
|
|
it('processes release notes when valid notes are found and updates watermark', async () => {
|
|
const {
|
|
setupStorage,
|
|
runFetcherAndWaitForCompletion,
|
|
serverStubs,
|
|
getCurrentWatermark,
|
|
} = await setupTest({
|
|
storedPreviousManifestHash: 'old-hash',
|
|
manifestHash: 'new-hash-123',
|
|
currentVersion: 'v1.37.0',
|
|
noteVersion: 'v1.37.0',
|
|
storedVersionWatermark: 'v1.36.0',
|
|
});
|
|
|
|
await setupStorage();
|
|
await runFetcherAndWaitForCompletion();
|
|
|
|
sinon.assert.calledOnce(serverStubs.getReleaseNotesManifest);
|
|
sinon.assert.calledOnce(serverStubs.getReleaseNote);
|
|
sinon.assert.called(window.MessageCache.register as sinon.SinonStub);
|
|
|
|
assert.strictEqual(getCurrentWatermark(), 'v1.37.0');
|
|
});
|
|
});
|
|
});
|