Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@
</script>
<script src="lib/astring.min.js" defer></script>
<script src="lib/purify.min.js" defer></script>
<script src="js/utils/sessionManager.js" defer></script>
<script src="js/utils/mb-dialog.js" defer></script>
<script src="js/svgAssetSelector.js" defer></script>
<script src="lib/acorn.min.js" defer></script>
Expand Down
214 changes: 214 additions & 0 deletions js/__tests__/activity_storage.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* MusicBlocks v3.4.1
*
* @author Lavjeet Kumar Rai
*
* @copyright 2026 Lavjeet Kumar Rai
*
* @license
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

const fs = require("fs");
const path = require("path");
const vm = require("vm");

const loadActivityStorageMethods = () => {
const activityPath = path.resolve(__dirname, "../activity.js");
let code = fs.readFileSync(activityPath, "utf8");

// Extract loadStart
const loadStartStart = code.indexOf("const loadStart = async that => {");
const loadStartEnd = code.indexOf(" this.loadStartWrapper = async", loadStartStart);
if (loadStartStart === -1 || loadStartEnd === -1) {
throw new Error("Could not locate loadStart in activity.js");
}
let extractedCode = code.slice(loadStartStart, loadStartEnd);
extractedCode += "\nthis.loadStart = loadStart;\n";

// Extract saveSessionAsync
const saveStart = code.indexOf("this.saveSessionAsync = async () => {");
const saveEnd = code.indexOf(" this.setupMouseEvents = () => {", saveStart);
if (saveStart === -1 || saveEnd === -1) {
throw new Error("Could not locate saveSessionAsync in activity.js");
}
let saveCode = code.slice(saveStart, saveEnd);
// Convert arrow function to normal function so we can use .bind(activity) in tests
saveCode = saveCode.replace(
"this.saveSessionAsync = async () => {",
"this.saveSessionAsync = async function() {"
);
extractedCode += saveCode;

const recoverable = jest.fn();
const sandbox = {
ErrorHandler: {
recoverable,
capture: jest.fn(),
warn: jest.fn(),
userFacing: jest.fn()
},
window: global.window || {},
document: { addEventListener: jest.fn(), attachEvent: jest.fn() },
console,
_: key => key,
setTimeout,
globalActivity: null,
_THIS_IS_MUSIC_BLOCKS_: true,
setupActivityAbcParser: jest.fn(),
pubsub: { on: jest.fn(), off: jest.fn() },
Date: { now: () => 1000000 } // Mock Date.now()
};

vm.createContext(sandbox);
vm.runInContext(extractedCode, sandbox);

return {
loadStart: sandbox.loadStart,
saveSessionAsync: sandbox.saveSessionAsync,
recoverable
};
};

describe("Activity Storage (loadStart / saveSessionAsync)", () => {
let loadStart;
let saveSessionAsync;
let recoverable;

beforeAll(() => {
({ loadStart, saveSessionAsync, recoverable } = loadActivityStorageMethods());
});

afterEach(() => {
jest.clearAllMocks();
});

describe("saveSessionAsync", () => {
it("should call __saveLocally and save to IndexedDB", async () => {
const mockSessionStorageManager = {
saveSession: jest.fn().mockResolvedValue()
};
const mockStorage = {
currentProject: "TestProject"
};
const activity = {
__saveLocally: jest.fn(),
sessionStorageManager: mockSessionStorageManager,
prepareExport: jest.fn().mockReturnValue('{"blocks":[]}'),
storage: mockStorage
};

// Bind saveSessionAsync to our mock activity
const boundSave = saveSessionAsync.bind(activity);

await boundSave();

expect(activity.__saveLocally).toHaveBeenCalled();
expect(mockSessionStorageManager.saveSession).toHaveBeenCalledWith(
"SESSIONTestProject",
'{"blocks":[]}',
1000000
);
expect(mockStorage["SESSION_TIMESTAMPTestProject"]).toBe("1000000");
});
});

describe("loadStart", () => {
it("should prefer IndexedDB payload if it is newer", async () => {
const mockSessionStorageManager = {
loadSession: jest.fn().mockResolvedValue({
timestamp: 2000000,
data: '{"blocks":["idb"]}'
})
};
const mockStorage = {
currentProject: "TestProject",
SESSIONTestProject: '{"blocks":["local"]}',
SESSION_TIMESTAMPTestProject: "1500000" // Older than IndexedDB
};
const activity = {
storage: mockStorage,
sessionStorageManager: mockSessionStorageManager,
doLoadAnimation: jest.fn(),
justLoadStart: jest.fn(),
blocks: { loadNewBlocks: jest.fn() }
};

await loadStart(activity);

expect(mockSessionStorageManager.loadSession).toHaveBeenCalledWith(
"SESSIONTestProject"
);
// Since idb timestamp (2000000) > local timestamp (1500000), we should load idb payload
expect(activity.sessionData).toBe('{"blocks":["idb"]}');
expect(activity.blocks.loadNewBlocks).toHaveBeenCalledWith({ blocks: ["idb"] });
});

it("should prefer LocalStorage payload if it is newer", async () => {
const mockSessionStorageManager = {
loadSession: jest.fn().mockResolvedValue({
timestamp: 1000000,
data: '{"blocks":["idb"]}'
})
};
const mockStorage = {
currentProject: "TestProject",
SESSIONTestProject: '{"blocks":["local"]}',
SESSION_TIMESTAMPTestProject: "2500000" // Newer than IndexedDB
};
const activity = {
storage: mockStorage,
sessionStorageManager: mockSessionStorageManager,
doLoadAnimation: jest.fn(),
justLoadStart: jest.fn(),
blocks: { loadNewBlocks: jest.fn() }
};

await loadStart(activity);

expect(mockSessionStorageManager.loadSession).toHaveBeenCalledWith(
"SESSIONTestProject"
);
// Since local timestamp (2500000) > idb timestamp (1000000), we should load local payload
expect(activity.sessionData).toBe('{"blocks":["local"]}');
expect(activity.blocks.loadNewBlocks).toHaveBeenCalledWith({ blocks: ["local"] });
});

it("should fallback to LocalStorage if IndexedDB throws or is empty", async () => {
const mockSessionStorageManager = {
loadSession: jest.fn().mockResolvedValue(null) // No data in IndexedDB
};
const mockStorage = {
currentProject: "TestProject",
SESSIONTestProject: '{"blocks":["local"]}'
// Missing timestamp is handled safely
};
const activity = {
storage: mockStorage,
sessionStorageManager: mockSessionStorageManager,
doLoadAnimation: jest.fn(),
justLoadStart: jest.fn(),
blocks: { loadNewBlocks: jest.fn() }
};

await loadStart(activity);

expect(mockSessionStorageManager.loadSession).toHaveBeenCalledWith(
"SESSIONTestProject"
);
expect(activity.sessionData).toBe('{"blocks":["local"]}');
expect(activity.blocks.loadNewBlocks).toHaveBeenCalledWith({ blocks: ["local"] });
});
});
});
87 changes: 87 additions & 0 deletions js/__tests__/activity_toolbar_integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -356,5 +356,92 @@ describe("Activity Toolbar Integration", () => {

expect(activity._doHardStopButton).toHaveBeenCalled();
});

