@robotical/martyblocksjr 4.2.0 → 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 (48) 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/viewOnTargetCompact.png +0 -0
  4. package/editions/free/src/css/editor.css +91 -0
  5. package/editions/free/src/css/editorleftpanel.css +16 -17
  6. package/editions/free/src/css/editorstage.css +62 -0
  7. package/editions/free/src/css/paintlook.css +23 -0
  8. package/editions/free/src/localizations/bg.json +1 -0
  9. package/editions/free/src/localizations/ca.json +1 -0
  10. package/editions/free/src/localizations/cs.json +1 -0
  11. package/editions/free/src/localizations/cy.json +1 -0
  12. package/editions/free/src/localizations/da.json +1 -0
  13. package/editions/free/src/localizations/de.json +1 -0
  14. package/editions/free/src/localizations/el.json +1 -0
  15. package/editions/free/src/localizations/en.json +1 -0
  16. package/editions/free/src/localizations/es.json +1 -0
  17. package/editions/free/src/localizations/fi.json +1 -0
  18. package/editions/free/src/localizations/fr.json +1 -0
  19. package/editions/free/src/localizations/it.json +1 -0
  20. package/editions/free/src/localizations/ja.json +1 -0
  21. package/editions/free/src/localizations/ko.json +1 -0
  22. package/editions/free/src/localizations/nl.json +1 -0
  23. package/editions/free/src/localizations/no.json +1 -0
  24. package/editions/free/src/localizations/pl.json +1 -0
  25. package/editions/free/src/localizations/pt-br.json +1 -0
  26. package/editions/free/src/localizations/pt.json +1 -0
  27. package/editions/free/src/localizations/sv.json +1 -0
  28. package/editions/free/src/localizations/th.json +1 -0
  29. package/editions/free/src/localizations/tr.json +1 -0
  30. package/editions/free/src/localizations/uk.json +1 -0
  31. package/editions/free/src/localizations/zh-cn.json +1 -0
  32. package/editions/free/src/localizations/zh-tw.json +1 -0
  33. package/editions/free/src/media.json +3 -0
  34. package/package.json +1 -1
  35. package/tests/e2e/accessibility.e2e.test.js +18 -2
  36. package/tests/e2e/animated-sprite-paint-disabled.e2e.test.js +270 -0
  37. package/tests/e2e/background-paint-active-selection.e2e.test.js +162 -0
  38. package/tests/e2e/blocks-jr-hidden-blocks.e2e.test.js +262 -0
  39. package/tests/e2e/chromium-79-smoke.test.js +19 -10
  40. package/tests/e2e/delete-last-sprite-mode.e2e.test.js +162 -0
  41. package/tests/e2e/duplicate-page.e2e.test.js +414 -0
  42. package/tests/e2e/marty-script-activation-paths.e2e.test.js +522 -0
  43. package/tests/e2e/microbit-extension-project-reload.e2e.test.js +6 -1
  44. package/tests/e2e/paint-editor-cancel.e2e.test.js +274 -0
  45. package/tests/e2e/project-load-progress-loop.e2e.test.js +6 -1
  46. package/tests/e2e/project-save-stall.e2e.test.js +230 -0
  47. package/tests/e2e/sprite-watermark-grayscale.e2e.test.js +269 -0
  48. package/tests/e2e/top-toolbar-tooltips.e2e.test.js +152 -0
@@ -108,6 +108,15 @@ async function assertTutorialOpened(page, tutorialId) {
108
108
  expect(tutorialState.tutorialTitle.length).toBeGreaterThan(0);
109
109
  }
110
110
 
