Skip to content

Commit 64b9cda

Browse files
authored
Merge branch 'sugarlabs:master' into fix/lock-contention
2 parents 8778bcf + 2955afa commit 64b9cda

55 files changed

Lines changed: 1786 additions & 904 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

android_chrome_manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
"background_color": "#F9F9F9",
7979
"theme_color": "#2196F3",
8080
"display": "standalone",
81-
"orientation": "landscape",
81+
"orientation": "any",
8282
"dir": "ltr",
8383
"lang": "en",
8484
"categories": ["code", "education"],
@@ -89,4 +89,4 @@
8989
"prefer_related_applications": false,
9090
"related_applications": [],
9191
"permissions": ["clipboardWrite", "storage", "unlimitedStorage"]
92-
}
92+
}

css/darkmode.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ body.highcontrast {
4949
color: var(--fg);
5050
}
5151

52+
.highcontrast #helpBodyDiv .help-tour-white-icon {
53+
filter: brightness(0) invert(1);
54+
}
55+
5256
.highcontrast #floatingWindows .wfWinBody {
5357
background-color: var(--bg) !important;
5458
}

env.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
// File for static Musicblocks hosting e.g. https://sugarlabs.github.io/musicblocks/
22
window.MB_ENV = "production";
33
window.MB_IS_DEV = false;
4+
5+
// API keys — centralised here so they are easy to rotate and audit.
6+
// Long-term these should be moved to a server-side proxy so that
7+
// secrets are never shipped to the client.
8+
window.MB_PLANET_API_KEY = "3f2d3a4c-c7a4-4c3c-892e-ac43784f7381";
9+
window.MB_PROJECT_API_KEY = "3tgTzMXbbw6xEKX7";

gulpfile.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,11 @@ export function prettify() {
7171
return gulp.src(paths.scripts.src).pipe(prettier()).pipe(gulp.dest("js"));
7272
}
7373

74-
export const build = gulp.series(gulp.parallel(jsTask, sassTask));
74+
export function validate() {
75+
return gulp
76+
.src(paths.scripts.src)
77+
.pipe(prettier.check({ singleQuote: true, trailingComma: "all" }));
78+
}
79+
80+
export const build = gulp.series(gulp.parallel(jsTask, cssTask, sassTask), validate);
7581
export default build;

index.html

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,6 @@
110110

111111
<!-- <script src="lib/mespeak.js"></script> -->
112112

