@robotical/martyblocksjr 4.1.2 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.firebase/hosting.ZWRpdGlvbnMvZnJlZS9zcmM.cache +599 -534
  2. package/editions/free/src/app.bundle.js +1 -1
  3. package/editions/free/src/assets/blockicons/microbitbuttona.svg +33 -0
  4. package/editions/free/src/assets/blockicons/microbitbuttonab.svg +33 -0
  5. package/editions/free/src/assets/blockicons/microbitbuttonb.svg +33 -0
  6. package/editions/free/src/assets/blockicons/microbitdisplayclear.svg +29 -0
  7. package/editions/free/src/assets/blockicons/microbitdisplayhappy.svg +36 -0
  8. package/editions/free/src/assets/blockicons/microbitdisplayheart.svg +42 -0
  9. package/editions/free/src/assets/blockicons/microbitdisplaytext.svg +29 -0
  10. package/editions/free/src/assets/blockicons/microbitgesturejumped.svg +40 -0
  11. package/editions/free/src/assets/blockicons/microbitgesturemoved.svg +41 -0
  12. package/editions/free/src/assets/blockicons/microbitgestureshaken.svg +45 -0
  13. package/editions/free/src/assets/blockicons/microbittiltany.svg +45 -0
  14. package/editions/free/src/assets/blockicons/microbittiltbackward.svg +39 -0
  15. package/editions/free/src/assets/blockicons/microbittiltforward.svg +39 -0
  16. package/editions/free/src/assets/blockicons/microbittiltleft.svg +39 -0
  17. package/editions/free/src/assets/blockicons/microbittiltright.svg +39 -0
  18. package/editions/free/src/assets/categories/MicroBitLooksOff.svg +17 -0
  19. package/editions/free/src/assets/categories/MicroBitLooksOn.svg +17 -0
  20. package/editions/free/src/assets/categories/MicroBitStartOff.svg +17 -0
  21. package/editions/free/src/assets/categories/MicroBitStartOn.svg +17 -0
  22. package/editions/free/src/assets/ui/viewOnCompact.png +0 -0
  23. package/editions/free/src/assets/ui/viewOnTargetCompact.png +0 -0
  24. package/editions/free/src/css/editor.css +334 -1
  25. package/editions/free/src/css/editorleftpanel.css +86 -26
  26. package/editions/free/src/css/editorstage.css +62 -0
  27. package/editions/free/src/css/paintlook.css +23 -0
  28. package/editions/free/src/localizations/bg.json +10 -1
  29. package/editions/free/src/localizations/ca.json +10 -1
  30. package/editions/free/src/localizations/cs.json +10 -1
  31. package/editions/free/src/localizations/cy.json +10 -1
  32. package/editions/free/src/localizations/da.json +10 -1
  33. package/editions/free/src/localizations/de.json +10 -1
  34. package/editions/free/src/localizations/el.json +10 -1
  35. package/editions/free/src/localizations/en.json +10 -1
  36. package/editions/free/src/localizations/es.json +10 -1
  37. package/editions/free/src/localizations/fi.json +10 -1
  38. package/editions/free/src/localizations/fr.json +10 -1
  39. package/editions/free/src/localizations/it.json +10 -1
  40. package/editions/free/src/localizations/ja.json +10 -1
  41. package/editions/free/src/localizations/ko.json +10 -1
  42. package/editions/free/src/localizations/nl.json +10 -1
  43. package/editions/free/src/localizations/no.json +10 -1
  44. package/editions/free/src/localizations/pl.json +10 -1
  45. package/editions/free/src/localizations/pt-br.json +10 -1
  46. package/editions/free/src/localizations/pt.json +10 -1
  47. package/editions/free/src/localizations/sv.json +10 -1
  48. package/editions/free/src/localizations/th.json +10 -1
  49. package/editions/free/src/localizations/tr.json +10 -1
  50. package/editions/free/src/localizations/uk.json +10 -1
  51. package/editions/free/src/localizations/zh-cn.json +10 -1
  52. package/editions/free/src/localizations/zh-tw.json +10 -1
  53. package/editions/free/src/media.json +15 -0
  54. package/editions/free/src/pnglibrary/BeeSprite1.png +0 -0
  55. package/editions/free/src/svglibrary/BeeSprite1.svg +4 -0
  56. package/editions/free/src/svglibrary/BeeSprite2.svg +4 -0
  57. package/editions/free/src/svglibrary/BeeSprite3.svg +4 -0
  58. package/package.json +1 -1
  59. package/tests/e2e/accessibility.e2e.test.js +18 -2
  60. package/tests/e2e/animated-sprite-paint-disabled.e2e.test.js +270 -0
  61. package/tests/e2e/background-paint-active-selection.e2e.test.js +162 -0
  62. package/tests/e2e/blocks-jr-hidden-blocks.e2e.test.js +262 -0
  63. package/tests/e2e/chromium-79-smoke.test.js +235 -0
  64. package/tests/e2e/delete-last-sprite-mode.e2e.test.js +162 -0
  65. package/tests/e2e/duplicate-page.e2e.test.js +414 -0
  66. package/tests/e2e/marty-connection-ui.e2e.test.js +56 -0
  67. package/tests/e2e/marty-script-activation-paths.e2e.test.js +522 -0
  68. package/tests/e2e/microbit-blocks.test.js +162 -0
  69. package/tests/e2e/microbit-extension-project-reload.e2e.test.js +309 -0
  70. package/tests/e2e/microbit-web-bluetooth.test.js +131 -0
  71. package/tests/e2e/page-background-loading.test.js +152 -0
  72. package/tests/e2e/paint-editor-cancel.e2e.test.js +274 -0
  73. package/tests/e2e/project-load-progress-loop.e2e.test.js +310 -0
  74. package/tests/e2e/project-save-stall.e2e.test.js +230 -0
  75. package/tests/e2e/sprite-watermark-grayscale.e2e.test.js +269 -0
  76. package/tests/e2e/top-toolbar-tooltips.e2e.test.js +152 -0
  77. package/vitest.config.js +2 -3
