@robotical/martyblocksjr 4.2.2 → 4.2.3

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 (46) hide show
  1. package/editions/free/src/app.bundle.js +1 -1
  2. package/editions/free/src/css/editor.css +5 -0
  3. package/editions/free/src/css/editorleftpanel.css +12 -6
  4. package/editions/free/src/inapp/about.html +55 -26
  5. package/editions/free/src/inapp/blocks.html +16 -459
  6. package/editions/free/src/inapp/images/interface.png +0 -0
  7. package/editions/free/src/inapp/images/paint.png +0 -0
  8. package/editions/free/src/inapp/interface.html +136 -123
  9. package/editions/free/src/inapp/style/about.css +333 -21
  10. package/editions/free/src/inapp/style/blocks.css +423 -82
  11. package/editions/free/src/inapp/style/interface.css +178 -453
  12. package/editions/free/src/localizations/bg.json +36 -2
  13. package/editions/free/src/localizations/ca.json +36 -2
  14. package/editions/free/src/localizations/cs.json +36 -2
  15. package/editions/free/src/localizations/cy.json +36 -2
  16. package/editions/free/src/localizations/da.json +36 -2
  17. package/editions/free/src/localizations/de.json +36 -2
  18. package/editions/free/src/localizations/el.json +36 -2
  19. package/editions/free/src/localizations/en.json +114 -7
  20. package/editions/free/src/localizations/es.json +36 -2
  21. package/editions/free/src/localizations/fi.json +36 -2
  22. package/editions/free/src/localizations/fr.json +36 -2
  23. package/editions/free/src/localizations/it.json +36 -2
  24. package/editions/free/src/localizations/ja.json +36 -2
  25. package/editions/free/src/localizations/ko.json +36 -2
  26. package/editions/free/src/localizations/nl.json +36 -2
  27. package/editions/free/src/localizations/no.json +36 -2
  28. package/editions/free/src/localizations/pl.json +36 -2
  29. package/editions/free/src/localizations/pt-br.json +36 -2
  30. package/editions/free/src/localizations/pt.json +36 -2
  31. package/editions/free/src/localizations/sv.json +36 -2
  32. package/editions/free/src/localizations/th.json +36 -2
  33. package/editions/free/src/localizations/tr.json +36 -2
  34. package/editions/free/src/localizations/uk.json +36 -2
  35. package/editions/free/src/localizations/zh-cn.json +36 -2
  36. package/editions/free/src/localizations/zh-tw.json +36 -2
  37. package/package.json +1 -1
  38. package/scripts/a11y-structure.mjs +9 -2
  39. package/tests/BlockGuideRegistry.test.js +60 -0
  40. package/tests/MartyManager.test.js +88 -0
  41. package/tests/e2e/accessibility.e2e.test.js +153 -10
  42. package/tests/e2e/blocks-jr-hidden-blocks.e2e.test.js +84 -0
  43. package/tests/e2e/chromium-79-smoke.test.js +12 -17
  44. package/tests/e2e/microbit-extension-project-reload.e2e.test.js +1 -6
  45. package/tests/e2e/project-load-progress-loop.e2e.test.js +1 -6
  46. package/tests/e2e/sidebar-scrollbars.e2e.test.js +179 -0
@@ -138,9 +138,11 @@ async function getFocusStyles(page, selector) {
138
138
  }
139
139
 
140
140
  function expectVisibleFocusIndicator(focusStyles) {
141
- expect(focusStyles.outlineStyle).not.toBe('none');
142
- expect(focusStyles.outlineWidth).toBeGreaterThan(0);
143
- expect(focusStyles.outlineColor).not.toBe('rgba(0, 0, 0, 0)');
141
+ const hasVisibleOutline = focusStyles.outlineStyle !== 'none' &&
142
+ focusStyles.outlineWidth > 0 &&
143
+ focusStyles.outlineColor !== 'rgba(0, 0, 0, 0)';
144
+ const hasVisibleBoxShadow = focusStyles.boxShadow && focusStyles.boxShadow !== 'none';
145
+ expect(hasVisibleOutline || hasVisibleBoxShadow).toBe(true);
144
146
  }
145
147
 