111
+ async function openHiddenExtensionsLibrary(page) {
112
+ await page.$eval("#addExtensionButton", (button) => {
113
+ if (!button.disabled || window.getComputedStyle(button).display !== "none") {
114
+ throw new Error("Expected the extensions entry point to remain hidden and disabled");
115
+ }
116
+ button.onclick();
117
+ });
118
+ }
119
+
111
120
  describe("Chromium 79 smoke test", () => {
112
121
  it(
113
122
  "loads home page without console errors",
@@ -123,7 +132,7 @@ describe("Chromium 79 smoke test", () => {
123
132
  );
124
133
 
125
134
  it(
126
- "hides micro:bit controls until the extension is added",
135
+ "keeps the extension entry hidden and reveals micro:bit controls only while loaded",
127
136
  async () => {
128
137
  const { browser, page, errors } = await openPage("/editor.html?mode=edit");
129
138
 
@@ -148,9 +157,9 @@ describe("Chromium 79 smoke test", () => {
148
157
  });
149
158
 
150
159
  expect(initialState).toEqual({
151
- addButtonDisplay: "flex",
152
- addButtonAriaHidden: "false",
153
- addButtonDisabled: false,
160
+ addButtonDisplay: "none",
161
+ addButtonAriaHidden: "true",
162
+ addButtonDisabled: true,
154
163
  microBitButtonDisplay: "none",
155
164
  microBitButtonAriaHidden: "true",
156
165
  microBitButtonDisabled: true,
@@ -158,7 +167,7 @@ describe("Chromium 79 smoke test", () => {
158
167
  extensionEnabled: false,
159
168
  });
160
169
 
161
- await page.click("#addExtensionButton");
170
+ await openHiddenExtensionsLibrary(page);
162
171
  await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
163
172
  const libraryState = await page.evaluate(() => ({
164
173
  title: document.getElementById("extensionsLibraryTitle").textContent.trim(),
@@ -212,9 +221,9 @@ describe("Chromium 79 smoke test", () => {
212
221
  });
213
222
 
214
223
  expect(enabledState).toEqual({
215
- addButtonDisplay: "flex",
216
- addButtonAriaHidden: "false",
217
- addButtonDisabled: false,
224
+ addButtonDisplay: "none",
225
+ addButtonAriaHidden: "true",
226
+ addButtonDisabled: true,
218
227
  microBitButtonDisplay: "flex",
219
228
  microBitButtonAriaHidden: "false",
220
229
  microBitButtonDisabled: false,
@@ -281,7 +290,7 @@ describe("Chromium 79 smoke test", () => {
281
290
  });
282
291
  expect(blocksAfterInsert).toContain("microbitdisplayclear");
283
292
 
284
- await page.click("#addExtensionButton");
293
+ await openHiddenExtensionsLibrary(page);
285
294
  await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
286
295
  const loadedLibraryState = await page.evaluate(() => ({
287
296
  microBitCardAction: document.getElementById("microBitExtensionCardAction").textContent.trim(),
@@ -330,7 +339,7 @@ describe("Chromium 79 smoke test", () => {
330
339
  extensionEnabled: document.getElementById("connectionButtonsArea").classList.contains("extensionEnabled"),
331
340
  }));
332
341
  expect(unloadedState).toEqual({
333
- addButtonDisplay: "flex",
342
+ addButtonDisplay: "none",
334
343
  microBitButtonDisplay: "none",
335
344
  extensionsLibraryOpen: false,
336
345
  microBitCardAction: "+",
@@ -0,0 +1,162 @@
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
+
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
+ }, 30000);
34
+
35
+ afterAll(() => {
36
+ if (server) {
37
+ server.kill();
38
+ }
39
+ });
40
+
41
+ async function openPage(pathname) {
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}${pathname}`, { waitUntil: "networkidle2", timeout: 30000 });
57
+
58
+ return { browser, page, errors };
59
+ }
60
+
61
+ async function waitForEditorReady(page) {
62
+ await page.waitForFunction(
63
+ () => {
64
+ const backdrop = document.getElementById("backdrop");
65
+ const selectedSpriteThumb = document.querySelector("#spritecc .spritethumb[aria-pressed=\"true\"]");
66
+ return Boolean(
67
+ window.ScratchJr
68
+ && window.ScratchJr.stage
69
+ && window.ScratchJr.stage.currentPage
70
+ && document.getElementById("addsprite")
71
+ && document.getElementById("martyMode")
72
+ && selectedSpriteThumb
73
+ && backdrop
74
+ && window.getComputedStyle(backdrop).display === "none"
75
+ && !window.ScratchJr.onHold
76
+ && window.ScratchJr.getActiveScript()
77
+ );
78
+ },
79
+ { timeout: 30000 }
80
+ );
81
+ }
82
+
83
+ async function getEditorState(page) {
84
+ return page.evaluate(() => {
85
+ const currentPage = window.ScratchJr.stage.currentPage;
86
+ const spriteIds = currentPage.getSprites();
87
+ const normalSpriteIds = spriteIds.filter(
88
+ (spriteId) => spriteId.indexOf(window.ScratchJr.BIRDS_EYE_SPRITE_NAME) === -1
89
+ );
90
+ const currentSpriteName = currentPage.currentSpriteName || "";
91
+ const addSpriteButton = document.getElementById("addsprite");
92
+
93
+ return {
94
+ currentSpriteName,
95
+ spriteIds,
96
+ normalSpriteIds,
97
+ isMartyModeEnabled: window.ScratchJr.isMartyModeEnabled,
98
+ activeSpriteIsMartyBirdsEye:
99
+ currentSpriteName.indexOf(window.ScratchJr.BIRDS_EYE_SPRITE_NAME) > -1,
100
+ hasMartyModeSidebarCard: Boolean(document.getElementById("martyModeSidebarCard")),
101
+ addSpriteButtonVisible:
102
+ Boolean(addSpriteButton) && window.getComputedStyle(addSpriteButton).display !== "none",
103
+ addSpriteButtonDisabled: Boolean(addSpriteButton && addSpriteButton.disabled),
104
+ };
105
+ });
106
+ }
107
+
108
+ async function deleteSelectedSpriteWithKeyboard(page) {
109
+ await page.focus("#spritecc .spritethumb[aria-pressed=\"true\"]");
110
+ await page.keyboard.press("Delete");
111
+ await page.waitForFunction(
112
+ () => {
113
+ const currentPage = window.ScratchJr.stage.currentPage;
114
+ return currentPage.getSprites().filter(
115
+ (spriteId) => spriteId.indexOf(window.ScratchJr.BIRDS_EYE_SPRITE_NAME) === -1
116
+ ).length === 0;
117
+ },
118
+ { timeout: 30000 }
119
+ );
120
+ }
121
+
122
+ describe("deleting the final sprite", () => {
123
+ it(
124
+ "keeps the editor in Sprite mode when no normal sprites remain",
125
+ async () => {
126
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
127
+
128
+ try {
129
+ await waitForEditorReady(page);
130
+
131
+ const beforeDelete = await getEditorState(page);
132
+ expect(beforeDelete).toMatchObject({
133
+ normalSpriteIds: [beforeDelete.currentSpriteName],
134
+ isMartyModeEnabled: false,
135
+ activeSpriteIsMartyBirdsEye: false,
136
+ hasMartyModeSidebarCard: false,
137
+ addSpriteButtonVisible: true,
138
+ addSpriteButtonDisabled: false,
139
+ });
140
+
141
+ await deleteSelectedSpriteWithKeyboard(page);
142
+
143
+ const afterDelete = await getEditorState(page);
144
+ expect(
145
+ afterDelete,
146
+ `Editor state after deleting the last normal sprite: ${JSON.stringify(afterDelete, null, 2)}`
147
+ ).toMatchObject({
148
+ normalSpriteIds: [],
149
+ isMartyModeEnabled: false,
150
+ activeSpriteIsMartyBirdsEye: false,
151
+ hasMartyModeSidebarCard: false,
152
+ addSpriteButtonVisible: true,
153
+ addSpriteButtonDisabled: false,
154
+ });
155
+ expect(errors).toEqual([]);
156
+ } finally {
157
+ await browser.close();
158
+ }
159
+ },
160
+ 90000
161
+ );
162
+ });
@@ -0,0 +1,414 @@
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 BACKGROUND_MD5 = "Beach.svg";
9
+ const SCRIPT_BLOCK_TYPE = "forward";
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
+
51
+ page.on("pageerror", (err) => errors.push(err.message || String(err)));
52
+ page.on("console", (msg) => {
53
+ if (msg.type() === "error") {
54
+ errors.push(msg.text());
55
+ }
56
+ });
57
+
58
+ await page.goto(`${HOST}${pathname}`, { waitUntil: "networkidle2", timeout: 30_000 });
59
+
60
+ return { browser, page, errors };
61
+ }
62
+
63
+ async function waitForEditorReady(page) {
64
+ await page.waitForFunction(
65
+ () => {
66
+ const backdrop = document.getElementById("backdrop");
67
+ return Boolean(
68
+ window.ScratchJr
69
+ && window.ScratchJr.stage
70
+ && window.ScratchJr.stage.currentPage
71
+ && document.getElementById("pagecc")
72
+ && document.getElementById("sprite-motion")
73
+ && backdrop
74
+ && window.getComputedStyle(backdrop).display === "none"
75
+ && !window.ScratchJr.onHold
76
+ && window.ScratchJr.getActiveScript()
77
+ );
78
+ },
79
+ { timeout: 30_000 }
80
+ );
81
+ }
82
+
83
+ async function prepareSourcePage(page) {
84
+ await page.evaluate((backgroundMd5) => new Promise((resolve) => {
85
+ const currentPage = window.ScratchJr.stage.currentPage;
86
+ currentPage.setBackground(backgroundMd5, () => {
87
+ currentPage.updateThumb();
88
+ resolve();
89
+ });
90
+ }), BACKGROUND_MD5);
91
+
92
+ await page.click("#sprite-motion");
93
+ await page.waitForFunction(
94
+ (blockType) => Boolean(document.querySelector(`#palette [data-blocktype="${blockType}"]`)),
95
+ { timeout: 30_000 },
96
+ SCRIPT_BLOCK_TYPE
97
+ );
98
+
99
+ await page.evaluate((blockType) => {
100
+ const blockElement = document.querySelector(`#palette [data-blocktype="${blockType}"]`);
101
+ window.Palette.insertBlockFromKeyboard(blockElement, new Event("duplicate-page-e2e"));
102
+ }, SCRIPT_BLOCK_TYPE);
103
+
104
+ await page.waitForFunction(
105
+ (blockType) => window.ScratchJr.getBlocks().some((block) => block.blocktype === blockType),
106
+ { timeout: 30_000 },
107
+ SCRIPT_BLOCK_TYPE
108
+ );
109
+ }
110
+
111
+ async function activateDuplicatePageAction(page, requestedPageId) {
112
+ const result = await page.evaluate((pageId) => {
113
+ const targetPageId = pageId || window.ScratchJr.stage.currentPage.id;
114
+ const action = document.querySelector(`#pageactions .duplicatepage[data-owner="${targetPageId}"]`);
115
+
116
+ if (!action) {
117
+ return {
118
+ activated: false,
119
+ pageActions: Array.from(document.querySelectorAll("#pageactions .duplicatepage")).map((element) => ({
120
+ id: element.id || null,
121
+ owner: element.getAttribute("data-owner"),
122
+ label: element.getAttribute("aria-label") || element.getAttribute("title") || element.textContent || "",
123
+ })),
124
+ };
125
+ }
126
+
127
+ if (action.disabled || action.getAttribute("aria-disabled") === "true") {
128
+ return { activated: false, disabled: true };
129
+ }
130
+
131
+ action.dispatchEvent(new MouseEvent("click", {
132
+ bubbles: true,
133
+ cancelable: true,
134
+ view: window,
135
+ }));
136
+ return { activated: true };
137
+ }, requestedPageId);
138
+
139
+ if (!result.activated) {
140
+ throw new Error(`No enabled accessible duplicate-page action found in the page strip: ${JSON.stringify(result)}`);
141
+ }
142
+ }
143
+
144
+ function pageHasBlock(pageData, blockType) {
145
+ return Boolean(pageData && Array.isArray(pageData.sprites) && pageData.sprites.some((spriteId) => {
146
+ const sprite = pageData[spriteId];
147
+ return sprite && Array.isArray(sprite.scripts) && sprite.scripts.some((strip) => {
148
+ return Array.isArray(strip) && strip.some((block) => Array.isArray(block) && block[0] === blockType);
149
+ });
150
+ }));
151
+ }
152
+
153
+ async function waitForPageDuplication(page, beforeCount) {
154
+ await page.waitForFunction(
155
+ (count) => window.ScratchJr.stage.pages.length === count + 1
156
+ && !window.ScratchJr.stage.duplicatingPage,
157
+ { timeout: 30_000 },
158
+ beforeCount
159
+ );
160
+ }
161
+
162
+ describe("duplicate page", () => {
163
+ it(
164
+ "duplicates the current page with its scripts and background from the page strip",
165
+ async () => {
166
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
167
+
168
+ try {
169
+ await waitForEditorReady(page);
170
+ await prepareSourcePage(page);
171
+
172
+ const sourceState = await page.evaluate(() => {
173
+ const sourcePage = window.ScratchJr.stage.currentPage;
174
+ return {
175
+ pageId: sourcePage.id,
176
+ pageData: sourcePage.encodePage(),
177
+ pageCount: window.ScratchJr.stage.pages.length,
178
+ };
179
+ });
180
+ expect(sourceState.pageData.md5).toBe(BACKGROUND_MD5);
181
+ expect(pageHasBlock(sourceState.pageData, SCRIPT_BLOCK_TYPE)).toBe(true);
182
+
183
+ const pageActionState = await page.evaluate((sourcePageId) => {
184
+ const action = document.querySelector(`#pageactions .duplicatepage[data-owner="${sourcePageId}"]`);
185
+ return {
186
+ actionCount: document.querySelectorAll("#pageactions .duplicatepage").length,
187
+ isNestedInPageThumb: Boolean(action && action.closest(".pagethumb")),
188
+ label: action && action.getAttribute("aria-label"),
189
+ };
190
+ }, sourceState.pageId);
191
+ expect(pageActionState).toEqual({
192
+ actionCount: sourceState.pageCount,
193
+ isNestedInPageThumb: false,
194
+ label: "Duplicate page 1",
195
+ });
196
+
197
+ await activateDuplicatePageAction(page, sourceState.pageId);
198
+
199
+ await page.waitForFunction(
200
+ (beforeCount, sourcePageId) => window.ScratchJr.stage.pages.length === beforeCount + 1
201
+ && !window.ScratchJr.stage.duplicatingPage
202
+ && window.ScratchJr.stage.currentPage.id !== sourcePageId,
203
+ { timeout: 30_000 },
204
+ sourceState.pageCount,
205
+ sourceState.pageId
206
+ );
207
+
208
+ const duplicatedState = await page.evaluate((sourcePageId) => {
209
+ const sourceIndex = window.ScratchJr.stage.pages.findIndex((stagePage) => stagePage.id === sourcePageId);
210
+ const duplicatePage = window.ScratchJr.stage.pages[sourceIndex + 1];
211
+ return {
212
+ duplicatePageId: duplicatePage ? duplicatePage.id : null,
213
+ duplicatePageData: duplicatePage ? duplicatePage.encodePage() : null,
214
+ pageIds: window.ScratchJr.stage.getPagesID(),
215
+ };
216
+ }, sourceState.pageId);
217
+
218
+ expect(duplicatedState.duplicatePageId).not.toBe(sourceState.pageId);
219
+ expect(duplicatedState.duplicatePageData.md5).toBe(BACKGROUND_MD5);
220
+ expect(pageHasBlock(duplicatedState.duplicatePageData, SCRIPT_BLOCK_TYPE)).toBe(true);
221
+ expect(duplicatedState.duplicatePageData.sprites).not.toEqual(sourceState.pageData.sprites);
222
+ expect(duplicatedState.pageIds).toHaveLength(sourceState.pageCount + 1);
223
+ expect(errors).toEqual([]);
224
+
225
+ await page.click("#id_undo");
226
+ await page.waitForFunction(
227
+ (duplicatePageId, beforeCount) => !document.getElementById(duplicatePageId)
228
+ && window.ScratchJr.stage.pages.length === beforeCount,
229
+ { timeout: 30_000 },
230
+ duplicatedState.duplicatePageId,
231
+ sourceState.pageCount
232
+ );
233
+
234
+ await page.click("#id_redo");
235
+ await page.waitForFunction(
236
+ (duplicatePageId, beforeCount) => document.getElementById(duplicatePageId)
237
+ && window.ScratchJr.stage.pages.length === beforeCount + 1,
238
+ { timeout: 30_000 },
239
+ duplicatedState.duplicatePageId,
240
+ sourceState.pageCount
241
+ );
242
+ const redonePageData = await page.evaluate((duplicatePageId) => {
243
+ return document.getElementById(duplicatePageId).owner.encodePage();
244
+ }, duplicatedState.duplicatePageId);
245
+ expect(redonePageData.md5).toBe(BACKGROUND_MD5);
246
+ expect(pageHasBlock(redonePageData, SCRIPT_BLOCK_TYPE)).toBe(true);
247
+ } finally {
248
+ await browser.close();
249
+ }
250
+ },
251
+ 60_000
252
+ );
253
+
254
+ it(
255
+ "respects the four-page project limit",
256
+ async () => {
257
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
258
+
259
+ try {
260
+ await waitForEditorReady(page);
261
+ for (let pageCount = 1; pageCount < 4; pageCount += 1) {
262
+ await activateDuplicatePageAction(page);
263
+ await waitForPageDuplication(page, pageCount);
264
+ }
265
+
266
+ const limitState = await page.evaluate(() => {
267
+ const duplicateButtons = Array.from(document.querySelectorAll("#pageactions .duplicatepage"));
268
+ return {
269
+ pageCount: window.ScratchJr.stage.pages.length,
270
+ buttonCount: duplicateButtons.length,
271
+ disabled: duplicateButtons.every((button) => button.disabled),
272
+ ariaDisabled: duplicateButtons.every((button) => button.getAttribute("aria-disabled") === "true"),
273
+ canDuplicate: window.ScratchJr.stage.canDuplicatePage(window.ScratchJr.stage.currentPage.id),
274
+ };
275
+ });
276
+ expect(limitState).toEqual({
277
+ pageCount: 4,
278
+ buttonCount: 4,
279
+ disabled: true,
280
+ ariaDisabled: true,
281
+ canDuplicate: false,
282
+ });
283
+ expect(errors).toEqual([]);
284
+ } finally {
285
+ await browser.close();
286
+ }
287
+ },
288
+ 60_000
289
+ );
290
+
291
+ it(
292
+ "duplicates the page attached to the clicked action when another page is selected",
293
+ async () => {
294
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
295
+
296
+ try {
297
+ await waitForEditorReady(page);
298
+ await prepareSourcePage(page);
299
+
300
+ const sourcePageId = await page.evaluate(() => window.ScratchJr.stage.currentPage.id);
301
+ await activateDuplicatePageAction(page, sourcePageId);
302
+ await waitForPageDuplication(page, 1);
303
+
304
+ const selectedPageId = await page.evaluate(() => new Promise((resolve) => {
305
+ const selectedPage = window.ScratchJr.stage.currentPage;
306
+ selectedPage.setBackground("none", () => {
307
+ selectedPage.updateThumb();
308
+ resolve(selectedPage.id);
309
+ });
310
+ }));
311
+
312
+ await activateDuplicatePageAction(page, sourcePageId);
313
+ await waitForPageDuplication(page, 2);
314
+
315
+ const targetedState = await page.evaluate(({ sourceId, previousSelectedId }) => {
316
+ const sourceIndex = window.ScratchJr.stage.pages.findIndex((stagePage) => stagePage.id === sourceId);
317
+ const targetedDuplicate = window.ScratchJr.stage.pages[sourceIndex + 1];
318
+ const previousSelectedIndex = window.ScratchJr.stage.pages.findIndex(
319
+ (stagePage) => stagePage.id === previousSelectedId
320
+ );
321
+ return {
322
+ currentPageId: window.ScratchJr.stage.currentPage.id,
323
+ targetedDuplicateId: targetedDuplicate.id,
324
+ targetedDuplicateData: targetedDuplicate.encodePage(),
325
+ previousSelectedIndex,
326
+ };
327
+ }, { sourceId: sourcePageId, previousSelectedId: selectedPageId });
328
+
329
+ expect(targetedState.currentPageId).toBe(targetedState.targetedDuplicateId);
330
+ expect(targetedState.targetedDuplicateId).not.toBe(selectedPageId);
331
+ expect(targetedState.targetedDuplicateData.md5).toBe(BACKGROUND_MD5);
332
+ expect(pageHasBlock(targetedState.targetedDuplicateData, SCRIPT_BLOCK_TYPE)).toBe(true);
333
+ expect(targetedState.previousSelectedIndex).toBe(2);
334
+ expect(errors).toEqual([]);
335
+ } finally {
336
+ await browser.close();
337
+ }
338
+ },
339
+ 60_000
340
+ );
341
+
342
+ it(
343
+ "always shows the duplicate glyph and hides the action during long-press delete mode",
344
+ async () => {
345
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
346
+
347
+ try {
348
+ await waitForEditorReady(page);
349
+ const pageId = await page.evaluate(() => window.ScratchJr.stage.currentPage.id);
350
+ await activateDuplicatePageAction(page, pageId);
351
+ await waitForPageDuplication(page, 1);
352
+ const restingState = await page.evaluate((currentPageId) => {
353
+ const action = document.querySelector(
354
+ `#pageactions .duplicatepage[data-owner="${currentPageId}"]`
355
+ );
356
+ const icon = action.querySelector(".duplicatepageicon");
357
+ const backSquare = window.getComputedStyle(icon, "::before");
358
+ const frontSquare = window.getComputedStyle(icon, "::after");
359
+ return {
360
+ actionVisibility: window.getComputedStyle(action).visibility,
361
+ backContent: backSquare.content,
362
+ frontContent: frontSquare.content,
363
+ backZIndex: backSquare.zIndex,
364
+ frontZIndex: frontSquare.zIndex,
365
+ };
366
+ }, pageId);
367
+
368
+ expect(restingState.actionVisibility).toBe("visible");
369
+ expect(restingState.backContent).not.toBe("none");
370
+ expect(restingState.frontContent).not.toBe("none");
371
+ expect(restingState.backZIndex).not.toBe("-1");
372
+ expect(restingState.frontZIndex).not.toBe("-1");
373
+
374
+ await page.evaluate((currentPageId) => new Promise((resolve) => {
375
+ const thumb = document.querySelector(`.pagethumb[data-owner="${currentPageId}"]`);
376
+ const bounds = thumb.getBoundingClientRect();
377
+ thumb.dispatchEvent(new PointerEvent("pointerdown", {
378
+ bubbles: true,
379
+ cancelable: true,
380
+ clientX: bounds.left + (bounds.width / 2),
381
+ clientY: bounds.top + (bounds.height / 2),
382
+ pointerId: 1,
383
+ pointerType: "mouse",
384
+ isPrimary: true,
385
+ }));
386
+ window.setTimeout(resolve, 600);
387
+ }), pageId);
388
+ await page.waitForFunction((currentPageId) => {
389
+ const thumb = document.querySelector(`.pagethumb[data-owner="${currentPageId}"]`);
390
+ const action = document.querySelector(
391
+ `#pageactions .duplicatepage[data-owner="${currentPageId}"]`
392
+ );
393
+ return thumb.classList.contains("shakeme")
394
+ && window.getComputedStyle(action).visibility === "hidden"
395
+ && window.getComputedStyle(thumb.querySelector(".deletethumb")).visibility === "visible";
396
+ }, { timeout: 5_000 }, pageId);
397
+
398
+ await page.evaluate(() => window.ScratchJr.clearSelection());
399
+ await page.waitForFunction((currentPageId) => {
400
+ const thumb = document.querySelector(`.pagethumb[data-owner="${currentPageId}"]`);
401
+ const action = document.querySelector(
402
+ `#pageactions .duplicatepage[data-owner="${currentPageId}"]`
403
+ );
404
+ return !thumb.classList.contains("shakeme")
405
+ && window.getComputedStyle(action).visibility === "visible";
406
+ }, { timeout: 5_000 }, pageId);
407
+ expect(errors).toEqual([]);
408
+ } finally {
409
+ await browser.close();
410
+ }
411
+ },
412
+ 60_000
413
+ );
414
+ });