test("Ctrl+Shift+R triggers hard refresh logic and clears session DB", () => {
let thenCallback;
activity.sessionStorageManager = {
clearAllSessions: jest.fn().mockReturnValue({
then: cb => {
thenCallback = cb;
return { catch: jest.fn() };
}
})
};
activity.storage = {
currentProject: "TestProject",
removeItem: jest.fn()
};

const event = {
ctrlKey: true,
shiftKey: true,
key: "r",
preventDefault: jest.fn(),
stopPropagation: jest.fn()
};

activity.__keyPressed(event);

expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(activity._isHardReloading).toBe(true);
expect(activity.sessionStorageManager.clearAllSessions).toHaveBeenCalled();

// Now execute the callback in a try-catch to swallow JSDOM's reload error
try {
thenCallback();
} catch (e) {
// Ignore JSDOM "Not implemented: navigation"
}

expect(activity.storage.removeItem).toHaveBeenCalledWith("SESSIONTestProject");
expect(activity.storage.removeItem).toHaveBeenCalledWith(
"SESSION_TIMESTAMPTestProject"
);
});
});

describe("beforeunload event", () => {
test("calls __saveLocally when _isHardReloading is false", () => {
activity.__saveLocally = jest.fn();
activity._stopRenderLoop = jest.fn();
activity._isHardReloading = false;

activity._handleBeforeUnload();

expect(activity.__saveLocally).toHaveBeenCalled();
});

test("does not call __saveLocally when _isHardReloading is true", () => {
activity.__saveLocally = jest.fn();
activity._stopRenderLoop = jest.fn();
activity._isHardReloading = true;

activity._handleBeforeUnload();

expect(activity.__saveLocally).not.toHaveBeenCalled();
});

test("calls saveLocally when it differs from __saveLocally", () => {
activity.__saveLocally = jest.fn();
activity.saveLocally = jest.fn();
activity._stopRenderLoop = jest.fn();
activity._isHardReloading = false;

activity._handleBeforeUnload();

expect(activity.saveLocally).toHaveBeenCalled();
});

test("calls _stopAutoSave if it exists", () => {
activity.__saveLocally = jest.fn();
activity._stopRenderLoop = jest.fn();
activity._stopAutoSave = jest.fn();
activity._isHardReloading = false;

activity._handleBeforeUnload();

expect(activity._stopAutoSave).toHaveBeenCalled();
});
});
});
26 changes: 25 additions & 1 deletion js/__tests__/languagebox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,31 @@ describe("LanguageBox Class", () => {
reloadSpy.mockRestore();
});

it("should wait for saveLocally before reloading", async () => {
it("should wait for saveSessionAsync before reloading if it exists", async () => {
const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});
let resolveSave;
mockActivity.saveSessionAsync = jest.fn().mockReturnValue(
new Promise(resolve => {
resolveSave = resolve;
})
);

languageBox.reload();

expect(mockActivity.saveSessionAsync).toHaveBeenCalled();
expect(mockActivity.saveLocally).not.toHaveBeenCalled();
expect(consoleSpy).not.toHaveBeenCalled();

resolveSave();
await Promise.resolve();
await Promise.resolve();

expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
delete mockActivity.saveSessionAsync;
});

it("should wait for saveLocally before reloading if saveSessionAsync is missing", async () => {
const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});
let resolveSave;
mockActivity.saveLocally.mockReturnValue(
Expand Down
Loading
Loading