@@ -0,0 +1,274 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import puppeteer from "puppeteer";
3
+ import { spawn } from "child_process";
4
+ import http from "http";
5
+
6
+ const PORT = 3016;
7
+ const HOST = `http://localhost:${PORT}`;
8
+
9
+ let server;
10
+
11
+ async function waitForServer(maxAttempts = 20) {
12
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
13
+ const ok = await new Promise((resolve) => {
14
+ const req = http.get(`${HOST}/`, (res) => {
15
+ res.destroy();
16
+ resolve(true);
17
+ });
18
+ req.on("error", () => resolve(false));
19
+ });
20
+ if (ok) {
21
+ return;
22
+ }
23
+ await new Promise((resolve) => setTimeout(resolve, 500));
24
+ }
25
+ throw new Error("Local server did not start in time");
26
+ }
27
+
28
+ beforeAll(async () => {
29
+ server = spawn("python3", ["-m", "http.server", `${PORT}`, "--directory", "editions/free/src"], {
30
+ stdio: "ignore",
31
+ });
32
+ await waitForServer();
33
+ }, 30_000);
34
+
35
+ afterAll(() => {
36
+ if (server) {
37
+ server.kill();
38
+ }
39
+ });
40
+
41
+ async function openEditor() {
42
+ const browser = await puppeteer.launch({
43
+ headless: true,
44
+ args: ["--no-sandbox"],
45
+ });
46
+ const page = await browser.newPage();
47
+ const errors = [];
48
+
49
+ page.on("pageerror", (err) => errors.push(err.message || String(err)));
50
+ page.on("console", (msg) => {
51
+ if (msg.type() === "error") {
52
+ errors.push(msg.text());
53
+ }
54
+ });
55
+
56
+ await page.goto(`${HOST}/editor.html?mode=edit`, { waitUntil: "networkidle2", timeout: 30_000 });
57
+ await waitForEditorReady(page);
58
+
59
+ return { browser, page, errors };
60
+ }
61
+
62
+ async function waitForEditorReady(page) {
63
+ await page.waitForFunction(
64
+ () => {
65
+ const backdrop = document.getElementById("backdrop");
66
+ return Boolean(
67
+ window.ScratchJr
68
+ && window.ScratchJr.stage
69
+ && window.ScratchJr.stage.currentPage
70
+ && document.getElementById("setbkg")
71
+ && document.querySelector("#spritecc .spritethumb.on .brush")
72
+ && backdrop
73
+ && window.getComputedStyle(backdrop).display === "none"
74
+ && !window.ScratchJr.onHold
75
+ && window.ScratchJr.getActiveScript()
76
+ );
77
+ },
78
+ { timeout: 30_000 }
79
+ );
80
+ }
81
+
82
+ async function openSpritePaintEditor(page) {
83
+ await page.click("#addsprite");
84
+ await page.waitForSelector("#libframe.appear #Star\\.svg", { timeout: 30_000 });
85
+ await page.click("#Star\\.svg");
86
+ await page.waitForFunction(
87
+ () => {
88
+ const star = document.getElementById("Star.svg");
89
+ const paintButton = document.getElementById("library_paintme");
90
+ return Boolean(
91
+ star
92
+ && star.className.includes("on")
93
+ && paintButton.getAttribute("aria-disabled") === "false"
94
+ );
95
+ },
96
+ { timeout: 30_000 }
97
+ );
98
+
99
+ await new Promise((resolve) => setTimeout(resolve, 250));
100
+ await page.click("#Star\\.svg");
101
+ await page.waitForFunction(
102
+ () => {
103
+ const sprite = window.ScratchJr && window.ScratchJr.getSprite();
104
+ return Boolean(sprite && sprite.md5 === "Star.svg");
105
+ },
106
+ { timeout: 30_000 }
107
+ );
108
+
109
+ const starThumbId = await page.evaluate(() => window.ScratchJr.getSprite().thumbnail.id);
110
+ await page.click(`[id="${starThumbId}"]`);
111
+ await page.waitForFunction(
112
+ () => {
113
+ const sprite = window.ScratchJr && window.ScratchJr.getSprite();
114
+ const brush = sprite && sprite.thumbnail && sprite.thumbnail.querySelector(".brush");
115
+ return Boolean(
116
+ sprite
117
+ && sprite.md5 === "Star.svg"
118
+ && sprite.thumbnail.className.includes("on")
119
+ && brush
120
+ && window.getComputedStyle(brush).display !== "none"
121
+ );
122
+ },
123
+ { timeout: 30_000 }
124
+ );
125
+
126
+ await page.evaluate(() => {
127
+ const sprite = window.ScratchJr.getSprite();
128
+ const brush = sprite && sprite.thumbnail && sprite.thumbnail.querySelector(".brush");
129
+ if (!brush || window.getComputedStyle(brush).display === "none") {
130
+ throw new Error("Selected editable sprite paintbrush control was not found");
131
+ }
132
+ brush.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true, pointerType: "mouse" }));
133
+ });
134
+ await waitForPaintEditor(page);
135
+ }
136
+
137
+ async function openBackgroundPaintEditor(page) {
138
+ await page.click("#setbkg");
139
+ await page.waitForSelector("#libframe.appear #library_paintme", { timeout: 30_000 });
140
+ await page.click("#library_paintme");
141
+ await waitForPaintEditor(page);
142
+ }
143
+
144
+ async function waitForPaintEditor(page) {
145
+ await page.waitForFunction(
146
+ () => {
147
+ const paintFrame = document.getElementById("paintframe");
148
+ const doneCheck = document.getElementById("donecheck");
149
+ const layer = document.getElementById("layer1");
150
+ return Boolean(
151
+ paintFrame
152
+ && doneCheck
153
+ && layer
154
+ && layer.childElementCount > 0
155
+ && window.getComputedStyle(paintFrame).display !== "none"
156
+ && window.getComputedStyle(doneCheck).display !== "none"
157
+ );
158
+ },
159
+ { timeout: 30_000 }
160
+ );
161
+ }
162
+
163
+ async function getPaintToolbarControls(page) {
164
+ return page.evaluate(() => {
165
+ const paintFrame = document.getElementById("paintframe");
166
+ const toolbar = paintFrame && paintFrame.querySelector(".paintop");
167
+ const controls = toolbar ? Array.from(toolbar.querySelectorAll("*")) : [];
168
+ const visibleControls = controls
169
+ .filter((element) => {
170
+ const styles = window.getComputedStyle(element);
171
+ return styles.display !== "none" && styles.visibility !== "hidden" && element.offsetWidth > 0 && element.offsetHeight > 0;
172
+ })
173
+ .map((element) => {
174
+ const styles = window.getComputedStyle(element);
175
+ return {
176
+ id: element.id,
177
+ className: element.className,
178
+ ariaLabel: element.getAttribute("aria-label") || "",
179
+ title: element.getAttribute("title") || "",
180
+ backgroundImage: styles.backgroundImage,
181
+ };
182
+ });
183
+
184
+ const isAcceptControl = (control) => (
185
+ control.id === "donecheck"
186
+ || /\bpaintdone\b/.test(control.className)
187
+ || /ok\.svg|check|accept|done/i.test(`${control.backgroundImage} ${control.ariaLabel} ${control.title}`)
188
+ );
189
+
190
+ const isCancelControl = (control) => (
191
+ /\b(cancelicon|paintcancel|closeModal|closex)\b/.test(control.className)
192
+ || /\b(cancel|close|dismiss)\b/i.test(`${control.id} ${control.ariaLabel} ${control.title}`)
193
+ || /cancel\.svg|close/i.test(control.backgroundImage)
194
+ );
195
+
196
+ return {
197
+ hasToolbar: Boolean(toolbar),
198
+ visibleControls,
199
+ acceptControls: visibleControls.filter(isAcceptControl),
200
+ cancelControls: visibleControls.filter(isCancelControl),
201
+ };
202
+ });
203
+ }
204
+
205
+ async function cancelPaintEditor(page, errors) {
206
+ const hitTarget = await page.$eval("#cancelcheck", (button) => {
207
+ const rect = button.getBoundingClientRect();
208
+ const target = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
209
+ return target ? { id: target.id, className: target.className, tagName: target.tagName } : null;
210
+ });
211
+ await page.click("#cancelcheck");
212
+ try {
213
+ await page.waitForFunction(() => {
214
+ const paintFrame = document.getElementById("paintframe");
215
+ const editorFrame = document.getElementById("frame");
216
+ return paintFrame
217
+ && editorFrame
218
+ && window.getComputedStyle(paintFrame).display === "none"
219
+ && window.getComputedStyle(editorFrame).display !== "none";
220
+ }, { timeout: 5_000 });
221
+ } catch (error) {
222
+ const state = await page.evaluate(() => ({
223
+ paintFrameClass: document.getElementById("paintframe").className,
224
+ paintFrameDisplay: window.getComputedStyle(document.getElementById("paintframe")).display,
225
+ editorFrameDisplay: window.getComputedStyle(document.getElementById("frame")).display,
226
+ cancelHasClickHandler: typeof document.getElementById("cancelcheck").onclick === "function",
227
+ }));
228
+ throw new Error(`Cancel control did not close the paint editor: ${JSON.stringify({ hitTarget, state, errors })}`);
229
+ }
230
+ }
231
+
232
+ describe("Paint editor cancel controls", () => {
233
+ it(
234
+ "offers a cancel control in the sprite paint editor",
235
+ async () => {
236
+ const { browser, page, errors } = await openEditor();
237
+
238
+ try {
239
+ await openSpritePaintEditor(page);
240
+ const spriteToolbar = await getPaintToolbarControls(page);
241
+
242
+ expect(spriteToolbar.hasToolbar).toBe(true);
243
+ expect(spriteToolbar.acceptControls.length).toBeGreaterThan(0);
244
+ expect(spriteToolbar.cancelControls.length).toBeGreaterThan(0);
245
+ await cancelPaintEditor(page, errors);
246
+ expect(errors).toEqual([]);
247
+ } finally {
248
+ await browser.close();
249
+ }
250
+ },
251
+ 60_000
252
+ );
253
+
254
+ it(
255
+ "offers a cancel control in the background paint editor",
256
+ async () => {
257
+ const { browser, page, errors } = await openEditor();
258
+
259
+ try {
260
+ await openBackgroundPaintEditor(page);
261
+ const backgroundToolbar = await getPaintToolbarControls(page);
262
+
263
+ expect(backgroundToolbar.hasToolbar).toBe(true);
264
+ expect(backgroundToolbar.acceptControls.length).toBeGreaterThan(0);
265
+ expect(backgroundToolbar.cancelControls.length).toBeGreaterThan(0);
266
+ await cancelPaintEditor(page, errors);
267
+ expect(errors).toEqual([]);
268
+ } finally {
269
+ await browser.close();
270
+ }
271
+ },
272
+ 60_000
273
+ );
274
+ });
@@ -0,0 +1,310 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import puppeteer from "puppeteer";
3
+ import { spawn } from "child_process";
4
+ import http from "http";
5
+
6
+ const PORT = 3014;
7
+ const HOST = `http://localhost:${PORT}`;
8
+ const MICROBIT_BLOCK_TYPE = "microbitdisplayclear";
9
+ const STUCK_PROGRESS_LOG = "setProgress 55 30 1 2";
10
+
11
+ let server;
12
+
13
+ async function waitForServer(maxAttempts = 20) {
14
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
15
+ const ok = await new Promise((resolve) => {
16
+ const req = http.get(`${HOST}/`, (res) => {
17
+ res.destroy();
18
+ resolve(true);
19
+ });
20
+ req.on("error", () => resolve(false));
21
+ });
22
+ if (ok) {
23
+ return;
24
+ }
25
+ await new Promise((resolve) => setTimeout(resolve, 500));
26
+ }
27
+ throw new Error("Local server did not start in time");
28
+ }
29
+
30
+ beforeAll(async () => {
31
+ server = spawn("python3", ["-m", "http.server", `${PORT}`, "--directory", "editions/free/src"], {
32
+ stdio: "ignore",
33
+ });
34
+ await waitForServer();
35
+ }, 30_000);
36
+
37
+ afterAll(() => {
38
+ if (server) {
39
+ server.kill();
40
+ }
41
+ });
42
+
43
+ async function openPage(pathname) {
44
+ const browser = await puppeteer.launch({
45
+ headless: true,
46
+ args: ["--no-sandbox"],
47
+ });
48
+ const page = await browser.newPage();
49
+ const errors = [];
50
+ const progressLogs = [];
51
+
52
+ page.on("pageerror", (err) => errors.push(err.message || String(err)));
53
+ page.on("console", (msg) => {
54
+ const text = msg.text();
55
+ if (text.indexOf("setProgress") > -1) {
56
+ progressLogs.push(text);
57
+ }
58
+ if (msg.type() === "error") {
59
+ errors.push(text);
60
+ }
61
+ });
62
+
63
+ await page.goto(`${HOST}${pathname}`, { waitUntil: "networkidle2", timeout: 30_000 });
64
+
65
+ return { browser, page, errors, progressLogs };
66
+ }
67
+
68
+ async function createProjectFromHome(page) {
69
+ await page.waitForSelector("#newproject .card-action-open", { timeout: 30_000 });
70
+ await Promise.all([
71
+ page.waitForNavigation({ waitUntil: "networkidle2", timeout: 30_000 }),
72
+ page.click("#newproject .card-action-open"),
73
+ ]);
74
+ await waitForEditorReady(page);
75
+ return page.evaluate(() => window.ScratchJr.currentProject);
76
+ }
77
+
78
+ async function waitForEditorReady(page, timeout = 30_000) {
79
+ await page.waitForFunction(
80
+ () => {
81
+ const backdrop = document.getElementById("backdrop");
82
+ return Boolean(
83
+ window.ScratchJr
84
+ && window.ScratchJr.stage
85
+ && window.ScratchJr.stage.currentPage
86
+ && document.getElementById("addExtensionButton")
87
+ && document.getElementById("martyMode")
88
+ && backdrop
89
+ && window.getComputedStyle(backdrop).display === "none"
90
+ && !window.ScratchJr.onHold
91
+ && window.ScratchJr.getActiveScript()
92
+ );
93
+ },
94
+ { timeout }
95
+ );
96
+ }
97
+
98
+ async function enableMartyMode(page) {
99
+ const alreadyEnabled = await page.evaluate(() => window.ScratchJr.isMartyModeEnabled);
100
+ if (!alreadyEnabled) {
101
+ await page.click("#martyMode");
102
+ }
103
+ await page.waitForFunction(
104
+ () => window.ScratchJr.isMartyModeEnabled
105
+ && window.ScratchJr.stage.currentPage.currentSpriteName
106
+ && window.ScratchJr.stage.currentPage.currentSpriteName.indexOf(window.ScratchJr.BIRDS_EYE_SPRITE_NAME) > -1
107
+ && window.ScratchJr.getActiveScript(),
108
+ { timeout: 30_000 }
109
+ );
110
+ }
111
+
112
+ async function enableMicroBitExtension(page) {
113
+ await page.$eval("#addExtensionButton", (button) => {
114
+ if (!button.disabled || window.getComputedStyle(button).display !== "none") {
115
+ throw new Error("Expected the extensions entry point to remain hidden and disabled");
116
+ }
117
+ button.onclick();
118
+ });
119
+ await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
120
+ await page.click("#microBitExtensionCard");
121
+ await page.waitForFunction(
122
+ () => {
123
+ const microBitButton = document.getElementById("microBitConnectionButton");
124
+ const library = document.getElementById("extensionsLibrary");
125
+ return window.ScratchJr.isMicroBitExtensionEnabled
126
+ && microBitButton
127
+ && window.getComputedStyle(microBitButton).display !== "none"
128
+ && document.querySelector("#selectorsright #microbit-start")
129
+ && document.querySelector("#selectorsright #microbit-looks")
130
+ && library
131
+ && !library.classList.contains("in");
132
+ },
133
+ { timeout: 30_000 }
134
+ );
135
+ }
136
+
137
+ async function insertMicroBitBlock(page) {
138
+ await page.click("#microbit-looks");
139
+ await page.waitForFunction(
140
+ (blockType) => Boolean(document.querySelector(`#palette [data-blocktype="${blockType}"]`)),
141
+ { timeout: 30_000 },
142
+ MICROBIT_BLOCK_TYPE
143
+ );
144
+
145
+ await page.evaluate((blockType) => {
146
+ const blockElement = document.querySelector(`#palette [data-blocktype="${blockType}"]`);
147
+ window.Palette.insertBlockFromKeyboard(blockElement, new Event("progress-loop-test"));
148
+ }, MICROBIT_BLOCK_TYPE);
149
+
150
+ await page.waitForFunction(
151
+ (blockType) => window.ScratchJr.getBlocks().some((block) => block.blocktype === blockType),
152
+ { timeout: 30_000 },
153
+ MICROBIT_BLOCK_TYPE
154
+ );
155
+ }
156
+
157
+ async function saveProjectAndWaitForBlock(page, projectId) {
158
+ await page.evaluate(() => new Promise((resolve) => {
159
+ window.ScratchJr.changed = true;
160
+ window.ScratchJr.saveProject(null, resolve);
161
+ }));
162
+
163
+ await waitForPersistedProject(page, projectId, (project) => projectHasBlock(project, MICROBIT_BLOCK_TYPE));
164
+ }
165
+
166
+ async function waitForPersistedProject(page, projectId, predicate) {
167
+ const startedAt = Date.now();
168
+ let lastProject = null;
169
+
170
+ while (Date.now() - startedAt < 15_000) {
171
+ lastProject = await readPersistedProject(page, projectId);
172
+ if (lastProject && predicate(lastProject)) {
173
+ return lastProject;
174
+ }
175
+ await new Promise((resolve) => setTimeout(resolve, 250));
176
+ }
177
+
178
+ throw new Error(`Timed out waiting for saved project state. Last project: ${JSON.stringify(lastProject)}`);
179
+ }
180
+
181
+ async function readPersistedProject(page, projectId) {
182
+ return page.evaluate((id) => new Promise((resolve) => {
183
+ window.OS.query(
184
+ { stmt: "select * from projects where id = ?", values: [Number(id)] },
185
+ (result) => {
186
+ const rows = JSON.parse(result);
187
+ if (!rows.length) {
188
+ resolve(null);
189
+ return;
190
+ }
191
+ const row = Object.fromEntries(Object.entries(rows[0]).map(([key, value]) => [key.toLowerCase(), value]));
192
+ resolve(typeof row.json === "string" ? JSON.parse(row.json) : row.json);
193
+ }
194
+ );
195
+ }), projectId);
196
+ }
197
+
198
+ function projectHasBlock(project, blockType) {
199
+ if (!project || !Array.isArray(project.pages)) {
200
+ return false;
201
+ }
202
+ return project.pages.some((pageId) => {
203
+ const page = project[pageId];
204
+ return page && Array.isArray(page.sprites) && page.sprites.some((spriteId) => {
205
+ const sprite = page[spriteId];
206
+ return sprite && Array.isArray(sprite.scripts) && sprite.scripts.some((strip) => {
207
+ return Array.isArray(strip) && strip.some((block) => Array.isArray(block) && block[0] === blockType);
208
+ });
209
+ });
210
+ });
211
+ }
212
+
213
+ async function hardReload(page) {
214
+ const client = await page.target().createCDPSession();
215
+ await client.send("Network.enable");
216
+ await client.send("Network.setCacheDisabled", { cacheDisabled: true });
217
+ try {
218
+ await page.reload({ waitUntil: "networkidle2", timeout: 30_000 });
219
+ } finally {
220
+ await client.send("Network.setCacheDisabled", { cacheDisabled: false });
221
+ await client.detach();
222
+ }
223
+ }
224
+
225
+ async function waitForLoadCurtainState(page, progressLogs, timeout = 12_000) {
226
+ const startedAt = Date.now();
227
+ let state = null;
228
+
229
+ while (Date.now() - startedAt < timeout) {
230
+ state = await getLoadCurtainState(page, progressLogs);
231
+ if (state.backdropDisplay === "none") {
232
+ return state;
233
+ }
234
+ await new Promise((resolve) => setTimeout(resolve, 250));
235
+ }
236
+
237
+ return getLoadCurtainState(page, progressLogs);
238
+ }
239
+
240
+ async function getLoadCurtainState(page, progressLogs) {
241
+ const state = await page.evaluate(() => {
242
+ const backdrop = document.getElementById("backdrop");
243
+ const currentSpriteName = window.ScratchJr?.stage?.currentPage?.currentSpriteName || "";
244
+ let activeBlocks = [];
245
+ try {
246
+ if (window.ScratchJr?.stage?.currentPage?.currentSpriteName && window.ScratchJr.getActiveScript()) {
247
+ activeBlocks = window.ScratchJr.getBlocks().map((block) => block.blocktype);
248
+ }
249
+ } catch (e) {
250
+ activeBlocks = [];
251
+ }
252
+
253
+ return {
254
+ backdropDisplay: backdrop ? window.getComputedStyle(backdrop).display : null,
255
+ backdropClassName: backdrop ? backdrop.className : null,
256
+ currentProject: window.ScratchJr?.currentProject || null,
257
+ isMartyModeEnabled: window.ScratchJr?.isMartyModeEnabled || false,
258
+ isMicroBitExtensionEnabled: window.ScratchJr?.isMicroBitExtensionEnabled || false,
259
+ activeSpriteIsMartyBirdsEye:
260
+ Boolean(window.ScratchJr) && currentSpriteName.indexOf(window.ScratchJr.BIRDS_EYE_SPRITE_NAME) > -1,
261
+ hasMicroBitBlockInActiveScript: activeBlocks.indexOf("microbitdisplayclear") > -1,
262
+ };
263
+ });
264
+
265
+ return {
266
+ ...state,
267
+ stuckProgressLoopCount: progressLogs.filter((log) => log.indexOf(STUCK_PROGRESS_LOG) > -1).length,
268
+ progressTail: progressLogs.slice(-12),
269
+ };
270
+ }
271
+
272
+ describe("project load progress loop", () => {
273
+ it(
274
+ "lifts the loading curtain after a hard refresh of a saved Marty micro:bit project",
275
+ async () => {
276
+ const { browser, page, errors, progressLogs } = await openPage("/home.html?place=home");
277
+
278
+ try {
279
+ const projectId = await createProjectFromHome(page);
280
+
281
+ await enableMartyMode(page);
282
+ await enableMicroBitExtension(page);
283
+ await insertMicroBitBlock(page);
284
+ await saveProjectAndWaitForBlock(page, projectId);
285
+
286
+ await page.reload({ waitUntil: "networkidle2", timeout: 30_000 });
287
+ await waitForEditorReady(page);
288
+
289
+ await hardReload(page);
290
+ const state = await waitForLoadCurtainState(page, progressLogs);
291
+
292
+ expect(
293
+ state.backdropDisplay,
294
+ `Load curtain state after hard refresh: ${JSON.stringify(state, null, 2)}`
295
+ ).toBe("none");
296
+ expect(state).toMatchObject({
297
+ currentProject: projectId,
298
+ isMartyModeEnabled: true,
299
+ isMicroBitExtensionEnabled: true,
300
+ activeSpriteIsMartyBirdsEye: true,
301
+ hasMicroBitBlockInActiveScript: true,
302
+ });
303
+ expect(errors).toEqual([]);
304
+ } finally {
305
+ await browser.close();
306
+ }
307
+ },
308
+ 120_000
309
+ );
310
+ });