146
148
  async function expectNamedControls(page, selectors) {
@@ -231,7 +233,7 @@ describe('Accessibility shell audit', () => {
231
233
  await new Promise((resolve) => setTimeout(resolve, 1200));
232
234
  await page.click('#booktab');
233
235
  await page.waitForSelector('iframe#htmlcontents', { timeout: 30_000 });
234
- expect(await page.$eval('iframe#htmlcontents', (element) => element.getAttribute('title'))).toBe('About ScratchJr');
236
+ expect(await page.$eval('iframe#htmlcontents', (element) => element.getAttribute('title'))).toBe('About Blocks Jr');
235
237
 
236
238
  expectNoAxeViolations(await runAxe(page));
237
239
  expect(errors).toEqual([]);
@@ -250,11 +252,25 @@ describe('Accessibility shell audit', () => {
250
252
  });
251
253
 
252
254
  const guideCases = [
255
+ {
256
+ path: 'inapp/about.html',
257
+ title: 'About Blocks Jr',
258
+ focusSelector: '#skip-link',
259
+ buttonSelectors: [],
260
+ decorativeImageSelector: '.about-hero-art img'
261
+ },
253
262
  {
254
263
  path: 'inapp/interface.html',
255
264
  title: 'Interface Guide',
256
265
  focusSelector: '#interface-button-save',
257
- buttonSelectors: ['#interface-button-save', '#interface-button-stage', '#interface-button-green-flag'],
266
+ buttonSelectors: [
267
+ '#interface-button-save',
268
+ '#interface-button-extensions',
269
+ '#interface-button-stage',
270
+ '#interface-button-green-flag',
271
+ '#interface-button-duplicate-page',
272
+ '#interface-button-zoom'
273
+ ],
258
274
  decorativeImageSelector: '.ipad-project-view'
259
275
  },
260
276
  {
@@ -267,9 +283,13 @@ describe('Accessibility shell audit', () => {
267
283
  {
268
284
  path: 'inapp/blocks.html',
269
285
  title: 'Blocks Guide',
270
- focusSelector: '#skip-link',
271
- buttonSelectors: [],
272
- decorativeImageSelector: '.block-image'
286
+ focusSelector: '#blocks-guide-tab-marty',
287
+ buttonSelectors: [
288
+ '#blocks-guide-tab-marty',
289
+ '#blocks-guide-tab-sprite',
290
+ '#blocks-guide-tab-cog'
291
+ ],
292
+ decorativeImageSelector: '.block-guide-icon'
273
293
  }
274
294
  ];
275
295
 
@@ -279,12 +299,14 @@ describe('Accessibility shell audit', () => {
279
299
  await page.goto(`${HOST}/${guideCase.path}`, { waitUntil: 'domcontentloaded', timeout: 30_000 });
280
300
  await waitForTitle(page, guideCase.title);
281
301
  await page.waitForSelector('#skip-link');
282
- await page.waitForSelector('#content[role="main"]');
302
+ await page.waitForSelector('#content');
283
303
 
284
304
  expect((await page.title()).includes(guideCase.title)).toBe(true);
285
305
  expect(await page.$eval('html', (element) => element.lang)).toBe('en');
286
306
  expect(await page.$eval('#skip-link', (element) => element.getAttribute('href'))).toBe('#content');
287
- expect(await page.$eval('#content', (element) => element.getAttribute('role'))).toBe('main');
307
+ expect(await page.$eval('#content', (element) => {
308
+ return element.tagName.toLowerCase() === 'main' || element.getAttribute('role') === 'main';
309
+ })).toBe(true);
288
310
 
289
311
  if (guideCase.buttonSelectors.length > 0) {
290
312
  await expectNamedControls(page, guideCase.buttonSelectors);
@@ -298,6 +320,127 @@ describe('Accessibility shell audit', () => {
298
320
  await tabToSelector(page, guideCase.focusSelector);
299
321
  expectVisibleFocusIndicator(await getFocusStyles(page, guideCase.focusSelector));
300
322
 
323
+ if (guideCase.path === 'inapp/interface.html') {
324
+ const buttonIds = await page.$$eval('.interface-button', (buttons) => {
325
+ return buttons.map((button) => button.id);
326
+ });
327
+ expect(buttonIds).toHaveLength(24);
328
+ expect(await page.$$eval('.interface-connector', (connectors) => connectors.length)).toBe(24);
329
+ expect(await page.$eval('#interface-key-header', (element) => element.textContent)).toBe('1 | Save');
330
+
331
+ for (let index = 0; index < buttonIds.length; index += 1) {
332
+ const selector = `#${buttonIds[index]}`;
333
+ await page.click(selector);
334
+ const selectedFeature = await page.evaluate((buttonSelector) => {
335
+ const button = document.querySelector(buttonSelector);
336
+ const connector = document.querySelector(
337
+ `.interface-connector[data-guide-key="${button.getAttribute('data-guide-key')}"]`
338
+ );
339
+ const number = button.querySelector('span');
340
+ return {
341
+ ariaCurrent: button.getAttribute('aria-current'),
342
+ ariaLabel: button.getAttribute('aria-label'),
343
+ buttonBackground: window.getComputedStyle(button).backgroundColor,
344
+ connectorIsSelected: connector.classList.contains('interface-connector-selected'),
345
+ connectorLineLength: connector.querySelector('line').getTotalLength(),
346
+ description: document.querySelector('#interface-key-description').textContent,
347
+ header: document.querySelector('#interface-key-header').textContent,
348
+ numberColor: window.getComputedStyle(number).color,
349
+ numberText: number.textContent,
350
+ selectedConnectorCount: document.querySelectorAll(
351
+ '.interface-connector-selected'
352
+ ).length,
353
+ selectedCount: document.querySelectorAll('.interface-button[aria-current="page"]').length
354
+ };
355
+ }, selector);
356
+
357
+ expect(selectedFeature.header).toBe(selectedFeature.ariaLabel);
358
+ expect(selectedFeature.header.startsWith(`${index + 1} | `)).toBe(true);
359
+ expect(selectedFeature.header).not.toContain('String missing:');
360
+ expect(selectedFeature.description.trim().length).toBeGreaterThan(0);
361
+ expect(selectedFeature.description).not.toContain('String missing:');
362
+ expect(selectedFeature.ariaCurrent).toBe('page');
363
+ expect(selectedFeature.numberText).toBe(String(index + 1));
364
+ expect(selectedFeature.numberColor).not.toBe(selectedFeature.buttonBackground);
365
+ expect(selectedFeature.connectorIsSelected).toBe(true);
366
+ expect(selectedFeature.connectorLineLength).toBeGreaterThan(0);
367
+ expect(selectedFeature.selectedConnectorCount).toBe(1);
368
+ expect(selectedFeature.selectedCount).toBe(1);
369
+ }
370
+
371
+ await page.click('#interface-button-extensions');
372
+ expect(await page.$eval('#interface-key-header', (element) => element.textContent)).toBe('3 | Extensions');
373
+ expect(await page.$eval('#interface-key-description', (element) => element.textContent)).toContain('micro:bit');
374
+ }
375
+
376
+ if (guideCase.path === 'inapp/blocks.html') {
377
+ const inventory = await page.evaluate(() => {
378
+ const modeCounts = {};
379
+ document.querySelectorAll('[data-guide-mode-panel]').forEach((panel) => {
380
+ modeCounts[panel.getAttribute('data-guide-mode-panel')] =
381
+ panel.querySelectorAll('.block-guide-card').length;
382
+ });
383
+ return {
384
+ activeMode: document.querySelector('[role="tab"][aria-selected="true"]')
385
+ .getAttribute('data-guide-mode'),
386
+ extensionCount: document.querySelectorAll(
387
+ '#blocks-guide-extension .block-guide-card'
388
+ ).length,
389
+ modeCounts,
390
+ missingCopy: document.body.textContent.includes('String missing:'),
391
+ tabCount: document.querySelectorAll('[role="tab"]').length
392
+ };
393
+ });
394
+ expect(inventory).toEqual({
395
+ activeMode: 'marty',
396
+ extensionCount: 8,
397
+ modeCounts: {
398
+ marty: 44,
399
+ sprite: 31,
400
+ cog: 19
401
+ },
402
+ missingCopy: false,
403
+ tabCount: 3
404
+ });
405
+
406
+ await page.click('#blocks-guide-tab-sprite');
407
+ expect(await page.$eval('#blocks-guide-panel-sprite', (panel) => panel.hidden)).toBe(false);
408
+ expect(await page.$eval('#blocks-guide-panel-marty', (panel) => panel.hidden)).toBe(true);
409
+
410
+ await page.focus('#blocks-guide-tab-sprite');
411
+ await page.keyboard.press('ArrowRight');
412
+ expect(await page.$eval('#blocks-guide-tab-cog', (tab) => tab.getAttribute('aria-selected'))).toBe('true');
413
+ expect(await page.$eval('#blocks-guide-panel-cog', (panel) => panel.hidden)).toBe(false);
414
+
415
+ for (const modeId of ['marty', 'sprite', 'cog']) {
416
+ await page.evaluate(() => {
417
+ window.scrollTo(0, document.documentElement.scrollHeight);
418
+ });
419
+ await page.click(`#blocks-guide-tab-${modeId}`);
420
+ const selectedMode = await page.evaluate((expectedMode) => {
421
+ const tab = document.querySelector(`[data-guide-mode="${expectedMode}"]`);
422
+ const panel = document.querySelector(`[data-guide-mode-panel="${expectedMode}"]`);
423
+ const firstCard = panel.querySelector('.block-guide-card');
424
+ const tabsShell = document.querySelector('.blocks-guide-tabs-shell');
425
+ const cardRect = firstCard.getBoundingClientRect();
426
+ const tabsRect = tabsShell.getBoundingClientRect();
427
+ return {
428
+ activeMode: document.querySelector('[role="tab"][aria-selected="true"]')
429
+ .getAttribute('data-guide-mode'),
430
+ firstCardVisible: cardRect.top < window.innerHeight && cardRect.bottom > tabsRect.bottom,
431
+ panelHidden: panel.hidden,
432
+ tabSelected: tab.getAttribute('aria-selected')
433
+ };
434
+ }, modeId);
435
+ expect(selectedMode).toEqual({
436
+ activeMode: modeId,
437
+ firstCardVisible: true,
438
+ panelHidden: false,
439
+ tabSelected: 'true'
440
+ });
441
+ }
442
+ }
443
+
301
444
  await activateSkipLink(page, 'content');
302
445
 
303
446
  expectNoAxeViolations(await runAxe(page));
@@ -164,6 +164,14 @@ const getSpriteScriptState = async (page, spriteId) =>
164
164
  };
165
165
  }, spriteId);
166
166
 
167
+ const getMartySensorPaletteState = page =>
168
+ page.evaluate(() => ({
169
+ colour: Boolean(document.getElementById("martycoloursensed_block")),
170
+ obstacle: Boolean(document.getElementById("martyobstaclesensed_block")),
171
+ light: Boolean(document.getElementById("martylightsensed_block")),
172
+ noise: Boolean(document.getElementById("martynoisesensed_block"))
173
+ }));
174
+
167
175
  describe("Blocks Jr hidden block drop regression", () => {
168
176
  beforeAll(async () => {
169
177
  server = spawn("python3", ["-m", "http.server", String(PORT), "--directory", "editions/free/src"], {
@@ -259,4 +267,80 @@ describe("Blocks Jr hidden block drop regression", () => {
259
267
  await browser.close();
260
268
  }
261
269
  }, 60000);
270
+
271
+ it("always shows standard Marty sensors and gates optional sensor blocks", async () => {
272
+ const { browser, page, errors } = await openEditor();
273
+
274
+ try {
275
+ await enableMartyMode(page);
276
+ await page.click("#marty-start");
277
+ await page.waitForSelector("#martycoloursensed_block", { visible: true });
278
+ await page.waitForSelector("#martyobstaclesensed_block", { visible: true });
279
+
280
+ const unpairedState = await getMartySensorPaletteState(page);
281
+ expect(unpairedState).toEqual({
282
+ colour: true,
283
+ obstacle: true,
284
+ light: false,
285
+ noise: false
286
+ });
287
+
288
+ await page.evaluate(() => {
289
+ window.sensorPolicyTestManager = window.martyManager;
290
+ window.martyManager = undefined;
291
+ window.Palette.selectCategory(window.Palette.numcat);
292
+ });
293
+ const managerUnavailableState = await getMartySensorPaletteState(page);
294
+ expect(managerUnavailableState).toEqual({
295
+ colour: true,
296
+ obstacle: true,
297
+ light: false,
298
+ noise: false
299
+ });
300
+
301
+ await page.evaluate(() => {
302
+ window.martyManager = window.sensorPolicyTestManager;
303
+ delete window.sensorPolicyTestManager;
304
+ });
305
+
306
+ await page.evaluate(() => {
307
+ window.martyManager.addMarty({ id: "sensor-policy-test" });
308
+ window.martyManager.setMartySensorAvailability("sensor-policy-test", {
309
+ colour: false,
310
+ obstacle: false,
311
+ light: true,
312
+ noise: false
313
+ });
314
+ });
315
+ await page.waitForSelector("#martylightsensed_block", { visible: true });
316
+
317
+ const lightSensorState = await getMartySensorPaletteState(page);
318
+ expect(lightSensorState).toEqual({
319
+ colour: true,
320
+ obstacle: true,
321
+ light: true,
322
+ noise: false
323
+ });
324
+
325
+ await page.evaluate(() => {
326
+ window.martyManager.setMartySensorAvailability("sensor-policy-test", {
327
+ light: false,
328
+ noise: true
329
+ });
330
+ });
331
+ await page.waitForSelector("#martynoisesensed_block", { visible: true });
332
+ await page.waitForFunction(() => !document.getElementById("martylightsensed_block"));
333
+
334
+ const noiseSensorState = await getMartySensorPaletteState(page);
335
+ expect(noiseSensorState).toEqual({
336
+ colour: true,
337
+ obstacle: true,
338
+ light: false,
339
+ noise: true
340
+ });
341
+ expect(errors).toEqual([]);
342
+ } finally {
343
+ await browser.close();
344
+ }
345
+ }, 60000);
262
346
  });
@@ -108,13 +108,8 @@ 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
- });
111
+ async function openExtensionsLibrary(page) {
112
+ await page.click("#addExtensionButton");
118
113
  }
119
114
 
120
115
  describe("Chromium 79 smoke test", () => {
@@ -132,7 +127,7 @@ describe("Chromium 79 smoke test", () => {
132
127
  );
133
128
 
134
129
  it(
135
- "keeps the extension entry hidden and reveals micro:bit controls only while loaded",
130
+ "keeps the extension entry visible and reveals micro:bit controls only while loaded",
136
131
  async () => {
137
132
  const { browser, page, errors } = await openPage("/editor.html?mode=edit");
138
133
 
@@ -157,9 +152,9 @@ describe("Chromium 79 smoke test", () => {
157
152
  });
158
153
 
159
154
  expect(initialState).toEqual({
160
- addButtonDisplay: "none",
161
- addButtonAriaHidden: "true",
162
- addButtonDisabled: true,
155
+ addButtonDisplay: "flex",
156
+ addButtonAriaHidden: "false",
157
+ addButtonDisabled: false,
163
158
  microBitButtonDisplay: "none",
164
159
  microBitButtonAriaHidden: "true",
165
160
  microBitButtonDisabled: true,
@@ -167,7 +162,7 @@ describe("Chromium 79 smoke test", () => {
167
162
  extensionEnabled: false,
168
163
  });
169
164
 
170
- await openHiddenExtensionsLibrary(page);
165
+ await openExtensionsLibrary(page);
171
166
  await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
172
167
  const libraryState = await page.evaluate(() => ({
173
168
  title: document.getElementById("extensionsLibraryTitle").textContent.trim(),
@@ -221,9 +216,9 @@ describe("Chromium 79 smoke test", () => {
221
216
  });
222
217
 
223
218
  expect(enabledState).toEqual({
224
- addButtonDisplay: "none",
225
- addButtonAriaHidden: "true",
226
- addButtonDisabled: true,
219
+ addButtonDisplay: "flex",
220
+ addButtonAriaHidden: "false",
221
+ addButtonDisabled: false,
227
222
  microBitButtonDisplay: "flex",
228
223
  microBitButtonAriaHidden: "false",
229
224
  microBitButtonDisabled: false,
@@ -290,7 +285,7 @@ describe("Chromium 79 smoke test", () => {
290
285
  });
291
286
  expect(blocksAfterInsert).toContain("microbitdisplayclear");
292
287
 
293
- await openHiddenExtensionsLibrary(page);
288
+ await openExtensionsLibrary(page);
294
289
  await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
295
290
  const loadedLibraryState = await page.evaluate(() => ({
296
291
  microBitCardAction: document.getElementById("microBitExtensionCardAction").textContent.trim(),
@@ -339,7 +334,7 @@ describe("Chromium 79 smoke test", () => {
339
334
  extensionEnabled: document.getElementById("connectionButtonsArea").classList.contains("extensionEnabled"),
340
335
  }));
341
336
  expect(unloadedState).toEqual({
342
- addButtonDisplay: "none",
337
+ addButtonDisplay: "flex",
343
338
  microBitButtonDisplay: "none",
344
339
  extensionsLibraryOpen: false,
345
340
  microBitCardAction: "+",
@@ -119,12 +119,7 @@ async function enableMartyMode(page) {
119
119
  }
120
120
 
121
121
  async function enableMicroBitExtension(page) {
122
- await page.$eval("#addExtensionButton", (button) => {
123
- if (!button.disabled || window.getComputedStyle(button).display !== "none") {
124
- throw new Error("Expected the extensions entry point to remain hidden and disabled");
125
- }
126
- button.onclick();
127
- });
122
+ await page.click("#addExtensionButton");
128
123
  await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
129
124
  await page.click("#microBitExtensionCard");
130
125
  await page.waitForFunction(
@@ -110,12 +110,7 @@ async function enableMartyMode(page) {
110
110
  }
111
111
 
112
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
- });
113
+ await page.click("#addExtensionButton");
119
114
  await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
120
115
  await page.click("#microBitExtensionCard");
121
116
  await page.waitForFunction(
@@ -0,0 +1,179 @@
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 = 3022;
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', (error) => errors.push(error.message || String(error)));
50
+ page.on('console', (message) => {
51
+ if (message.type() === 'error') {
52
+ errors.push(message.text());
53
+ }
54
+ });
55
+ await page.goto(`${HOST}/editor.html?mode=edit`, {
56
+ waitUntil: 'networkidle2',
57
+ timeout: 30_000
58
+ });
59
+ await page.waitForFunction(() => {
60
+ const backdrop = document.getElementById('backdrop');
61
+ return Boolean(
62
+ window.ScratchJr &&
63
+ window.ScratchJr.stage &&
64
+ document.getElementById('spritecc') &&
65
+ document.getElementById('pagecc') &&
66
+ document.getElementById('scrollbar') &&
67
+ document.getElementById('pagescrollbar') &&
68
+ backdrop &&
69
+ window.getComputedStyle(backdrop).display === 'none'
70
+ );
71
+ }, { timeout: 30_000 });
72
+ return { browser, page, errors };
73
+ }
74
+
75
+ describe('editor sidebar scrollbars', () => {
76
+ it('shows and synchronizes persistent scrollbars when sprites and pages overflow', async () => {
77
+ const { browser, page, errors } = await openEditor();
78
+
79
+ try {
80
+ const naturalSpriteState = await page.evaluate(() => {
81
+ const spriteContainer = document.getElementById('spriteScrollContainer');
82
+ const addSprite = document.getElementById('addsprite');
83
+ return {
84
+ addButtonScrollsWithPanel: addSprite.parentNode === spriteContainer,
85
+ overflow: spriteContainer.scrollHeight - spriteContainer.clientHeight,
86
+ scrollbarVisible: document.getElementById('scrollbar').classList.contains('on')
87
+ };
88
+ });
89
+ expect(naturalSpriteState.addButtonScrollsWithPanel).toBe(true);
90
+ if (naturalSpriteState.overflow <= 1) {
91
+ expect(naturalSpriteState.scrollbarVisible).toBe(false);
92
+ }
93
+
94
+ await page.evaluate(() => {
95
+ const spriteSpacer = document.createElement('div');
96
+ spriteSpacer.id = 'sprite-scroll-test-spacer';
97
+ spriteSpacer.style.height = '800px';
98
+ document.getElementById('spritecc').appendChild(spriteSpacer);
99
+
100
+ const pageSpacer = document.createElement('div');
101
+ pageSpacer.id = 'page-scroll-test-spacer';
102
+ pageSpacer.style.height = '800px';
103
+ document.getElementById('pagelist').appendChild(pageSpacer);
104
+ });
105
+
106
+ await page.waitForFunction(() =>
107
+ document.getElementById('scrollbar').classList.contains('on') &&
108
+ document.getElementById('pagescrollbar').classList.contains('on')
109
+ );
110
+
111
+ const initialState = await page.evaluate(() => ({
112
+ spriteRole: document.getElementById('scrollbar').getAttribute('role'),
113
+ spriteLabel: document.getElementById('scrollbar').getAttribute('aria-label'),
114
+ spriteControls: document.getElementById('scrollbar').getAttribute('aria-controls'),
115
+ pageRole: document.getElementById('pagescrollbar').getAttribute('role'),
116
+ pageLabel: document.getElementById('pagescrollbar').getAttribute('aria-label'),
117
+ pageControls: document.getElementById('pagescrollbar').getAttribute('aria-controls'),
118
+ spriteThumbHeight: document.getElementById('sbthumb').offsetHeight,
119
+ pageThumbHeight: document.getElementById('pagesbthumb').offsetHeight,
120
+ addSpriteTop: document.getElementById('addsprite').getBoundingClientRect().top
121
+ }));
122
+ expect(initialState).toEqual({
123
+ spriteRole: 'scrollbar',
124
+ spriteLabel: 'Characters',
125
+ spriteControls: 'spriteScrollContainer',
126
+ pageRole: 'scrollbar',
127
+ pageLabel: 'Pages',
128
+ pageControls: 'pagecc',
129
+ spriteThumbHeight: expect.any(Number),
130
+ pageThumbHeight: expect.any(Number),
131
+ addSpriteTop: expect.any(Number)
132
+ });
133
+ expect(initialState.spriteThumbHeight).toBeGreaterThan(0);
134
+ expect(initialState.pageThumbHeight).toBeGreaterThan(0);
135
+
136
+ await page.evaluate(() => {
137
+ const spriteContainer = document.querySelector('#library .spritethumbs');
138
+ const pageContainer = document.getElementById('pagecc');
139
+ spriteContainer.scrollTop = spriteContainer.scrollHeight;
140
+ pageContainer.scrollTop = pageContainer.scrollHeight;
141
+ });
142
+ await page.waitForFunction(() =>
143
+ Number(document.getElementById('scrollbar').getAttribute('aria-valuenow')) > 0 &&
144
+ Number(document.getElementById('pagescrollbar').getAttribute('aria-valuenow')) > 0
145
+ );
146
+
147
+ const scrolledState = await page.evaluate(() => ({
148
+ spriteThumbTop: document.getElementById('sbthumb').offsetTop,
149
+ pageThumbTop: document.getElementById('pagesbthumb').offsetTop,
150
+ spriteValue: Number(document.getElementById('scrollbar').getAttribute('aria-valuenow')),
151
+ pageValue: Number(document.getElementById('pagescrollbar').getAttribute('aria-valuenow')),
152
+ addSpriteTop: document.getElementById('addsprite').getBoundingClientRect().top
153
+ }));
154
+ expect(scrolledState.spriteThumbTop).toBeGreaterThan(0);
155
+ expect(scrolledState.pageThumbTop).toBeGreaterThan(0);
156
+ expect(scrolledState.spriteValue).toBeGreaterThan(0);
157
+ expect(scrolledState.pageValue).toBeGreaterThan(0);
158
+ expect(scrolledState.addSpriteTop).toBeLessThan(initialState.addSpriteTop);
159
+
160
+ await page.evaluate(() => {
161
+ document.getElementById('pagecc').scrollTop = 0;
162
+ });
163
+ const pageScrollBar = await page.$('#pagescrollbar');
164
+ const pageScrollBarBox = await pageScrollBar.boundingBox();
165
+ await page.mouse.click(
166
+ pageScrollBarBox.x + (pageScrollBarBox.width / 2),
167
+ pageScrollBarBox.y + pageScrollBarBox.height - 2
168
+ );
169
+ await page.waitForFunction(() => document.getElementById('pagecc').scrollTop > 0);
170
+
171
+ await page.focus('#pagescrollbar');
172
+ await page.keyboard.press('Home');
173
+ await page.waitForFunction(() => document.getElementById('pagecc').scrollTop === 0);
174
+ expect(errors).toEqual([]);
175
+ } finally {
176
+ await browser.close();
177
+ }
178
+ }, 45_000);
179
+ });