@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,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
+ });
@@ -0,0 +1,269 @@
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 AFFECTED_SPRITES = [
9
+ { name: "Pink Octopus", md5: "Sprites_Octopus-Pink.svg" },
10
+ { name: "Raccoon", md5: "Sprites_Raccoon.svg" },
11
+ { name: "Unicorn", md5: "Sprites_Unicorn.svg" },
12
+ ];
13
+ const LEGACY_SPRITES = [
14
+ { name: "Dog", md5: "Dog.svg", expectsGrayscale: false },
15
+ { name: "Marty", md5: "Sprites_Marty.svg", expectsGrayscale: false },
16
+ { name: "Bee", md5: "BeeSprite1.svg", expectsGrayscale: true },
17
+ ];
18
+
19
+ let server;
20
+
21
+ async function waitForServer(maxAttempts = 20) {
22
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
23
+ const ok = await new Promise((resolve) => {
24
+ const req = http.get(`${HOST}/`, (res) => {
25
+ res.destroy();
26
+ resolve(true);
27
+ });
28
+ req.on("error", () => resolve(false));
29
+ });
30
+ if (ok) {
31
+ return;
32
+ }
33
+ await new Promise((resolve) => setTimeout(resolve, 500));
34
+ }
35
+ throw new Error("Local server did not start in time");
36
+ }
37
+
38
+ beforeAll(async () => {
39
+ server = spawn("python3", ["-m", "http.server", `${PORT}`, "--directory", "editions/free/src"], {
40
+ stdio: "ignore",
41
+ });
42
+ await waitForServer();
43
+ }, 30_000);
44
+
45
+ afterAll(() => {
46
+ if (server) {
47
+ server.kill();
48
+ }
49
+ });
50
+
51
+ async function openEditor() {
52
+ const browser = await puppeteer.launch({
53
+ headless: true,
54
+ args: ["--no-sandbox"],
55
+ });
56
+ const page = await browser.newPage();
57
+ const errors = [];
58
+
59
+ page.on("pageerror", (err) => errors.push(err.message || String(err)));
60
+ page.on("console", (msg) => {
61
+ if (msg.type() === "error") {
62
+ errors.push(msg.text());
63
+ }
64
+ });
65
+
66
+ await page.goto(`${HOST}/editor.html?mode=edit`, { waitUntil: "networkidle2", timeout: 30_000 });
67
+ await waitForEditorReady(page);
68
+
69
+ return { browser, page, errors };
70
+ }
71
+
72
+ async function waitForEditorReady(page) {
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.querySelector(".watermark")
81
+ && backdrop
82
+ && window.getComputedStyle(backdrop).display === "none"
83
+ && !window.ScratchJr.onHold
84
+ && window.ScratchJr.getActiveScript()
85
+ );
86
+ },
87
+ { timeout: 30_000 }
88
+ );
89
+ }
90
+
91
+ async function addSpriteAndWait(page, sprite) {
92
+ await page.evaluate((spriteMd5, spriteName) => {
93
+ window.ScratchJr.stage.currentPage.addSprite(0.5, spriteMd5, spriteName);
94
+ }, sprite.md5, sprite.name);
95
+
96
+ await page.waitForFunction(
97
+ (spriteMd5) => {
98
+ const activeSprite = window.ScratchJr && window.ScratchJr.getSprite();
99
+ const watermark = document.querySelector(".watermark img");
100
+ return Boolean(
101
+ activeSprite
102
+ && activeSprite.md5 === spriteMd5
103
+ && watermark
104
+ && watermark.complete
105
+ && (watermark.naturalWidth > 0 || watermark.width > 0)
106
+ );
107
+ },
108
+ { timeout: 30_000 },
109
+ sprite.md5
110
+ );
111
+ }
112
+
113
+ async function measureGrayscaleMatch(page, sprite) {
114
+ return page.evaluate(async (spriteName, spriteMd5) => {
115
+ const watermark = document.querySelector(".watermark img");
116
+ if (watermark.decode) {
117
+ try {
118
+ await watermark.decode();
119
+ } catch (err) {
120
+ // Some SVG data URLs report as already usable even when decode rejects.
121
+ }
122
+ }
123
+
124
+ const original = new Image();
125
+ original.src = `/svglibrary/${spriteMd5}`;
126
+ await original.decode();
127
+
128
+ const width = original.naturalWidth;
129
+ const height = original.naturalHeight;
130
+ const filter = window.getComputedStyle(watermark).filter;
131
+
132
+ const renderPixels = (image, imageFilter) => {
133
+ const canvas = document.createElement("canvas");
134
+ canvas.width = width;
135
+ canvas.height = height;
136
+ const ctx = canvas.getContext("2d");
137
+ ctx.filter = imageFilter;
138
+ ctx.drawImage(image, 0, 0, width, height);
139
+ return ctx.getImageData(0, 0, width, height).data;
140
+ };
141
+
142
+ const actualPixels = renderPixels(watermark, filter);
143
+ const expectedPixels = renderPixels(original, "grayscale(1)");
144
+ let comparedPixels = 0;
145
+ let differentPixels = 0;
146
+ let totalChannelDifference = 0;
147
+ let maxChannelDifference = 0;
148
+ let opaquePixels = 0;
149
+ let coloredPixels = 0;
150
+
151
+ for (let i = 0; i < actualPixels.length; i += 4) {
152
+ if (actualPixels[i + 3] <= 20 && expectedPixels[i + 3] <= 20) {
153
+ continue;
154
+ }
155
+
156
+ comparedPixels += 1;
157
+ let pixelDifference = 0;
158
+ for (let channel = 0; channel < 4; channel += 1) {
159
+ const difference = Math.abs(actualPixels[i + channel] - expectedPixels[i + channel]);
160
+ totalChannelDifference += difference;
161
+ pixelDifference = Math.max(pixelDifference, difference);
162
+ maxChannelDifference = Math.max(maxChannelDifference, difference);
163
+ }
164
+ if (pixelDifference > 2) {
165
+ differentPixels += 1;
166
+ }
167
+
168
+ if (actualPixels[i + 3] > 20) {
169
+ opaquePixels += 1;
170
+ const highestChannel = Math.max(actualPixels[i], actualPixels[i + 1], actualPixels[i + 2]);
171
+ const lowestChannel = Math.min(actualPixels[i], actualPixels[i + 1], actualPixels[i + 2]);
172
+ if (highestChannel - lowestChannel > 2) {
173
+ coloredPixels += 1;
174
+ }
175
+ }
176
+ }
177
+
178
+ return {
179
+ name: spriteName,
180
+ md5: spriteMd5,
181
+ width,
182
+ height,
183
+ filter,
184
+ opaquePixels,
185
+ meanChannelDifference: comparedPixels === 0 ? 0 : totalChannelDifference / (comparedPixels * 4),
186
+ differentPixelRatio: comparedPixels === 0 ? 0 : differentPixels / comparedPixels,
187
+ maxChannelDifference,
188
+ coloredPixelRatio: opaquePixels === 0 ? 0 : coloredPixels / opaquePixels,
189
+ };
190
+ }, sprite.name, sprite.md5);
191
+ }
192
+
193
+ async function getLegacyWatermarkState(page, sprite) {
194
+ return page.evaluate((spriteName, spriteMd5) => {
195
+ const activeSprite = window.ScratchJr.getSprite();
196
+ const watermark = document.querySelector(".watermark img");
197
+ const legacyWatermark = activeSprite.getSVGimage(activeSprite.watermark);
198
+ const sourceWatermark = activeSprite.getSVGimage(activeSprite.svg);
199
+
200
+ return {
201
+ name: spriteName,
202
+ md5: spriteMd5,
203
+ filter: window.getComputedStyle(watermark).filter,
204
+ usesLegacySource: watermark.src === legacyWatermark.src,
205
+ usesOriginalSource: watermark.src === sourceWatermark.src,
206
+ };
207
+ }, sprite.name, sprite.md5);
208
+ }
209
+
210
+ describe("sprite scripts watermark grayscale rendering", () => {
211
+ it(
212
+ "renders affected sprite watermarks from their source SVG in grayscale",
213
+ async () => {
214
+ const { browser, page, errors } = await openEditor();
215
+
216
+ try {
217
+ const metrics = [];
218
+ for (const sprite of AFFECTED_SPRITES) {
219
+ await addSpriteAndWait(page, sprite);
220
+ metrics.push(await measureGrayscaleMatch(page, sprite));
221
+ }
222
+
223
+ for (const metric of metrics) {
224
+ expect(metric.filter).toContain("grayscale");
225
+ expect(metric.opaquePixels).toBeGreaterThan(0);
226
+ expect(metric.meanChannelDifference).toBeLessThan(0.5);
227
+ expect(metric.differentPixelRatio).toBeLessThan(0.01);
228
+ expect(metric.coloredPixelRatio).toBeLessThan(0.01);
229
+ }
230
+ expect(errors).toEqual([]);
231
+ } finally {
232
+ await browser.close();
233
+ }
234
+ },
235
+ 60_000
236
+ );
237
+
238
+ it(
239
+ "preserves the legacy watermark path for existing sprites",
240
+ async () => {
241
+ const { browser, page, errors } = await openEditor();
242
+
243
+ try {
244
+ const states = [];
245
+ for (const sprite of LEGACY_SPRITES) {
246
+ await addSpriteAndWait(page, sprite);
247
+ states.push({
248
+ ...await getLegacyWatermarkState(page, sprite),
249
+ expectsGrayscale: sprite.expectsGrayscale,
250
+ });
251
+ }
252
+
253
+ for (const state of states) {
254
+ if (state.expectsGrayscale) {
255
+ expect(state.filter).toContain("grayscale");
256
+ } else {
257
+ expect(state.filter).toBe("none");
258
+ }
259
+ expect(state.usesLegacySource).toBe(true);
260
+ expect(state.usesOriginalSource).toBe(false);
261
+ }
262
+ expect(errors).toEqual([]);
263
+ } finally {
264
+ await browser.close();
265
+ }
266
+ },
267
+ 60_000
268
+ );
269
+ });
@@ -0,0 +1,152 @@
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 = 3013;
7
+ const HOST = `http://localhost:${PORT}`;
8
+
9
+ const TOP_TOOLBAR_BUTTON_IDS = [
10
+ "full",
11
+ "grid",
12
+ "traceBtn",
13
+ "traceClear",
14
+ "setbkg",
15
+ "addtext",
16
+ "resetall",
17
+ "go",
18
+ ];
19
+
20
+ let server;
21
+
22
+ async function waitForServer(maxAttempts = 20) {
23
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
24
+ const ok = await new Promise((resolve) => {
25
+ const req = http.get(`${HOST}/`, (res) => {
26
+ res.destroy();
27
+ resolve(true);
28
+ });
29
+ req.on("error", () => resolve(false));
30
+ });
31
+ if (ok) {
32
+ return;
33
+ }
34
+ await new Promise((resolve) => setTimeout(resolve, 500));
35
+ }
36
+ throw new Error("Local server did not start in time");
37
+ }
38
+
39
+ beforeAll(async () => {
40
+ server = spawn("python3", ["-m", "http.server", `${PORT}`, "--directory", "editions/free/src"], {
41
+ stdio: "ignore",
42
+ });
43
+ await waitForServer();
44
+ }, 30_000);
45
+
46
+ afterAll(() => {
47
+ if (server) {
48
+ server.kill();
49
+ }
50
+ });
51
+
52
+ async function openEditor() {
53
+ const browser = await puppeteer.launch({
54
+ headless: true,
55
+ args: ["--no-sandbox"],
56
+ });
57
+ const page = await browser.newPage();
58
+ const errors = [];
59
+
60
+ page.on("pageerror", (err) => errors.push(err.message || String(err)));
61
+ page.on("console", (msg) => {
62
+ if (msg.type() === "error") {
63
+ errors.push(msg.text());
64
+ }
65
+ });
66
+
67
+ await page.goto(`${HOST}/editor.html?mode=edit`, { waitUntil: "networkidle2", timeout: 30_000 });
68
+
69
+ return { browser, page, errors };
70
+ }
71
+
72
+ describe("top toolbar tooltips", () => {
73
+ it(
74
+ "renders bounded app-owned tooltips for hover and keyboard focus",
75
+ async () => {
76
+ const { browser, page, errors } = await openEditor();
77
+
78
+ try {
79
+ await page.waitForSelector("#stageframe", { timeout: 30_000 });
80
+ await page.waitForFunction(
81
+ (buttonIds) =>
82
+ buttonIds.every((id) => {
83
+ const button = document.getElementById(id);
84
+ return button && button.tagName === "BUTTON" && button.getAttribute("aria-label");
85
+ }),
86
+ { timeout: 30_000 },
87
+ TOP_TOOLBAR_BUTTON_IDS
88
+ );
89
+
90
+ const invalidTooltips = await page.evaluate((buttonIds) => {
91
+ return buttonIds
92
+ .map((id) => {
93
+ const button = document.getElementById(id);
94
+ const label = button.getAttribute("aria-label") || "";
95
+ return {
96
+ id,
97
+ ariaLabel: label,
98
+ nativeTitle: button.getAttribute("title"),
99
+ tooltip: button.getAttribute("data-tooltip") || "",
100
+ };
101
+ })
102
+ .filter(
103
+ (button) =>
104
+ button.nativeTitle !== null ||
105
+ button.tooltip !== button.ariaLabel
106
+ );
107
+ }, TOP_TOOLBAR_BUTTON_IDS);
108
+
109
+ expect(invalidTooltips).toEqual([]);
110
+
111
+ await page.hover("#grid");
112
+ await new Promise((resolve) => setTimeout(resolve, 200));
113
+
114
+ const hoverTooltip = await page.$eval("#grid", (button) => {
115
+ const style = window.getComputedStyle(button, "::after");
116
+ return {
117
+ content: style.content,
118
+ opacity: style.opacity,
119
+ visibility: style.visibility,
120
+ maxWidth: style.maxWidth,
121
+ };
122
+ });
123
+
124
+ expect(hoverTooltip.content).toContain("Grid");
125
+ expect(hoverTooltip.opacity).toBe("1");
126
+ expect(hoverTooltip.visibility).toBe("visible");
127
+ expect(hoverTooltip.maxWidth).toBe("240px");
128
+
129
+ await page.mouse.move(0, 0);
130
+ await page.focus("#traceBtn");
131
+ await new Promise((resolve) => setTimeout(resolve, 200));
132
+
133
+ const focusedTooltip = await page.$eval("#traceBtn", (button) => {
134
+ const style = window.getComputedStyle(button, "::after");
135
+ return {
136
+ focused: document.activeElement === button,
137
+ opacity: style.opacity,
138
+ visibility: style.visibility,
139
+ };
140
+ });
141
+
142
+ expect(focusedTooltip.focused).toBe(true);
143
+ expect(focusedTooltip.opacity).toBe("1");
144
+ expect(focusedTooltip.visibility).toBe("visible");
145
+ expect(errors).toEqual([]);
146
+ } finally {
147
+ await browser.close();
148
+ }
149
+ },
150
+ 60_000
151
+ );
152
+ });
package/vitest.config.js CHANGED
@@ -1,7 +1,6 @@
1
1
  const path = require('path');
2
- const { defineConfig } = require('vitest/config');
3
2
 
4
- module.exports = defineConfig({
3
+ module.exports = {
5
4
  resolve: {
6
5
  alias: {
7
6
  '@': path.resolve(__dirname, './src')
@@ -12,4 +11,4 @@ module.exports = defineConfig({
12
11
  include: ['tests/**/*.test.js'],
13
12
  fileParallelism: false
14
13
  }
15
- });
14
+ };