@robotical/martyblocksjr 4.2.0 → 4.2.2

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 (49) hide show
  1. package/editions/free/src/app.bundle.js +1 -1
  2. package/editions/free/src/assets/ui/viewOnCompact.png +0 -0
  3. package/editions/free/src/assets/ui/viewOnCompactWideLabel.png +0 -0
  4. package/editions/free/src/assets/ui/viewOnTargetCompact.png +0 -0
  5. package/editions/free/src/css/editor.css +96 -0
  6. package/editions/free/src/css/editorleftpanel.css +21 -22
  7. package/editions/free/src/css/editorstage.css +62 -0
  8. package/editions/free/src/css/paintlook.css +23 -0
  9. package/editions/free/src/localizations/bg.json +1 -0
  10. package/editions/free/src/localizations/ca.json +1 -0
  11. package/editions/free/src/localizations/cs.json +1 -0
  12. package/editions/free/src/localizations/cy.json +1 -0
  13. package/editions/free/src/localizations/da.json +1 -0
  14. package/editions/free/src/localizations/de.json +1 -0
  15. package/editions/free/src/localizations/el.json +1 -0
  16. package/editions/free/src/localizations/en.json +1 -0
  17. package/editions/free/src/localizations/es.json +1 -0
  18. package/editions/free/src/localizations/fi.json +1 -0
  19. package/editions/free/src/localizations/fr.json +1 -0
  20. package/editions/free/src/localizations/it.json +1 -0
  21. package/editions/free/src/localizations/ja.json +1 -0
  22. package/editions/free/src/localizations/ko.json +1 -0
  23. package/editions/free/src/localizations/nl.json +1 -0
  24. package/editions/free/src/localizations/no.json +1 -0
  25. package/editions/free/src/localizations/pl.json +1 -0
  26. package/editions/free/src/localizations/pt-br.json +1 -0
  27. package/editions/free/src/localizations/pt.json +1 -0
  28. package/editions/free/src/localizations/sv.json +1 -0
  29. package/editions/free/src/localizations/th.json +1 -0
  30. package/editions/free/src/localizations/tr.json +1 -0
  31. package/editions/free/src/localizations/uk.json +1 -0
  32. package/editions/free/src/localizations/zh-cn.json +1 -0
  33. package/editions/free/src/localizations/zh-tw.json +1 -0
  34. package/editions/free/src/media.json +3 -0
  35. package/package.json +1 -1
  36. package/tests/e2e/accessibility.e2e.test.js +19 -2
  37. package/tests/e2e/animated-sprite-paint-disabled.e2e.test.js +358 -0
  38. package/tests/e2e/background-paint-active-selection.e2e.test.js +162 -0
  39. package/tests/e2e/blocks-jr-hidden-blocks.e2e.test.js +262 -0
  40. package/tests/e2e/chromium-79-smoke.test.js +19 -10
  41. package/tests/e2e/delete-last-sprite-mode.e2e.test.js +162 -0
  42. package/tests/e2e/duplicate-page.e2e.test.js +414 -0
  43. package/tests/e2e/marty-script-activation-paths.e2e.test.js +593 -0
  44. package/tests/e2e/microbit-extension-project-reload.e2e.test.js +6 -1
  45. package/tests/e2e/paint-editor-cancel.e2e.test.js +274 -0
  46. package/tests/e2e/project-load-progress-loop.e2e.test.js +6 -1
  47. package/tests/e2e/project-save-stall.e2e.test.js +230 -0
  48. package/tests/e2e/sprite-watermark-grayscale.e2e.test.js +269 -0
  49. package/tests/e2e/top-toolbar-tooltips.e2e.test.js +152 -0
@@ -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
+ });
@@ -110,7 +110,12 @@ async function enableMartyMode(page) {
110
110
  }
111
111
 
112
112
  async function enableMicroBitExtension(page) {
113
- await page.click("#addExtensionButton");
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
+ });
114
119
  await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
115
120
  await page.click("#microBitExtensionCard");