113-
<script src="lib/reqwest.js" defer></script>
114-
115113
<script src="dist/vendor.min.js"></script>
116114
<link
117115
rel="preload"
@@ -1040,8 +1038,15 @@
10401038
<script>
10411039
// Register service worker only in production (not on localhost)
10421040
if ("serviceWorker" in navigator) {
1041+
const readStorage = key => {
1042+
try {
1043+
return localStorage.getItem(key);
1044+
} catch (e) {
1045+
return null;
1046+
}
1047+
};
10431048
// Localhost SW is disabled to avoid caching; set allowLocalPwa to test installs.
1044-
const allowLocalPwa = localStorage.getItem("allowLocalPwa") === "true";
1049+
const allowLocalPwa = readStorage("allowLocalPwa") === "true";
10451050

10461051
// Detect if running on localhost for development
10471052
const runtimeEnv = window.MB_ENV;
@@ -1121,12 +1126,28 @@
11211126
<script>
11221127
document.addEventListener("DOMContentLoaded", function () {
11231128
window._ = window._ || (x => x);
1129+
const readStorage = key => {
1130+
try {
1131+
return localStorage.getItem(key);
1132+
} catch (e) {
1133+
return null;
1134+
}
1135+
};
1136+
const writeStorage = (key, value) => {
1137+
try {
1138+
localStorage.setItem(key, value);
1139+
return true;
1140+
} catch (e) {
1141+
return false;
1142+
}
1143+
};
11241144
let loadL10nSplashScreen = function () {
11251145
console.debug("The browser is set to " + navigator.language);
11261146
let lang = navigator.language;
1127-
if (localStorage.languagePreference) {
1128-
console.debug("Music Blocks is set to " + localStorage.languagePreference);
1129-
lang = localStorage.languagePreference;
1147+
const savedLanguage = readStorage("languagePreference");
1148+
if (savedLanguage) {
1149+
console.debug("Music Blocks is set to " + savedLanguage);
1150+
lang = savedLanguage;
11301151
}
11311152

11321153
console.debug("Using " + lang);
@@ -1169,9 +1190,8 @@
11691190
link.addEventListener("click", function (e) {
11701191
e.preventDefault(); // prevent default <a> behavior
11711192
const selectedLang = this.id; // ID corresponds to language code
1172-
const currentLang = localStorage.getItem("languagePreference");
1173-
if (currentLang !== selectedLang) {
1174-
localStorage.setItem("languagePreference", selectedLang);
1193+
const currentLang = readStorage("languagePreference");
1194+
if (currentLang !== selectedLang && writeStorage("languagePreference", selectedLang)) {
11751195
location.reload(); // automatically reload
11761196
}
11771197
});

js/__tests__/activity_listeners.test.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ describe("Activity Event Listener Management", () => {
148148
expect(activity._listeners).toHaveLength(0);
149149
});
150150

151+
test("should delegate idle watcher cleanup through _stopIdleWatcher", () => {
152+
activity._stopIdleWatcher = jest.fn();
153+
154+
activity.cleanupEventListeners();
155+
156+
expect(activity._stopIdleWatcher).toHaveBeenCalledTimes(1);
157+
});
158+
151159
test("should handle boolean options vs object options", () => {
152160
// true === { capture: true } in our logic?
153161
// _areOptionsEqual logic: normalize boolean to boolean, object to !!opt.capture

js/__tests__/blockfactory.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
const { SVG } = require("../blockfactory");
2121
const { platformColor } = require("../utils/platformstyle");
2222
global.platformColor = platformColor;
23+
global.deepClone = value => {
24+
if (typeof structuredClone === "function") {
25+
return structuredClone(value);
26+
}
27+
return JSON.parse(JSON.stringify(value));
28+
};
2329

2430
jest.mock("../utils/platformstyle", () => ({
2531
platformColor: { header: "#FFFFFF" }

js/__tests__/palette.test.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,12 @@ describe("Palettes Class", () => {
284284

285285
describe("_makeSelectorButton method", () => {
286286
test("creates a selector cell and hooks hover handlers", () => {
287-
const tdMock = { style: {}, appendChild: jest.fn() };
288-
const trMock = { insertCell: jest.fn(() => tdMock), children: [{}, { children: [] }] };
287+
const tdMock = { style: {}, appendChild: jest.fn(), setAttribute: jest.fn() };
288+
const trMock = {
289+
insertCell: jest.fn(() => tdMock),
290+
children: [{}, { children: [] }],
291+
setAttribute: jest.fn()
292+
};
289293
const paletteElement = {
290294
children: [
291295
{
@@ -1520,6 +1524,7 @@ describe("Palettes Class", () => {
15201524
if (tag === "table") return paletteBody;
15211525
return {
15221526
style: {},
1527+
setAttribute: jest.fn(),
15231528
appendChild: jest.fn(),
15241529
children: [],
15251530
classList: { add: jest.fn() }
@@ -1546,8 +1551,10 @@ describe("Palettes Class", () => {
15461551
test("_showMenuItems renders a basic block", () => {
15471552
const paletteList = {
15481553
insertRow: jest.fn(() => ({
1554+
setAttribute: jest.fn(),
15491555
insertCell: jest.fn(() => ({
15501556
style: {},
1557+
setAttribute: jest.fn(),
15511558
appendChild: jest.fn()
15521559
}))
15531560
})),
@@ -1600,6 +1607,7 @@ describe("Palettes Class", () => {
16001607
if (tag === "tr") {
16011608
return {
16021609
children: [],
1610+
setAttribute: jest.fn(),
16031611
appendChild(child) {
16041612
this.children.push(child);
16051613
}
@@ -1609,6 +1617,7 @@ describe("Palettes Class", () => {
16091617
if (tag === "td") {
16101618
return {
16111619
style: {},
1620+
setAttribute: jest.fn(),
16121621
appendChild(img) {
16131622
capturedImg = img;
16141623
}
@@ -1698,6 +1707,7 @@ describe("Palettes Class", () => {
16981707
if (tag === "tr") {
16991708
return {
17001709
children: [],
1710+
setAttribute: jest.fn(),
17011711
appendChild(child) {
17021712
this.children.push(child);
17031713
}
@@ -1707,6 +1717,7 @@ describe("Palettes Class", () => {
17071717
if (tag === "td") {
17081718
return {
17091719
style: {},
1720+
setAttribute: jest.fn(),
17101721
appendChild(img) {
17111722
capturedImg = img;
17121723
}
@@ -1760,8 +1771,10 @@ describe("Palettes Class", () => {
17601771
test("_showMenuItems hides palette when mobile", () => {
17611772
const paletteList = {
17621773
insertRow: jest.fn(() => ({
1774+
setAttribute: jest.fn(),
17631775
insertCell: jest.fn(() => ({
17641776
style: {},
1777+
setAttribute: jest.fn(),
17651778
appendChild: jest.fn()
17661779
}))
17671780
})),

js/__tests__/planetInterface.test.js

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const mockActivity = {
3232
_loadStart: jest.fn(),
3333
doLoadAnimation: jest.fn(),
3434
textMsg: jest.fn(),
35+
errorMsg: jest.fn(),
3536
stage: { enableDOMEvents: jest.fn(), update: jest.fn() },
3637
blocks: { loadNewBlocks: jest.fn(), palettes: { _hideMenus: jest.fn() }, trashStacks: [] },
3738
logo: { doStopTurtles: jest.fn() },
@@ -75,6 +76,7 @@ describe("PlanetInterface", () => {
7576

7677
beforeEach(() => {
7778
planetInterface = new PlanetInterface(mockActivity);
79+
mockActivity.errorMsg.mockClear();
7880
});
7981

8082
test("hideMusicBlocks hides relevant elements and disables DOM events", () => {
@@ -108,6 +110,7 @@ describe("PlanetInterface", () => {
108110
});
109111

110112
test("openPlanet calls saveLocally, hideMusicBlocks, and showPlanet", () => {
113+
planetInterface.planet = { ProjectStorage: {}, open: jest.fn() };
111114
jest.spyOn(planetInterface, "saveLocally").mockImplementation(() => {});
112115
jest.spyOn(planetInterface, "hideMusicBlocks").mockImplementation(() => {});
113116
jest.spyOn(planetInterface, "showPlanet").mockImplementation(() => {});
@@ -117,6 +120,23 @@ describe("PlanetInterface", () => {
117120
expect(planetInterface.showPlanet).toHaveBeenCalled();
118121
});
119122

123+
test("openPlanet: does not crash when Planet backend is unavailable", () => {
124+
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
125+
planetInterface.planet = null;
126+
jest.spyOn(planetInterface, "saveLocally");
127+
jest.spyOn(planetInterface, "hideMusicBlocks");
128+
jest.spyOn(planetInterface, "showPlanet");
129+
130+
expect(() => planetInterface.openPlanet()).not.toThrow();
131+
expect(planetInterface.saveLocally).not.toHaveBeenCalled();
132+
expect(planetInterface.hideMusicBlocks).not.toHaveBeenCalled();
133+
expect(planetInterface.showPlanet).not.toHaveBeenCalled();
134+
expect(errorSpy).toHaveBeenCalledWith(
135+
"[PlanetInterface] openPlanet called before Planet is ready."
136+
);
137+
errorSpy.mockRestore();
138+
});
139+
120140
test("closePlanet calls hidePlanet and showMusicBlocks", () => {
121141
jest.spyOn(planetInterface, "hidePlanet").mockImplementation(() => {});
122142
jest.spyOn(planetInterface, "showMusicBlocks").mockImplementation(() => {});
@@ -189,14 +209,26 @@ describe("PlanetInterface", () => {
189209
doSVG.mockReturnValue("");
190210
const D = { x: 1 };
191211
mockActivity.prepareExport.mockReturnValue(D);
212+
mockActivity.stage.update.mockClear();
192213

193214
planetInterface.planet = { ProjectStorage: { saveLocally: jest.fn() } };
194215

195216
return planetInterface.saveLocally().then(() => {
217+
expect(mockActivity.stage.update).toHaveBeenCalledWith();
196218
expect(planetInterface.planet.ProjectStorage.saveLocally).toHaveBeenCalledWith(D, null);
197219
});
198220
});
199221

222+
test("saveLocally: returns null without throwing when Planet storage is unavailable", async () => {
223+
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
224+
planetInterface.planet = null;
225+
226+
await expect(planetInterface.saveLocally()).resolves.toBeNull();
227+
expect(errorSpy).toHaveBeenCalledWith(
228+
"[PlanetInterface] saveLocally called before Planet storage is ready."
229+
);
230+
errorSpy.mockRestore();
231+
});
200232
test("getCurrentProjectDescription/Image/TimeLastSaved", () => {
201233
const D = new Date(2020, 1, 1);
202234
planetInterface.planet = {
@@ -369,10 +401,9 @@ describe("PlanetInterface", () => {
369401
it("loadProjectFromData shows error and returns early if data is undefined", () => {
370402
const saved_ = global._;
371403
global._ = jest.fn(str => str);
372-
planetInterface.errorMsg = jest.fn();
373404
planetInterface.iframe = { style: { display: "" } };
374405
planetInterface.loadProjectFromData(undefined, false);
375-
expect(planetInterface.errorMsg).toHaveBeenCalledWith("project undefined");
406+
expect(mockActivity.errorMsg).toHaveBeenCalledWith("project undefined");
376407
global._ = saved_;
377408
});
378409

@@ -399,12 +430,11 @@ describe("PlanetInterface", () => {
399430
delete document.attachEvent;
400431
});
401432

402-
it("loadProjectFromData catches JSON parse errors and calls errorMsg", () => {
433+
it("loadProjectFromData catches JSON parse errors and calls activity.errorMsg", () => {
403434
planetInterface.iframe = { style: { display: "" } };
404435
planetInterface.getCurrentProjectName = jest.fn(() => "foo");
405-
planetInterface.errorMsg = jest.fn();
406436
planetInterface.loadProjectFromData("invalid json");
407-
expect(planetInterface.errorMsg).toHaveBeenCalledWith(expect.any(SyntaxError));
437+
expect(mockActivity.errorMsg).toHaveBeenCalledWith(expect.any(SyntaxError));
408438
});
409439

410440
it("saveLocally handles quota exceeded error and shows storage warning", () => {

0 commit comments

Comments
 (0)