mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* fix(op-log): lock snapshot save to prevent lost-update window saveCurrentStateAsSnapshot() read NgRx state then lastSeq without holding OPERATION_LOG lock. An op appended between the two reads would get seq <= lastAppliedOpSeq but its effect would be absent from the snapshot. On next hydration the tail replay would start after that seq, silently skipping the op forever. Fix: wrap in lockService.request(LOCK_NAMES.OPERATION_LOG, ...) and read lastSeq BEFORE state snapshot so the worst interleaving degrades to harmless re-replay (idempotent) rather than a missed op. Fixes #8308 * fix(op-log): address review feedback on snapshot lock PR - Amend JSDoc idempotency claim: syncTimeSpent is additive on re-replay - Add inline note about compaction's opposite read order and worse failure mode - Add lock regression tests (#8308): lock acquired, read order, error handling Co-Authored-By: Claude <noreply@anthropic.com> * refactor(sync): remove unused error classes, dialog, and constructor-time logging Phase 1 of #8325: clean up orphaned sync error types and their UI. Removed error classes that are no longer thrown anywhere: - NoEtagAPIError, FileExistsAPIError (unused API errors) - RevMismatchForModelError, SyncInvalidTimeValuesError (superseded by file-based flow) - RevMapModelMismatchErrorOnDownload/Upload, NoRemoteModelFile, NoRemoteMetaFile - LockPresentError, LockFromLocalClientPresentError, MetaNotReadyError, InvalidRevMapError Removed DialogSyncErrorComponent and all references in SyncWrapperService (_forceDownload, _handleIncoherentTimestampsDialog, _handleIncompleteSyncDialog, _openSyncErrorDialog, _extractModelIdFromError). Removed constructor-time logging from JsonParseError, ModelValidationError, DataValidationFailedError — these errors are logged at the catch site; redundant construction-time logs risk leaking user data. Cleaned up dead translation keys (D_INCOMPLETE_SYNC block, DIALOG_RESULT_ERROR, ERROR_DATA_IS_CURRENTLY_WRITTEN) from en.json and t.const.ts. Updated file-based-sync-flowchart.md to reflect the removed error types. --------- Co-authored-by: Claude <noreply@anthropic.com>
349 lines
9.1 KiB
JavaScript
349 lines
9.1 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const Module = require('node:module');
|
|
|
|
require('ts-node/register/transpile-only');
|
|
|
|
const originalModuleLoad = Module._load;
|
|
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
|
|
|
const indicatorModulePath = path.resolve(__dirname, 'indicator.ts');
|
|
|
|
let createdTrayArgs;
|
|
let createdFromPath = [];
|
|
let traySetImageCalls = [];
|
|
let traySetTitleCalls = [];
|
|
let traySetToolTipCalls = [];
|
|
let ipcHandlers = new Map();
|
|
let nextNativeImageIsEmpty = false;
|
|
let beforeQuitHandler = () => {};
|
|
let mockIsTrayShowCurrentTask = false;
|
|
let mockIsTrayShowCurrentCountdown = false;
|
|
|
|
const resetModule = () => {
|
|
delete require.cache[indicatorModulePath];
|
|
};
|
|
|
|
const installMocks = () => {
|
|
Module._load = function patchedLoad(request, parent, isMain) {
|
|
if (request === 'electron') {
|
|
class FakeTray {
|
|
constructor(image, guid) {
|
|
createdTrayArgs.push([image, guid]);
|
|
}
|
|
|
|
setContextMenu() {}
|
|
|
|
on(eventName, handler) {
|
|
if (eventName === 'click') {
|
|
this._clickHandler = handler;
|
|
}
|
|
}
|
|
|
|
setImage(image) {
|
|
traySetImageCalls.push(image);
|
|
}
|
|
|
|
setTitle(title) {
|
|
traySetTitleCalls.push(title);
|
|
}
|
|
|
|
setToolTip(title) {
|
|
traySetToolTipCalls.push(title);
|
|
}
|
|
|
|
destroy() {}
|
|
}
|
|
|
|
return {
|
|
Tray: FakeTray,
|
|
Menu: {
|
|
buildFromTemplate: (template) => ({ template }),
|
|
},
|
|
ipcMain: {
|
|
on: (eventName, handler) => {
|
|
ipcHandlers.set(eventName, handler);
|
|
},
|
|
},
|
|
nativeTheme: {
|
|
shouldUseDarkColors: false,
|
|
},
|
|
nativeImage: {
|
|
createFromPath: (iconPath) => {
|
|
createdFromPath.push(iconPath);
|
|
return {
|
|
iconPath,
|
|
kind: 'native-image',
|
|
isEmpty: () => nextNativeImageIsEmpty,
|
|
setTemplateImage: () => {},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (request === 'electron-log/main') {
|
|
return {
|
|
log: () => {},
|
|
};
|
|
}
|
|
|
|
if (request === './shared-with-frontend/ipc-events.const') {
|
|
return {
|
|
IPC: {
|
|
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
|
|
SET_PROGRESS_BAR: 'SET_PROGRESS_BAR',
|
|
CURRENT_TASK_UPDATED: 'CURRENT_TASK_UPDATED',
|
|
TODAY_TASKS_UPDATED: 'TODAY_TASKS_UPDATED',
|
|
TASK_TOGGLE_START: 'TASK_TOGGLE_START',
|
|
SWITCH_TASK: 'SWITCH_TASK',
|
|
},
|
|
};
|
|
}
|
|
|
|
if (request === './shared-state') {
|
|
return {
|
|
getIsTrayShowCurrentTask: () => mockIsTrayShowCurrentTask,
|
|
getIsTrayShowCurrentCountdown: () => mockIsTrayShowCurrentCountdown,
|
|
};
|
|
}
|
|
|
|
if (request === './task-widget/task-widget') {
|
|
return {
|
|
updateTaskWidgetAlwaysShow: () => {},
|
|
updateTaskWidgetEnabled: () => {},
|
|
updateTaskWidgetOpacity: () => {},
|
|
updateTaskWidgetTask: () => {},
|
|
initTaskWidgetSettingsListener: () => {},
|
|
};
|
|
}
|
|
|
|
if (request === './main-window') {
|
|
return {
|
|
getWin: () => undefined,
|
|
};
|
|
}
|
|
|
|
if (
|
|
request === '../src/app/features/tasks/task.model' ||
|
|
request === '../src/app/features/config/global-config.model'
|
|
) {
|
|
return {};
|
|
}
|
|
|
|
return originalModuleLoad.call(this, request, parent, isMain);
|
|
};
|
|
};
|
|
|
|
const loadIndicatorModule = () => {
|
|
resetModule();
|
|
return require(indicatorModulePath);
|
|
};
|
|
|
|
test.beforeEach(() => {
|
|
createdTrayArgs = [];
|
|
createdFromPath = [];
|
|
traySetImageCalls = [];
|
|
traySetTitleCalls = [];
|
|
traySetToolTipCalls = [];
|
|
ipcHandlers = new Map();
|
|
nextNativeImageIsEmpty = false;
|
|
beforeQuitHandler = () => {};
|
|
mockIsTrayShowCurrentTask = false;
|
|
mockIsTrayShowCurrentCountdown = false;
|
|
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'linux',
|
|
});
|
|
|
|
installMocks();
|
|
});
|
|
|
|
test.afterEach(() => {
|
|
Module._load = originalModuleLoad;
|
|
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
|
// Windows distribution-channel signals read by getDistChannel(); clear so
|
|
// they never leak into the next test.
|
|
delete process.windowsStore;
|
|
delete process.env.PORTABLE_EXECUTABLE_DIR;
|
|
resetModule();
|
|
});
|
|
|
|
// Windows tray icon GUIDs are bound to the executable path. NSIS installs to a
|
|
// stable location, so it keeps its GUID; Store (MSIX) and portable/scoop run
|
|
// from versioned paths where a stale GUID makes the icon silently invisible
|
|
// (#7282), so those build the tray without a GUID.
|
|
const NSIS_TRAY_GUID = 'a2512177-8bee-4b70-a0a8-f3d18e0eab90';
|
|
|
|
const initIndicatorOnWindows = () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32',
|
|
});
|
|
const { initIndicator } = loadIndicatorModule();
|
|
initIndicator({
|
|
showApp: () => {},
|
|
quitApp: () => {},
|
|
ICONS_FOLDER: '/icons/',
|
|
forceDarkTray: false,
|
|
app: { on: () => {} },
|
|
});
|
|
};
|
|
|
|
test('Windows NSIS build creates the tray with a stable GUID', () => {
|
|
// Neither windowsStore nor PORTABLE_EXECUTABLE_DIR set -> win-nsis.
|
|
initIndicatorOnWindows();
|
|
|
|
assert.equal(createdTrayArgs.length, 1);
|
|
assert.equal(createdTrayArgs[0][1], NSIS_TRAY_GUID);
|
|
});
|
|
|
|
test('Windows Store (MSIX) build creates the tray without a GUID', () => {
|
|
process.windowsStore = true;
|
|
initIndicatorOnWindows();
|
|
|
|
assert.equal(createdTrayArgs.length, 1);
|
|
assert.equal(createdTrayArgs[0][1], undefined);
|
|
});
|
|
|
|
test('Windows portable build creates the tray without a GUID', () => {
|
|
process.env.PORTABLE_EXECUTABLE_DIR = 'C:\\Users\\test\\sp-portable';
|
|
initIndicatorOnWindows();
|
|
|
|
assert.equal(createdTrayArgs.length, 1);
|
|
assert.equal(createdTrayArgs[0][1], undefined);
|
|
});
|
|
|
|
test('initIndicator uses NativeImage for Linux tray creation and updates', () => {
|
|
const { initIndicator } = loadIndicatorModule();
|
|
|
|
initIndicator({
|
|
showApp: () => {},
|
|
quitApp: () => {},
|
|
ICONS_FOLDER: '/icons/',
|
|
forceDarkTray: false,
|
|
app: {
|
|
on: (eventName, handler) => {
|
|
if (eventName === 'before-quit') {
|
|
beforeQuitHandler = handler;
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(createdTrayArgs.length, 1);
|
|
assert.equal(createdTrayArgs[0][0].kind, 'native-image');
|
|
assert.match(createdTrayArgs[0][0].iconPath, /\/icons\/indicator\/stopped-d\.png$/);
|
|
assert.match(createdFromPath[0], /\/icons\/indicator\/stopped-d\.png$/);
|
|
|
|
const currentTaskUpdated = ipcHandlers.get('CURRENT_TASK_UPDATED');
|
|
assert.equal(typeof currentTaskUpdated, 'function');
|
|
|
|
currentTaskUpdated(
|
|
{},
|
|
{ id: 'T1', title: 'Task', timeSpent: 5 * 60000, timeEstimate: 25 * 60000 },
|
|
false,
|
|
0,
|
|
false,
|
|
0,
|
|
undefined,
|
|
);
|
|
|
|
// On Linux the running icon stays static (no progress-animation frames) to
|
|
// avoid StatusNotifierItem flicker (#4905).
|
|
assert.equal(traySetImageCalls.at(-1).kind, 'native-image');
|
|
assert.match(traySetImageCalls.at(-1).iconPath, /\/icons\/indicator\/running-d\.png$/);
|
|
|
|
beforeQuitHandler();
|
|
});
|
|
|
|
test('non-Linux platforms keep the running progress animation', () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'darwin',
|
|
});
|
|
const { initIndicator } = loadIndicatorModule();
|
|
|
|
initIndicator({
|
|
showApp: () => {},
|
|
quitApp: () => {},
|
|
ICONS_FOLDER: '/icons/',
|
|
forceDarkTray: false,
|
|
app: { on: () => {} },
|
|
});
|
|
|
|
const currentTaskUpdated = ipcHandlers.get('CURRENT_TASK_UPDATED');
|
|
currentTaskUpdated(
|
|
{},
|
|
{ id: 'T1', title: 'Task', timeSpent: 5 * 60000, timeEstimate: 25 * 60000 },
|
|
false,
|
|
0,
|
|
false,
|
|
0,
|
|
undefined,
|
|
);
|
|
|
|
// macOS uses the black (`-l`) template icon and still animates progress.
|
|
assert.match(
|
|
traySetImageCalls.at(-1).iconPath,
|
|
/\/icons\/indicator\/running-anim-l\/3\.png$/,
|
|
);
|
|
});
|
|
|
|
test('initIndicator falls back to icon path if NativeImage creation is empty', () => {
|
|
nextNativeImageIsEmpty = true;
|
|
const { initIndicator } = loadIndicatorModule();
|
|
|
|
initIndicator({
|
|
showApp: () => {},
|
|
quitApp: () => {},
|
|
ICONS_FOLDER: '/icons/',
|
|
forceDarkTray: false,
|
|
app: {
|
|
on: () => {},
|
|
},
|
|
});
|
|
|
|
assert.equal(createdTrayArgs.length, 1);
|
|
assert.equal(typeof createdTrayArgs[0][0], 'string');
|
|
assert.match(createdTrayArgs[0][0], /\/icons\/indicator\/stopped-d\.png$/);
|
|
});
|
|
|
|
test('tray title shows the task title when countdown display is disabled', () => {
|
|
mockIsTrayShowCurrentTask = true;
|
|
mockIsTrayShowCurrentCountdown = false;
|
|
const { initIndicator } = loadIndicatorModule();
|
|
|
|
initIndicator({
|
|
showApp: () => {},
|
|
quitApp: () => {},
|
|
ICONS_FOLDER: '/icons/',
|
|
forceDarkTray: false,
|
|
app: {
|
|
on: () => {},
|
|
},
|
|
});
|
|
|
|
const currentTaskUpdated = ipcHandlers.get('CURRENT_TASK_UPDATED');
|
|
assert.equal(typeof currentTaskUpdated, 'function');
|
|
|
|
currentTaskUpdated(
|
|
{},
|
|
{
|
|
id: 'T1',
|
|
title: 'Write release notes',
|
|
timeSpent: 5 * 60000,
|
|
timeEstimate: 25 * 60000,
|
|
},
|
|
false,
|
|
0,
|
|
false,
|
|
0,
|
|
undefined,
|
|
);
|
|
|
|
assert.equal(traySetTitleCalls.at(-1), 'Write release notes');
|
|
assert.equal(traySetToolTipCalls.at(-1), 'Write release notes');
|
|
});
|