116
121
  await page.waitForFunction(
@@ -0,0 +1,230 @@
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 = 3015;
7
+ const HOST = `http://localhost:${PORT}`;
8
+ const BLOCK_TYPE = "back";
9
+
10
+ let server;
11
+
12
+ async function waitForServer(maxAttempts = 20) {
13
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
14
+ const ok = await new Promise((resolve) => {
15
+ const req = http.get(`${HOST}/`, (res) => {
16
+ res.destroy();
17
+ resolve(true);
18
+ });
19
+ req.on("error", () => resolve(false));
20
+ });
21
+ if (ok) {
22
+ return;
23
+ }
24
+ await new Promise((resolve) => setTimeout(resolve, 500));
25
+ }
26
+ throw new Error("Local server did not start in time");
27
+ }
28
+
29
+ beforeAll(async () => {
30
+ server = spawn("python3", ["-m", "http.server", `${PORT}`, "--directory", "editions/free/src"], {
31
+ stdio: "ignore",
32
+ });
33
+ await waitForServer();
34
+ }, 30_000);
35
+
36
+ afterAll(() => {
37
+ if (server) {
38
+ server.kill();
39
+ }
40
+ });
41
+
42
+ async function openPage(pathname) {
43
+ const browser = await puppeteer.launch({
44
+ headless: true,
45
+ args: ["--no-sandbox"],
46
+ });
47
+ const page = await browser.newPage();
48
+ const errors = [];
49
+
50
+ page.on("pageerror", (err) => errors.push(err.message || String(err)));
51
+ page.on("console", (msg) => {
52
+ if (msg.type() === "error") {
53
+ errors.push(msg.text());
54
+ }
55
+ });
56
+
57
+ await page.goto(`${HOST}${pathname}`, { waitUntil: "networkidle2", timeout: 30_000 });
58
+
59
+ return { browser, page, errors };
60
+ }
61
+
62
+ async function createProjectFromHome(page) {
63
+ await page.waitForSelector("#newproject .card-action-open", { timeout: 30_000 });
64
+ await Promise.all([
65
+ page.waitForNavigation({ waitUntil: "networkidle2", timeout: 30_000 }),
66
+ page.click("#newproject .card-action-open"),
67
+ ]);
68
+ await waitForEditorReady(page);
69
+ return page.evaluate(() => window.ScratchJr.currentProject);
70
+ }
71
+
72
+ async function waitForEditorReady(page, timeout = 30_000) {
73
+ await page.waitForFunction(
74
+ () => {
75
+ const backdrop = document.getElementById("backdrop");
76
+ return Boolean(
77
+ window.ScratchJr
78
+ && window.ScratchJr.stage
79
+ && window.ScratchJr.stage.currentPage
80
+ && document.getElementById("sprite-motion")
81
+ && backdrop
82
+ && window.getComputedStyle(backdrop).display === "none"
83
+ && !window.ScratchJr.onHold
84
+ && window.ScratchJr.getActiveScript()
85
+ );
86
+ },
87
+ { timeout }
88
+ );
89
+ }
90
+
91
+ async function insertMotionBlock(page, blockType = BLOCK_TYPE) {
92
+ await page.click("#sprite-motion");
93
+ await page.waitForFunction(
94
+ (type) => Boolean(document.querySelector(`#palette [data-blocktype="${type}"]`)),
95
+ { timeout: 30_000 },
96
+ blockType
97
+ );
98
+
99
+ await page.evaluate((type) => {
100
+ const blockElement = document.querySelector(`#palette [data-blocktype="${type}"]`);
101
+ window.Palette.insertBlockFromKeyboard(blockElement, new Event("project-save-stall-test"));
102
+ }, blockType);
103
+
104
+ await page.waitForFunction(
105
+ (type) => window.ScratchJr.getBlocks().some((block) => block.blocktype === type),
106
+ { timeout: 30_000 },
107
+ blockType
108
+ );
109
+ }
110
+
111
+ async function interruptNextThumbnailSave(page) {
112
+ await page.evaluate(() => {
113
+ const originalSetMediaName = window.OS.setmedianame;
114
+ window.__projectSaveStallTest = {
115
+ droppedThumbnailSave: false,
116
+ restore() {
117
+ window.OS.setmedianame = originalSetMediaName;
118
+ },
119
+ };
120
+ window.OS.setmedianame = function (str, name, ext, callback) {
121
+ if (!window.__projectSaveStallTest.droppedThumbnailSave && ext === "png") {
122
+ window.__projectSaveStallTest.droppedThumbnailSave = true;
123
+ return undefined;
124
+ }
125
+ return originalSetMediaName.call(this, str, name, ext, callback);
126
+ };
127
+ });
128
+ }
129
+
130
+ async function restoreThumbnailSave(page) {
131
+ await page.evaluate(() => {
132
+ if (window.__projectSaveStallTest) {
133
+ window.__projectSaveStallTest.restore();
134
+ }
135
+ });
136
+ }
137
+
138
+ async function saveProjectWithTimeout(page, timeoutMs = 4_000) {
139
+ return page.evaluate((timeout) => new Promise((resolve) => {
140
+ let settled = false;
141
+ window.ScratchJr.changed = true;
142
+ window.ScratchJr.saveProject(null, () => {
143
+ if (!settled) {
144
+ settled = true;
145
+ resolve(true);
146
+ }
147
+ });
148
+ window.setTimeout(() => {
149
+ if (!settled) {
150
+ settled = true;
151
+ resolve(false);
152
+ }
153
+ }, timeout);
154
+ }), timeoutMs);
155
+ }
156
+
157
+ async function waitForPersistedProject(page, projectId, predicate) {
158
+ const startedAt = Date.now();
159
+ let lastProject = null;
160
+
161
+ while (Date.now() - startedAt < 15_000) {
162
+ lastProject = await readPersistedProject(page, projectId);
163
+ if (lastProject && predicate(lastProject)) {
164
+ return lastProject;
165
+ }
166
+ await new Promise((resolve) => setTimeout(resolve, 250));
167
+ }
168
+
169
+ throw new Error(`Timed out waiting for saved project state. Last project: ${JSON.stringify(lastProject)}`);
170
+ }
171
+
172
+ async function readPersistedProject(page, projectId) {
173
+ return page.evaluate((id) => new Promise((resolve) => {
174
+ window.OS.query(
175
+ { stmt: "select * from projects where id = ?", values: [Number(id)] },
176
+ (result) => {
177
+ const rows = JSON.parse(result);
178
+ if (!rows.length) {
179
+ resolve(null);
180
+ return;
181
+ }
182
+ const row = Object.fromEntries(Object.entries(rows[0]).map(([key, value]) => [key.toLowerCase(), value]));
183
+ resolve(typeof row.json === "string" ? JSON.parse(row.json) : row.json);
184
+ }
185
+ );
186
+ }), projectId);
187
+ }
188
+
189
+ function projectHasBlock(project, blockType) {
190
+ if (!project || !Array.isArray(project.pages)) {
191
+ return false;
192
+ }
193
+ return project.pages.some((pageId) => {
194
+ const page = project[pageId];
195
+ return page && Array.isArray(page.sprites) && page.sprites.some((spriteId) => {
196
+ const sprite = page[spriteId];
197
+ return sprite && Array.isArray(sprite.scripts) && sprite.scripts.some((strip) => {
198
+ return Array.isArray(strip) && strip.some((block) => Array.isArray(block) && block[0] === blockType);
199
+ });
200
+ });
201
+ });
202
+ }
203
+
204
+ describe("project save stall reproduction", () => {
205
+ it.fails(
206
+ "recovers local save/autosave after one interrupted thumbnail save",
207
+ async () => {
208
+ const { browser, page, errors } = await openPage("/home.html?place=home");
209
+
210
+ try {
211
+ const projectId = await createProjectFromHome(page);
212
+ await insertMotionBlock(page);
213
+
214
+ await interruptNextThumbnailSave(page);
215
+ const interruptedSaveCompleted = await saveProjectWithTimeout(page, 1_500);
216
+ expect(interruptedSaveCompleted).toBe(false);
217
+
218
+ await restoreThumbnailSave(page);
219
+ const recoveredSaveCompleted = await saveProjectWithTimeout(page, 4_000);
220
+
221
+ expect(recoveredSaveCompleted).toBe(true);
222
+ await waitForPersistedProject(page, projectId, (project) => projectHasBlock(project, BLOCK_TYPE));
223
+ expect(errors).toEqual([]);
224
+ } finally {
225
+ await browser.close();
226
+ }
227
+ },
228
+ 120_000
229
+ );
230
+ });