Repository: google/blockly-keyboard-experimentation
Pull request #557: chore: Introduce tests for flyout & toolbox
Author: login: BenHenning
Created: 2025-05-29T00:28:15Z | Merged: 2025-05-29T19:15:43Z

Fixes part of https://github.com/google/blockly/issues/8915
Fixes part of https://github.com/google/blockly/issues/9020

This PR does a few things:
- It introduces some new helpers to simplify arrow key inputs across all of the webdriver tests.
- It introduces a new focusable div element that exists in the tab order before toolbox (so that back navigation can be tested).
- It renames a few functions for clarity: `setCurrentCursorNodeById` -> `focusOnBlock` and `setCurrentCursorNodeByIdAndFieldName` -> `focusOnBlockField`. This is closer to what the functions are actually doing, and it moves away from cursor verbiage (which could become confusing in the future once the cursor is removed).
- It introduces some robustness sanity checks for test utility functions. These should fail if an assumption isn't met rather than return null or undefined--failing fast is really useful in tests to avoid hiding legitimate failures.
- It introduces a whole test suite for toolbox and flyout (though a lot more tests can be added). This specifically emphasizes regressions fixed by https://github.com/google/blockly/pull/9079.


Diff:
diff --git a/test/toolboxCategories.js b/test/toolboxCategories.js
index c66918ce..f88f0825 100644
--- a/test/toolboxCategories.js
+++ b/test/toolboxCategories.js
@@ -17,6 +17,7 @@ export default {
         {
           type: 'controls_if',
           kind: 'block',
+          id: 'if_block',
         },
         {
           type: 'logic_compare',
@@ -374,6 +375,7 @@ export default {
           fields: {
             TEXT: '',
           },
+          id: 'text_block',
         },
         {
           type: 'text_join',
@@ -382,6 +384,7 @@ export default {
         {
           type: 'text_append',
           kind: 'block',
+          id: 'append_text_block',
           fields: {
             name: 'item',
           },
diff --git a/test/webdriverio/index.html b/test/webdriverio/index.html
index 432dd1a0..fb1bf4d6 100644
--- a/test/webdriverio/index.html
+++ b/test/webdriverio/index.html
@@ -62,6 +62,7 @@
 
   <body>
     <div id="root">
+      <div id="focusableDiv" tabindex="0">Simple div to focus.</div>
       <div id="blocklyDiv"></div>
       <div id="shortcuts"></div>
     </div>
diff --git a/test/webdriverio/test/actions_test.ts b/test/webdriverio/test/actions_test.ts
index 239ed7ed..249e4d65 100644
--- a/test/webdriverio/test/actions_test.ts
+++ b/test/webdriverio/test/actions_test.ts
@@ -10,10 +10,11 @@ import {
   contextMenuExists,
   moveToToolboxCategory,
   PAUSE_TIME,
-  setCurrentCursorNodeById,
+  focusOnBlock,
   tabNavigateToWorkspace,
   testFileLocations,
   testSetup,
+  keyRight,
 } from './test_setup.js';
 
 suite('Menus test', function () {
@@ -29,7 +30,7 @@ suite('Menus test', function () {
   test('Menu action opens menu', async function () {
     // Navigate to draw_circle_1.
     await tabNavigateToWorkspace(this.browser);
-    await setCurrentCursorNodeById(this.browser, 'draw_circle_1');
+    await focusOnBlock(this.browser, 'draw_circle_1');
     await this.browser.pause(PAUSE_TIME);
     await this.browser.keys([Key.Ctrl, Key.Return]);
     await this.browser.pause(PAUSE_TIME);
@@ -42,11 +43,11 @@ suite('Menus test', function () {
   test('Menu action returns true in the toolbox', async function () {
     // Navigate to draw_circle_1.
     await tabNavigateToWorkspace(this.browser);
-    await setCurrentCursorNodeById(this.browser, 'draw_circle_1');
+    await focusOnBlock(this.browser, 'draw_circle_1');
     // Navigate to a toolbox category
     await moveToToolboxCategory(this.browser, 'Functions');
     // Move to flyout.
-    await this.browser.keys(Key.ArrowRight);
+    await keyRight(this.browser);
     await this.browser.keys([Key.Ctrl, Key.Return]);
     await this.browser.pause(PAUSE_TIME);
 
@@ -59,7 +60,7 @@ suite('Menus test', function () {
   test('Menu action returns false during drag', async function () {
     // Navigate to draw_circle_1.
     await tabNavigateToWorkspace(this.browser);
-    await setCurrentCursorNodeById(this.browser, 'draw_circle_1');
+    await focusOnBlock(this.browser, 'draw_circle_1');
     // Start moving the block
     await this.browser.keys('m');
     await this.browser.keys([Key.Ctrl, Key.Return]);
diff --git a/test/webdriverio/test/basic_test.ts b/test/webdriverio/test/basic_test.ts
index 089c467a..c8701140 100644
--- a/test/webdriverio/test/basic_test.ts
+++ b/test/webdriverio/test/basic_test.ts
@@ -8,8 +8,8 @@ import * as chai from 'chai';
 import * as Blockly from 'blockly';
 import {
   isDragging,
-  setCurrentCursorNodeById,
-  setCurrentCursorNodeByIdAndFieldName,
+  focusOnBlock,
+  focusOnBlockField,
   getCurrentFocusNodeId,
   getCurrentFocusedBlockId,
   getFocusedFieldName,
@@ -17,6 +17,10 @@ import {
   testFileLocations,
   PAUSE_TIME,
   tabNavigateToWorkspace,
+  keyLeft,
+  keyRight,
+  keyUp,
+  keyDown,
 } from './test_setup.js';
 import {Key} from 'webdriverio';
 
@@ -40,10 +44,7 @@ suite('Keyboard navigation on Blocks', function () {
   test('Selected block', async function () {
     await tabNavigateToWorkspace(this.browser);
 
-    for (let i = 0; i < 14; i++) {
-      await this.browser.keys(Key.ArrowDown);
-      await this.browser.pause(PAUSE_TIME);
-    }
+    await keyDown(this.browser, 14);
 
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
@@ -53,10 +54,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Down from statement block selects next block across stacks', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'p5_canvas_1');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowDown);
+    await focusOnBlock(this.browser, 'p5_canvas_1');
     await this.browser.pause(PAUSE_TIME);
+    await keyDown(this.browser);
 
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
@@ -66,10 +66,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Up from statement block selects previous block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'simple_circle_1');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowUp);
+    await focusOnBlock(this.browser, 'simple_circle_1');
     await this.browser.pause(PAUSE_TIME);
+    await keyUp(this.browser);
 
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
@@ -79,10 +78,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Down from parent block selects first child block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'p5_setup_1');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowDown);
+    await focusOnBlock(this.browser, 'p5_setup_1');
     await this.browser.pause(PAUSE_TIME);
+    await keyDown(this.browser);
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
       .equal('p5_canvas_1');
@@ -91,10 +89,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Up from child block selects parent block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'p5_canvas_1');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowUp);
+    await focusOnBlock(this.browser, 'p5_canvas_1');
     await this.browser.pause(PAUSE_TIME);
+    await keyUp(this.browser);
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
       .equal('p5_setup_1');
@@ -103,10 +100,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Right from block selects first field', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'p5_canvas_1');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowRight);
+    await focusOnBlock(this.browser, 'p5_canvas_1');
     await this.browser.pause(PAUSE_TIME);
+    await keyRight(this.browser);
 
     chai
       .expect(await getCurrentFocusNodeId(this.browser))
@@ -118,10 +114,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Right from block selects first inline input', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'simple_circle_1');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowRight);
+    await focusOnBlock(this.browser, 'simple_circle_1');
     await this.browser.pause(PAUSE_TIME);
+    await keyRight(this.browser);
 
     chai.assert.equal(
       await getCurrentFocusedBlockId(this.browser),
@@ -132,10 +127,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Up from inline input selects statement block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'math_number_2');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowUp);
+    await focusOnBlock(this.browser, 'math_number_2');
     await this.browser.pause(PAUSE_TIME);
+    await keyUp(this.browser);
 
     chai.assert.equal(
       await getCurrentFocusedBlockId(this.browser),
@@ -146,10 +140,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Left from first inline input selects block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'math_number_2');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowLeft);
+    await focusOnBlock(this.browser, 'math_number_2');
     await this.browser.pause(PAUSE_TIME);
+    await keyLeft(this.browser);
 
     chai.assert.equal(
       await getCurrentFocusedBlockId(this.browser),
@@ -160,10 +153,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Right from first inline input selects second inline input', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'math_number_2');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowRight);
+    await focusOnBlock(this.browser, 'math_number_2');
     await this.browser.pause(PAUSE_TIME);
+    await keyRight(this.browser);
 
     chai.assert.equal(
       await getCurrentFocusedBlockId(this.browser),
@@ -174,10 +166,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Left from second inline input selects first inline input', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'math_number_3');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowLeft);
+    await focusOnBlock(this.browser, 'math_number_3');
     await this.browser.pause(PAUSE_TIME);
+    await keyLeft(this.browser);
 
     chai.assert.equal(
       await getCurrentFocusedBlockId(this.browser),
@@ -188,10 +179,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Right from last inline input selects next block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'colour_picker_1');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowRight);
+    await focusOnBlock(this.browser, 'colour_picker_1');
     await this.browser.pause(PAUSE_TIME);
+    await keyRight(this.browser);
 
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
@@ -201,10 +191,9 @@ suite('Keyboard navigation on Blocks', function () {
   test('Down from inline input selects next block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'colour_picker_1');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowDown);
+    await focusOnBlock(this.browser, 'colour_picker_1');
     await this.browser.pause(PAUSE_TIME);
+    await keyDown(this.browser);
 
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
@@ -214,10 +203,9 @@ suite('Keyboard navigation on Blocks', function () {
   test("Down from inline input selects block's child block", async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'logic_boolean_1');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowDown);
+    await focusOnBlock(this.browser, 'logic_boolean_1');
     await this.browser.pause(PAUSE_TIME);
+    await keyDown(this.browser);
 
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
@@ -227,22 +215,19 @@ suite('Keyboard navigation on Blocks', function () {
   test('Right from text block selects shadow block then field', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'text_print_1');
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowRight);
+    await focusOnBlock(this.browser, 'text_print_1');
     await this.browser.pause(PAUSE_TIME);
+    await keyRight(this.browser);
 
     chai.assert.equal(await getCurrentFocusedBlockId(this.browser), 'text_1');
 
-    await this.browser.keys(Key.ArrowRight);
-    await this.browser.pause(PAUSE_TIME);
+    await keyRight(this.browser);
 
     chai
       .expect(await getCurrentFocusNodeId(this.browser))
       .to.include('text_1_field_');
 
-    await this.browser.keys(Key.ArrowRight);
-    await this.browser.pause(PAUSE_TIME);
+    await keyRight(this.browser);
 
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
@@ -252,7 +237,7 @@ suite('Keyboard navigation on Blocks', function () {
   test('Losing focus cancels move', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'text_print_1');
+    await focusOnBlock(this.browser, 'text_print_1');
     await this.browser.keys('m');
     await this.browser.pause(PAUSE_TIME);
 
@@ -265,7 +250,6 @@ suite('Keyboard navigation on Blocks', function () {
   });
 });
 
-// TODO(#499) These tests fail because focusing on a field doesn't update the cursor
 suite('Keyboard navigation on Fields', function () {
   // Setting timeout to unlimited as these tests take a longer time to run than most mocha test
   this.timeout(0);
@@ -278,14 +262,9 @@ suite('Keyboard navigation on Fields', function () {
   test('Up from first field selects block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeByIdAndFieldName(
-      this.browser,
-      'p5_canvas_1',
-      'WIDTH',
-    );
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowUp);
+    await focusOnBlockField(this.browser, 'p5_canvas_1', 'WIDTH');
     await this.browser.pause(PAUSE_TIME);
+    await keyUp(this.browser);
 
     chai.assert.equal(
       await getCurrentFocusedBlockId(this.browser),
@@ -296,14 +275,9 @@ suite('Keyboard navigation on Fields', function () {
   test('Left from first field selects block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeByIdAndFieldName(
-      this.browser,
-      'p5_canvas_1',
-      'WIDTH',
-    );
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowLeft);
+    await focusOnBlockField(this.browser, 'p5_canvas_1', 'WIDTH');
     await this.browser.pause(PAUSE_TIME);
+    await keyLeft(this.browser);
 
     chai.assert.equal(
       await getCurrentFocusedBlockId(this.browser),
@@ -314,14 +288,9 @@ suite('Keyboard navigation on Fields', function () {
   test('Right from first field selects second field', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeByIdAndFieldName(
-      this.browser,
-      'p5_canvas_1',
-      'WIDTH',
-    );
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowRight);
+    await focusOnBlockField(this.browser, 'p5_canvas_1', 'WIDTH');
     await this.browser.pause(PAUSE_TIME);
+    await keyRight(this.browser);
 
     chai
       .expect(await getCurrentFocusNodeId(this.browser))
@@ -333,14 +302,9 @@ suite('Keyboard navigation on Fields', function () {
   test('Left from second field selects first field', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeByIdAndFieldName(
-      this.browser,
-      'p5_canvas_1',
-      'HEIGHT',
-    );
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowLeft);
+    await focusOnBlockField(this.browser, 'p5_canvas_1', 'HEIGHT');
     await this.browser.pause(PAUSE_TIME);
+    await keyLeft(this.browser);
 
     chai
       .expect(await getCurrentFocusNodeId(this.browser))
@@ -352,14 +316,9 @@ suite('Keyboard navigation on Fields', function () {
   test('Right from second field selects next block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeByIdAndFieldName(
-      this.browser,
-      'p5_canvas_1',
-      'HEIGHT',
-    );
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowRight);
+    await focusOnBlockField(this.browser, 'p5_canvas_1', 'HEIGHT');
     await this.browser.pause(PAUSE_TIME);
+    await keyRight(this.browser);
 
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
@@ -369,14 +328,9 @@ suite('Keyboard navigation on Fields', function () {
   test('Down from field selects next block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeByIdAndFieldName(
-      this.browser,
-      'p5_canvas_1',
-      'WIDTH',
-    );
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowDown);
+    await focusOnBlockField(this.browser, 'p5_canvas_1', 'WIDTH');
     await this.browser.pause(PAUSE_TIME);
+    await keyDown(this.browser);
 
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
@@ -386,14 +340,9 @@ suite('Keyboard navigation on Fields', function () {
   test("Down from field selects block's child block", async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeByIdAndFieldName(
-      this.browser,
-      'controls_repeat_1',
-      'TIMES',
-    );
-    await this.browser.pause(PAUSE_TIME);
-    await this.browser.keys(Key.ArrowDown);
+    await focusOnBlockField(this.browser, 'controls_repeat_1', 'TIMES');
     await this.browser.pause(PAUSE_TIME);
+    await keyDown(this.browser);
 
     chai
       .expect(await getCurrentFocusedBlockId(this.browser))
diff --git a/test/webdriverio/test/clipboard_test.ts b/test/webdriverio/test/clipboard_test.ts
index 2af136e4..c21a57ce 100644
--- a/test/webdriverio/test/clipboard_test.ts
+++ b/test/webdriverio/test/clipboard_test.ts
@@ -14,7 +14,7 @@ import {
   getSelectedBlockId,
   ElementWithId,
   tabNavigateToWorkspace,
-  setCurrentCursorNodeById,
+  focusOnBlock,
 } from './test_setup.js';
 import {Key, KeyAction, PointerAction, WheelAction} from 'webdriverio';
 
@@ -31,7 +31,7 @@ suite('Clipboard test', function () {
   test('Copy and paste while block selected', async function () {
     // Navigate to draw_circle_1.
     await tabNavigateToWorkspace(this.browser);
-    await setCurrentCursorNodeById(this.browser, 'draw_circle_1');
+    await focusOnBlock(this.browser, 'draw_circle_1');
 
     // Copy and paste
     await this.browser.keys([Key.Ctrl, 'c']);
@@ -52,7 +52,7 @@ suite('Clipboard test', function () {
   test('Cut and paste while block selected', async function () {
     // Navigate to draw_circle_1.
     await tabNavigateToWorkspace(this.browser);
-    await setCurrentCursorNodeById(this.browser, 'draw_circle_1');
+    await focusOnBlock(this.browser, 'draw_circle_1');
     const block = await getBlockElementById(this.browser, 'draw_circle_1');
 
     // Cut and paste
diff --git a/test/webdriverio/test/delete_test.ts b/test/webdriverio/test/delete_test.ts
index 2cd35391..aa7a5eea 100644
--- a/test/webdriverio/test/delete_test.ts
+++ b/test/webdriverio/test/delete_test.ts
@@ -7,7 +7,7 @@
 import * as chai from 'chai';
 import {
   blockIsPresent,
-  setCurrentCursorNodeById,
+  focusOnBlock,
   getCurrentFocusedBlockId,
   getFocusedBlockType,
   moveToToolboxCategory,
@@ -15,6 +15,7 @@ import {
   testFileLocations,
   PAUSE_TIME,
   tabNavigateToWorkspace,
+  keyRight,
 } from './test_setup.js';
 import {Key} from 'webdriverio';
 
@@ -30,7 +31,7 @@ suite('Deleting Blocks', function () {
   test('Deleting block selects parent block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'controls_if_2');
+    await focusOnBlock(this.browser, 'controls_if_2');
     await this.browser.pause(PAUSE_TIME);
 
     chai
@@ -52,7 +53,7 @@ suite('Deleting Blocks', function () {
   test('Cutting block selects parent block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'controls_if_2');
+    await focusOnBlock(this.browser, 'controls_if_2');
     await this.browser.pause(PAUSE_TIME);
 
     chai
@@ -74,7 +75,7 @@ suite('Deleting Blocks', function () {
   test('Deleting block also deletes children and inputs', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'controls_if_2');
+    await focusOnBlock(this.browser, 'controls_if_2');
     await this.browser.pause(PAUSE_TIME);
 
     chai
@@ -96,7 +97,7 @@ suite('Deleting Blocks', function () {
   test('Cutting block also removes children and inputs', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'controls_if_2');
+    await focusOnBlock(this.browser, 'controls_if_2');
     await this.browser.pause(PAUSE_TIME);
 
     chai
@@ -118,7 +119,7 @@ suite('Deleting Blocks', function () {
   test('Deleting inline input selects parent block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'logic_boolean_1');
+    await focusOnBlock(this.browser, 'logic_boolean_1');
     await this.browser.pause(PAUSE_TIME);
 
     chai
@@ -140,7 +141,7 @@ suite('Deleting Blocks', function () {
   test('Cutting inline input selects parent block', async function () {
     await tabNavigateToWorkspace(this.browser);
     await this.browser.pause(PAUSE_TIME);
-    await setCurrentCursorNodeById(this.browser, 'logic_boolean_1');
+    await focusOnBlock(this.browser, 'logic_boolean_1');
     await this.browser.pause(PAUSE_TIME);
 
     chai
@@ -172,8 +173,7 @@ suite('Deleting Blocks', function () {
     await moveToToolboxCategory(this.browser, 'Math');
     await this.browser.pause(PAUSE_TIME);
     // Move to flyout.
-    await this.browser.keys(Key.ArrowRight);
-    await this.browser.pause(PAUSE_TIME);
+    await keyRight(this.browser);
     // Select number block.
     await this.browser.keys(Key.Enter);
     await this.browser.pause(PAUSE_TIME);
@@ -200,8 +200,7 @@ suite('Deleting Blocks', function () {
     await moveToToolboxCategory(this.browser, 'Math');
     await this.browser.pause(PAUSE_TIME);
     // Move to flyout.
-    await this.browser.keys(Key.ArrowRight);
-    await this.browser.pause(PAUSE_TIME);
+    await keyRight(this.browser);
     // Select number block.
     await this.browser.keys(Key.Enter);
     await this.browser.pause(PAUSE_TIME);
diff --git a/test/webdriverio/test/flyout_test.ts b/test/webdriverio/test/flyout_test.ts
new file mode 100644
index 00000000..83dc7c14
--- /dev/null
+++ b/test/webdriverio/test/flyout_test.ts
@@ -0,0 +1,290 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as chai from 'chai';
+import * as Blockly from 'blockly';
+import {
+  testSetup,
+  testFileLocations,
+  PAUSE_TIME,
+  tabNavigateForward,
+  keyDown,
+  tabNavigateBackward,
+  tabNavigateToWorkspace,
+  keyRight,
+  getCurrentFocusNodeId,
+  getCurrentFocusedBlockId,
+} from './test_setup.js';
+
+suite('Toolbox and flyout test', function () {
+  // Clear the workspace and load start blocks
+  setup(async function () {
+    this.browser = await testSetup(testFileLocations.BASE);
+    await this.browser.pause(PAUSE_TIME);
+  });
+
+  test('Tab navigating to toolbox should open flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped).
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateForward(this.browser);
+
+    // The flyout should now be open.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isTrue(flyoutIsOpen);
+  });
+
+  test('Tab navigating to flyout should auto-select first block', async function () {
+    // Three tabs should navigate to the flyout (initial element is skipped).
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateForward(this.browser);
+
+    // The top block of the category should be automatically selected. See:
+    // https://github.com/google/blockly/issues/8978.
+    const blockId = await getCurrentFocusedBlockId(this.browser);
+    chai.assert.strictEqual(blockId, 'if_block');
+  });
+
+  test('Tab navigating to toolbox then right arrow key should auto-select first block in flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped). One
+    // right arrow key should select a block on the flyout.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyRight(this.browser);
+
+    // The top block of the category should be automatically selected. See:
+    // https://github.com/google/blockly/issues/8978.
+    const blockId = await getCurrentFocusedBlockId(this.browser);
+    chai.assert.strictEqual(blockId, 'if_block');
+  });
+
+  test('Keyboard nav to different toolbox category should auto-select first block', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then keys for a different category with a tab to select the flyout.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyDown(this.browser, 3);
+    await tabNavigateForward(this.browser);
+
+    // The top block of the category should be automatically selected.
+    const blockId = await getCurrentFocusedBlockId(this.browser);
+    chai.assert.strictEqual(blockId, 'text_block');
+  });
+
+  test('Keyboard nav to different toolbox category and block should select different block', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then keys for a different category with a tab to select the flyout and
+    // finally keys to select a different block.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyDown(this.browser, 3);
+    await tabNavigateForward(this.browser);
+    await keyDown(this.browser, 2);
+
+    // A non-top blockshould be manually selected.
+    const blockId = await getCurrentFocusedBlockId(this.browser);
+    chai.assert.strictEqual(blockId, 'append_text_block');
+  });
+
+  test('Tab navigate away from toolbox restores focus to initial element', async function () {
+    // Two tabs should navigate to the toolbox. One tab back should leave it.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // Focus should restore to the initial div element. See:
+    // https://github.com/google/blockly-keyboard-experimentation/issues/523.
+    const activeElementId = await this.browser.execute(
+      () => document.activeElement?.id,
+    );
+    chai.assert.strictEqual(activeElementId, 'focusableDiv');
+  });
+
+  test('Tab navigate away from toolbox closes flyout', async function () {
+    // Two tabs should navigate to the toolbox. One tab back should leave it.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be closed since the toolbox lost focus. See:
+    // https://github.com/google/blockly/issues/8970.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tab navigate away from flyout to toolbox and away closes flyout', async function () {
+    // Three tabs should navigate to the flyout, and two tabs back should close.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be closed since the toolbox lost focus.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace should close the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    // The flyout should be closed since it lost focus.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace after selecting flyout block should close the flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then use right key to specifically navigate into the flyout before
+    // navigating to the workspace.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyRight(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The flyout should be closed since it lost focus. See:
+    // https://github.com/google/blockly-keyboard-experimentation/issues/547.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace after selecting flyout block via workspace toolbox shortcut should close the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    await this.browser.keys('t');
+    await keyRight(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The flyout should now be closed and unfocused. See:
+    // https://github.com/google/blockly/pull/9079#issuecomment-2913759646.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing back from workspace should reopen the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be open again since the toolbox is again focused. See:
+    // https://github.com/google/blockly/issues/8965.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isTrue(flyoutIsOpen);
+  });
+
+  test('Navigation position in workspace should be retained when tabbing to flyout and back', async function () {
+    // Navigate to the workspace and select a non-default block.
+    await tabNavigateToWorkspace(this.browser);
+
+    // Note that two tabs are needed here to move past the flyout.
+    await keyDown(this.browser, 3);
+    await tabNavigateBackward(this.browser);
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The previously selected block should be retained upon returning. See:
+    // https://github.com/google/blockly/issues/8965#issuecomment-2900479280.
+    const blockId = await getCurrentFocusedBlockId(this.browser);
+    chai.assert.strictEqual(blockId, 'p5_draw_1');
+  });
+
+  test('Clicking outside Blockly with focused toolbox closes the flyout', async function () {
+    // Two tabs should navigate to the toolbox. One click to unfocus.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    const elem = this.browser.$('#focusableDiv');
+    await elem.click();
+
+    // The flyout should be closed due to clicking an outside element.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Clicking outside Blockly with focused flyout closes the flyout', async function () {
+    // Two tabs should navigate to the toolbox. One key to select the flyout.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+    await keyRight(this.browser);
+
+    const elem = this.browser.$('#focusableDiv');
+    await elem.click();
+
+    // The flyout should be closed due to clicking an outside element. See:
+    // https://github.com/google/blockly/pull/9079#issuecomment-2914628810.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Clicking on toolbox category focuses it and opens flyout', async function () {
+    const elemId = await findToolboxCategoryIdByName(this.browser, 'Loops');
+    const elem = this.browser.$(`#${elemId}`);
+    await elem.click();
+
+    // The clicked category should now be focused.
+    const focusedId = await getCurrentFocusNodeId(this.browser);
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.strictEqual(focusedId, elemId);
+    chai.assert.isTrue(flyoutIsOpen);
+  });
+});
+
+/**
+ * Checks if the flyout is currently open.
+ *
+ * This throws an error if the current main workspace has no flyout.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @returns A promise indicating whether the flyout is currently open.
+ */
+async function checkIfFlyoutIsOpen(
+  browser: WebdriverIO.Browser,
+): Promise<boolean> {
+  return await browser.execute(() => {
+    const workspaceSvg = Blockly.getMainWorkspace() as Blockly.WorkspaceSvg;
+    const flyout = workspaceSvg.getFlyout();
+    if (!flyout) throw new Error('Workspace has no flyout.');
+    return flyout.isVisible();
+  });
+}
+
+/**
+ * Finds the element ID of the toolbox category with the specified name.
+ *
+ * This throws an error if the current main workspace has no toolbox or the
+ * toolbox does not have a category with the specified name.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param name The name of the category to find.
+ * @returns A promise with the focusable element ID of the sought category.
+ */
+async function findToolboxCategoryIdByName(
+  browser: WebdriverIO.Browser,
+  name: string,
+): Promise<string> {
+  return await browser.execute((name) => {
+    const workspaceSvg = Blockly.getMainWorkspace() as Blockly.WorkspaceSvg;
+    const toolbox = workspaceSvg.getToolbox() as Blockly.Toolbox;
+    if (!toolbox) throw new Error('Workspace has no toolbox.');
+
+    for (const item of toolbox.getToolboxItems()) {
+      if (item instanceof Blockly.ToolboxCategory && item.getName() === name) {
+        return item.getFocusableElement().id;
+      }
+    }
+
+    throw new Error(`No category found with name: ${name}.`);
+  }, name);
+}
diff --git a/test/webdriverio/test/insert_test.ts b/test/webdriverio/test/insert_test.ts
index 863f7461..fefa32a8 100644
--- a/test/webdriverio/test/insert_test.ts
+++ b/test/webdriverio/test/insert_test.ts
@@ -10,10 +10,11 @@ import {
   getFocusedBlockType,
   moveToToolboxCategory,
   PAUSE_TIME,
-  setCurrentCursorNodeById,
+  focusOnBlock,
   tabNavigateToWorkspace,
   testFileLocations,
   testSetup,
+  keyRight,
 } from './test_setup.js';
 
 suite('Insert test', function () {
@@ -29,11 +30,11 @@ suite('Insert test', function () {
   test('Insert C-shaped block with statement block selected', async function () {
     // Navigate to draw_circle_1.
     await tabNavigateToWorkspace(this.browser);
-    await setCurrentCursorNodeById(this.browser, 'draw_circle_1');
+    await focusOnBlock(this.browser, 'draw_circle_1');
 
     await moveToToolboxCategory(this.browser, 'Functions');
     // Move to flyout.
-    await this.browser.keys(Key.ArrowRight);
+    await keyRight(this.browser);
     // Select Function block.
     await this.browser.keys(Key.Enter);
     // Confirm move.
diff --git a/test/webdriverio/test/move_test.ts b/test/webdriverio/test/move_test.ts
index 7a784f0b..d5d911ac 100644
--- a/test/webdriverio/test/move_test.ts
+++ b/test/webdriverio/test/move_test.ts
@@ -9,7 +9,7 @@ import * as chai from 'chai';
 import {Browser, Key} from 'webdriverio';
 import {
   PAUSE_TIME,
-  setCurrentCursorNodeById,
+  focusOnBlock,
   tabNavigateToWorkspace,
   testFileLocations,
   testSetup,
@@ -34,7 +34,7 @@ suite('Move tests', function () {
     for (let i = 1; i < 7; i++) {
       // Navigate to statement_<i>.
       await tabNavigateToWorkspace(this.browser);
-      await setCurrentCursorNodeById(this.browser, `statement_${i}`);
+      await focusOnBlock(this.browser, `statement_${i}`);
 
       // Get information about parent connection of selected block,
       // and block connected to selected block's next connection.
@@ -94,7 +94,7 @@ suite('Move tests', function () {
     for (let i = 1; i < 7; i++) {
       // Navigate to statement_<i>.
       await tabNavigateToWorkspace(this.browser);
-      await setCurrentCursorNodeById(this.browser, `value_${i}`);
+      await focusOnBlock(this.browser, `value_${i}`);
 
       // Get information about parent connection of selected block,
       // and block connected to selected block's value input.
diff --git a/test/webdriverio/test/test_setup.ts b/test/webdriverio/test/test_setup.ts
index 85f0bf0f..154e4593 100644
--- a/test/webdriverio/test/test_setup.ts
+++ b/test/webdriverio/test/test_setup.ts
@@ -195,9 +195,7 @@ export async function moveToToolboxCategory(
   if (categoryIndex < 0) {
     throw new Error(`No category found: ${category}`);
   }
-  if (categoryIndex > 0) {
-    await browser.keys(webdriverio.Key.ArrowDown.repeat(categoryIndex));
-  }
+  if (categoryIndex > 0) await keyDown(browser, categoryIndex);
 }
 
 /**
@@ -232,32 +230,36 @@ export async function currentFocusIsMainWorkspace(
 }
 
 /**
- * Select a block with the given id as the current cursor node.
+ * Focuses and selects a block with the provided ID.
+ *
+ * This throws an error if no block exists for the specified ID.
  *
  * @param browser The active WebdriverIO Browser object.
- * @param blockId The id of the block to select.
+ * @param blockId The ID of the block to select.
  */
-export async function setCurrentCursorNodeById(
+export async function focusOnBlock(
   browser: WebdriverIO.Browser,
   blockId: string,
 ) {
   return await browser.execute((blockId) => {
     const workspaceSvg = Blockly.getMainWorkspace() as Blockly.WorkspaceSvg;
     const block = workspaceSvg.getBlockById(blockId);
-    if (block) {
-      block.getFocusableElement()?.focus();
-    }
+    if (!block) throw new Error(`No block found with ID: ${blockId}.`);
+    Blockly.getFocusManager().focusNode(block);
   }, blockId);
 }
 
 /**
- * Select a block's field with the given block id and field name.
+ * Focuses and selects the field of a block given a block ID and field name.
+ *
+ * This throws an error if no block exists for the specified ID, or if the block
+ * corresponding to the specified ID has no field with the provided name.
  *
  * @param browser The active WebdriverIO Browser object.
- * @param blockId The id of the block to select.
+ * @param blockId The ID of the block to select.
  * @param fieldName The name of the field on the block to select.
  */
-export async function setCurrentCursorNodeByIdAndFieldName(
+export async function focusOnBlockField(
   browser: WebdriverIO.Browser,
   blockId: string,
   fieldName: string,
@@ -266,14 +268,12 @@ export async function setCurrentCursorNodeByIdAndFieldName(
     (blockId, fieldName) => {
       const workspaceSvg = Blockly.getMainWorkspace() as Blockly.WorkspaceSvg;
       const block = workspaceSvg.getBlockById(blockId);
-      const field = block?.getField(fieldName);
-      if (field) {
-        // TODO: Stop referencing getCursor() and use focus() instead.
-        // getCursor().setCurNode() calls Marker.setCurNode(), but focus() does
-        // not accomplish the same goal yet.
-        workspaceSvg.getCursor()?.setCurNode(field);
-        // field.getFocusableElement()?.focus();
+      if (!block) throw new Error(`No block found with ID: ${blockId}.`);
+      const field = block.getField(fieldName);
+      if (!field) {
+        throw new Error(`No field found: ${fieldName} (block ${blockId}).`);
       }
+      Blockly.getFocusManager().focusNode(field);
     },
     blockId,
     fieldName,
@@ -403,6 +403,8 @@ export async function tabNavigateToWorkspace(
   hasToolbox = true,
   hasFlyout = true,
 ) {
+  // Navigate past the initial pre-injection focusable div element.
+  tabNavigateForward(browser);
   if (hasToolbox) tabNavigateForward(browser);
   if (hasFlyout) tabNavigateForward(browser);
   tabNavigateForward(browser); // Tab to the workspace itself.
@@ -418,6 +420,75 @@ export async function tabNavigateForward(browser: WebdriverIO.Browser) {
   await browser.pause(PAUSE_TIME);
 }
 
+/**
+ * Navigates backward to the test page's previous tab stop.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ */
+export async function tabNavigateBackward(browser: WebdriverIO.Browser) {
+  await browser.keys([webdriverio.Key.Shift, webdriverio.Key.Tab]);
+  await browser.pause(PAUSE_TIME);
+}
+
+/**
+ * Sends the keyboard event for arrow key left.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyLeft(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowLeft, times);
+}
+
+/**
+ * Sends the keyboard event for arrow key right.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyRight(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowRight, times);
+}
+
+/**
+ * Sends the keyboard event for arrow key up.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyUp(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowUp, times);
+}
+
+/**
+ * Sends the keyboard event for arrow key down.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyDown(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowDown, times);
+}
+
+/**
+ * Sends the specified key for the specified number of times, waiting between
+ * each key press to allow changes to keep up.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param key The WebdriverIO representative key value to press.
+ * @param times The number of times to repeat the key press.
+ */
+async function sendKeyAndWait(
+  browser: WebdriverIO.Browser,
+  key: string,
+  times: number,
+) {
+  for (let i = 0; i < times; i++) {
+    await browser.keys(key);
+    await browser.pause(PAUSE_TIME);
+  }
+}
+
 /**
  * Returns whether there's a drag in progress on the main workspace.
  *


Reviews:
user: login: BenHenning
body: Basic self-review (mostly a quick spot check).
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-29T01:40:27Z
user: login: cpcallen
body: A few minor suggestions—but definitely acceptable as-is.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-29T16:49:12Z
user: login: BenHenning
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-29T17:35:14Z
user: login: BenHenning
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-29T17:36:05Z
user: login: BenHenning
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-29T18:00:15Z
user: login: BenHenning
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-29T18:21:35Z
user: login: BenHenning
body: Self-reviewed latest (spot check).
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-29T19:14:30Z
user: login: cpcallen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-30T16:46:20Z

Review comments:
diff_hunk: @@ -418,6 +420,75 @@ export async function tabNavigateForward(browser: WebdriverIO.Browser) {
   await browser.pause(PAUSE_TIME);
 }
 
+/**
+ * Navigates backward to the test page's previous tab stop.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ */
+export async function tabNavigateBackward(browser: WebdriverIO.Browser) {
+  await browser.keys([webdriverio.Key.Shift, webdriverio.Key.Tab]);
+  await browser.pause(PAUSE_TIME);
+}
+
+/**
+ * Sends the keyboard event for arrow key left.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyLeft(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowLeft, times);
+}
+
+/**
+ * Sends the keyboard event for arrow key right.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyRight(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowRight, times);
+}
+
+/**
+ * Sends the keyboard event for arrow key up.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyUp(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowUp, times);
+}
+
+/**
+ * Sends the keyboard event for arrow key down.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyDown(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowDown, times);
+}
+
+/**
+ * Sends the specified key for the specified number of times, waiting between
+ * each key press to allow changes to keep up.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param key The WebdriverIO representative key value to press.
+ * @param times The number of times to repeat the key press.
+ */
+async function sendKeyAndWait(
+  browser: WebdriverIO.Browser,
+  key: string,
path: test/webdriverio/test/test_setup.ts
user: login: cpcallen
body: Any reason not to make this accept everything that `browser.keys` does?  Then you could call it from `tabNavigateForward` and `tabNavigateBackward` (and other places too, if exported).
created_at: 2025-05-29T16:26:18Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,290 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as chai from 'chai';
+import * as Blockly from 'blockly';
+import {
+  testSetup,
+  testFileLocations,
+  PAUSE_TIME,
+  tabNavigateForward,
+  getFocusedBlockType,
+  keyDown,
+  tabNavigateBackward,
+  tabNavigateToWorkspace,
+  keyRight,
+  getCurrentFocusNodeId,
+} from './test_setup.js';
+
+suite('Toolbox and flyout test', function () {
+  // Clear the workspace and load start blocks
+  setup(async function () {
+    this.browser = await testSetup(testFileLocations.BASE);
+    await this.browser.pause(PAUSE_TIME);
+  });
+
+  test('Tab navigating to toolbox should open flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped).
+    await tabNavigateForward(this.browser);
+
path: test/webdriverio/test/flyout_test.ts
user: login: cpcallen
body: Delete seemingly spurious blank line?  (Here and on several more test cases below).
created_at: 2025-05-29T16:37:27Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,290 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as chai from 'chai';
+import * as Blockly from 'blockly';
+import {
+  testSetup,
+  testFileLocations,
+  PAUSE_TIME,
+  tabNavigateForward,
+  getFocusedBlockType,
+  keyDown,
+  tabNavigateBackward,
+  tabNavigateToWorkspace,
+  keyRight,
+  getCurrentFocusNodeId,
+} from './test_setup.js';
+
+suite('Toolbox and flyout test', function () {
+  // Clear the workspace and load start blocks
+  setup(async function () {
+    this.browser = await testSetup(testFileLocations.BASE);
+    await this.browser.pause(PAUSE_TIME);
+  });
+
+  test('Tab navigating to toolbox should open flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped).
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateForward(this.browser);
+
+    // The flyout should now be open.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isTrue(flyoutIsOpen);
+  });
+
+  test('Tab navigating to flyout should auto-select first block', async function () {
+    // Three tabs should navigate to the flyout (initial element is skipped).
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateForward(this.browser);
+
+    // The top block of the category should be automatically selected. See:
+    // https://github.com/google/blockly/issues/8978.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'controls_if');
+  });
+
+  test('Tab navigating to toolbox then right arrow key should auto-select first block in flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped). One
+    // right arrow key should select a block on the flyout.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyRight(this.browser);
+
+    // The top block of the category should be automatically selected. See:
+    // https://github.com/google/blockly/issues/8978.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'controls_if');
+  });
+
+  test('Keyboard nav to different toolbox category should auto-select first block', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then keys for a different category with a tab to select the flyout.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyDown(this.browser, 3);
+    await tabNavigateForward(this.browser);
+
+    // The top block of the category should be automatically selected.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'text');
+  });
+
+  test('Keyboard nav to different toolbox category and block should select different block', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then keys for a different category with a tab to select the flyout and
+    // finally keys to select a different block.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyDown(this.browser, 3);
+    await tabNavigateForward(this.browser);
+    await keyDown(this.browser, 2);
+
+    // A non-top blockshould be manually selected.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'text_append');
+  });
+
+  test('Tab navigate away from toolbox restores focus to initial element', async function () {
+    // Two tabs should navigate to the toolbox. One tab back should leave it.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // Focus should restore to the initial div element. See:
+    // https://github.com/google/blockly-keyboard-experimentation/issues/523.
+    const activeElementId = await this.browser.execute(
+      () => document.activeElement?.id,
+    );
+    chai.assert.strictEqual(activeElementId, 'focusableDiv');
+  });
+
+  test('Tab navigate away from toolbox closes flyout', async function () {
+    // Two tabs should navigate to the toolbox. One tab back should leave it.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be closed since the toolbox lost focus. See:
+    // https://github.com/google/blockly/issues/8970.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tab navigate away from flyout to toolbox and away closes flyout', async function () {
+    // Three tabs should navigate to the flyout, and two tabs back should close.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be closed since the toolbox lost focus.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace should close the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    // The flyout should be closed since it lost focus.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace after selecting flyout block should close the flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then use right key to specifically navigate into the flyout before
+    // navigating to the workspace.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyRight(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The flyout should be closed since it lost focus. See:
+    // https://github.com/google/blockly-keyboard-experimentation/issues/547.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace after selecting flyout block via workspace toolbox shortcut should close the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    await this.browser.keys('t');
+    await keyRight(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The flyout should now be closed and unfocused. See:
+    // https://github.com/google/blockly/pull/9079#issuecomment-2913759646.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing back from workspace should reopen the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be open again since the toolbox is again focused. See:
+    // https://github.com/google/blockly/issues/8965.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isTrue(flyoutIsOpen);
+  });
+
+  test('Navigation position in workspace should be retained when tabbing to flyout and back', async function () {
+    // Navigate to the workspace and select a non-default block.
+    await tabNavigateToWorkspace(this.browser);
+
+    // Note that two tabs are needed here to move past the flyout.
+    await keyDown(this.browser, 3);
+    await tabNavigateBackward(this.browser);
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The previously selected block should be retained upon returning. See:
+    // https://github.com/google/blockly/issues/8965#issuecomment-2900479280.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'p5_draw');
path: test/webdriverio/test/flyout_test.ts
user: login: cpcallen
body: Maybe I'm being excessively picky, especially since the draw block is supposed to be unique, but since this is a block loaded from the scenario I think I'd check the block's ID rather than type—and to facilitate that, make sure that the JSON being loaded specified a nice human one.
created_at: 2025-05-29T16:47:43Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,290 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as chai from 'chai';
+import * as Blockly from 'blockly';
+import {
+  testSetup,
+  testFileLocations,
+  PAUSE_TIME,
+  tabNavigateForward,
+  getFocusedBlockType,
+  keyDown,
+  tabNavigateBackward,
+  tabNavigateToWorkspace,
+  keyRight,
+  getCurrentFocusNodeId,
+} from './test_setup.js';
+
+suite('Toolbox and flyout test', function () {
+  // Clear the workspace and load start blocks
+  setup(async function () {
+    this.browser = await testSetup(testFileLocations.BASE);
+    await this.browser.pause(PAUSE_TIME);
+  });
+
+  test('Tab navigating to toolbox should open flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped).
+    await tabNavigateForward(this.browser);
+
path: test/webdriverio/test/flyout_test.ts
user: login: BenHenning
body: These actually aren't spurious. I usually format my tests always into two or three blocks for distinct arrange-act-assert sections (to make it very clear to readers which part of the test is setup, the actual operation being verified, and the code for doing that verifying).
created_at: 2025-05-29T17:35:14Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,290 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as chai from 'chai';
+import * as Blockly from 'blockly';
+import {
+  testSetup,
+  testFileLocations,
+  PAUSE_TIME,
+  tabNavigateForward,
+  getFocusedBlockType,
+  keyDown,
+  tabNavigateBackward,
+  tabNavigateToWorkspace,
+  keyRight,
+  getCurrentFocusNodeId,
+} from './test_setup.js';
+
+suite('Toolbox and flyout test', function () {
+  // Clear the workspace and load start blocks
+  setup(async function () {
+    this.browser = await testSetup(testFileLocations.BASE);
+    await this.browser.pause(PAUSE_TIME);
+  });
+
+  test('Tab navigating to toolbox should open flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped).
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateForward(this.browser);
+
+    // The flyout should now be open.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isTrue(flyoutIsOpen);
+  });
+
+  test('Tab navigating to flyout should auto-select first block', async function () {
+    // Three tabs should navigate to the flyout (initial element is skipped).
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateForward(this.browser);
+
+    // The top block of the category should be automatically selected. See:
+    // https://github.com/google/blockly/issues/8978.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'controls_if');
+  });
+
+  test('Tab navigating to toolbox then right arrow key should auto-select first block in flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped). One
+    // right arrow key should select a block on the flyout.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyRight(this.browser);
+
+    // The top block of the category should be automatically selected. See:
+    // https://github.com/google/blockly/issues/8978.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'controls_if');
+  });
+
+  test('Keyboard nav to different toolbox category should auto-select first block', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then keys for a different category with a tab to select the flyout.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyDown(this.browser, 3);
+    await tabNavigateForward(this.browser);
+
+    // The top block of the category should be automatically selected.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'text');
+  });
+
+  test('Keyboard nav to different toolbox category and block should select different block', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then keys for a different category with a tab to select the flyout and
+    // finally keys to select a different block.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyDown(this.browser, 3);
+    await tabNavigateForward(this.browser);
+    await keyDown(this.browser, 2);
+
+    // A non-top blockshould be manually selected.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'text_append');
+  });
+
+  test('Tab navigate away from toolbox restores focus to initial element', async function () {
+    // Two tabs should navigate to the toolbox. One tab back should leave it.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // Focus should restore to the initial div element. See:
+    // https://github.com/google/blockly-keyboard-experimentation/issues/523.
+    const activeElementId = await this.browser.execute(
+      () => document.activeElement?.id,
+    );
+    chai.assert.strictEqual(activeElementId, 'focusableDiv');
+  });
+
+  test('Tab navigate away from toolbox closes flyout', async function () {
+    // Two tabs should navigate to the toolbox. One tab back should leave it.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be closed since the toolbox lost focus. See:
+    // https://github.com/google/blockly/issues/8970.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tab navigate away from flyout to toolbox and away closes flyout', async function () {
+    // Three tabs should navigate to the flyout, and two tabs back should close.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be closed since the toolbox lost focus.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace should close the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    // The flyout should be closed since it lost focus.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace after selecting flyout block should close the flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then use right key to specifically navigate into the flyout before
+    // navigating to the workspace.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyRight(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The flyout should be closed since it lost focus. See:
+    // https://github.com/google/blockly-keyboard-experimentation/issues/547.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace after selecting flyout block via workspace toolbox shortcut should close the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    await this.browser.keys('t');
+    await keyRight(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The flyout should now be closed and unfocused. See:
+    // https://github.com/google/blockly/pull/9079#issuecomment-2913759646.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing back from workspace should reopen the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be open again since the toolbox is again focused. See:
+    // https://github.com/google/blockly/issues/8965.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isTrue(flyoutIsOpen);
+  });
+
+  test('Navigation position in workspace should be retained when tabbing to flyout and back', async function () {
+    // Navigate to the workspace and select a non-default block.
+    await tabNavigateToWorkspace(this.browser);
+
+    // Note that two tabs are needed here to move past the flyout.
+    await keyDown(this.browser, 3);
+    await tabNavigateBackward(this.browser);
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The previously selected block should be retained upon returning. See:
+    // https://github.com/google/blockly/issues/8965#issuecomment-2900479280.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'p5_draw');
path: test/webdriverio/test/flyout_test.ts
user: login: BenHenning
body: I think I was noticing that the IDs were being generated and not retained in the workspace for whatever reason, but I will give this a try and follow up.
created_at: 2025-05-29T17:36:05Z
author_association: COLLABORATOR
diff_hunk: @@ -418,6 +420,75 @@ export async function tabNavigateForward(browser: WebdriverIO.Browser) {
   await browser.pause(PAUSE_TIME);
 }
 
+/**
+ * Navigates backward to the test page's previous tab stop.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ */
+export async function tabNavigateBackward(browser: WebdriverIO.Browser) {
+  await browser.keys([webdriverio.Key.Shift, webdriverio.Key.Tab]);
+  await browser.pause(PAUSE_TIME);
+}
+
+/**
+ * Sends the keyboard event for arrow key left.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyLeft(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowLeft, times);
+}
+
+/**
+ * Sends the keyboard event for arrow key right.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyRight(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowRight, times);
+}
+
+/**
+ * Sends the keyboard event for arrow key up.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyUp(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowUp, times);
+}
+
+/**
+ * Sends the keyboard event for arrow key down.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param times The number of times to repeat the key press (default is 1).
+ */
+export async function keyDown(browser: WebdriverIO.Browser, times = 1) {
+  await sendKeyAndWait(browser, webdriverio.Key.ArrowDown, times);
+}
+
+/**
+ * Sends the specified key for the specified number of times, waiting between
+ * each key press to allow changes to keep up.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param key The WebdriverIO representative key value to press.
+ * @param times The number of times to repeat the key press.
+ */
+async function sendKeyAndWait(
+  browser: WebdriverIO.Browser,
+  key: string,
path: test/webdriverio/test/test_setup.ts
user: login: BenHenning
body: I'm not opposed, but I think I'll do that in a follow-up if there's no concerns there (since I'd rather move everything over and that's a bit of a scope expansion for this PR).

I also don't think I want to exactly mimic the contract of `browser.keys` since it can do some inadvertent key repeats (which we actually use elsewhere), but allowing it to include meta keys is a good point.
created_at: 2025-05-29T18:00:15Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,290 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as chai from 'chai';
+import * as Blockly from 'blockly';
+import {
+  testSetup,
+  testFileLocations,
+  PAUSE_TIME,
+  tabNavigateForward,
+  getFocusedBlockType,
+  keyDown,
+  tabNavigateBackward,
+  tabNavigateToWorkspace,
+  keyRight,
+  getCurrentFocusNodeId,
+} from './test_setup.js';
+
+suite('Toolbox and flyout test', function () {
+  // Clear the workspace and load start blocks
+  setup(async function () {
+    this.browser = await testSetup(testFileLocations.BASE);
+    await this.browser.pause(PAUSE_TIME);
+  });
+
+  test('Tab navigating to toolbox should open flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped).
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateForward(this.browser);
+
+    // The flyout should now be open.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isTrue(flyoutIsOpen);
+  });
+
+  test('Tab navigating to flyout should auto-select first block', async function () {
+    // Three tabs should navigate to the flyout (initial element is skipped).
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateForward(this.browser);
+
+    // The top block of the category should be automatically selected. See:
+    // https://github.com/google/blockly/issues/8978.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'controls_if');
+  });
+
+  test('Tab navigating to toolbox then right arrow key should auto-select first block in flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped). One
+    // right arrow key should select a block on the flyout.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyRight(this.browser);
+
+    // The top block of the category should be automatically selected. See:
+    // https://github.com/google/blockly/issues/8978.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'controls_if');
+  });
+
+  test('Keyboard nav to different toolbox category should auto-select first block', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then keys for a different category with a tab to select the flyout.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyDown(this.browser, 3);
+    await tabNavigateForward(this.browser);
+
+    // The top block of the category should be automatically selected.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'text');
+  });
+
+  test('Keyboard nav to different toolbox category and block should select different block', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then keys for a different category with a tab to select the flyout and
+    // finally keys to select a different block.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyDown(this.browser, 3);
+    await tabNavigateForward(this.browser);
+    await keyDown(this.browser, 2);
+
+    // A non-top blockshould be manually selected.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'text_append');
+  });
+
+  test('Tab navigate away from toolbox restores focus to initial element', async function () {
+    // Two tabs should navigate to the toolbox. One tab back should leave it.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // Focus should restore to the initial div element. See:
+    // https://github.com/google/blockly-keyboard-experimentation/issues/523.
+    const activeElementId = await this.browser.execute(
+      () => document.activeElement?.id,
+    );
+    chai.assert.strictEqual(activeElementId, 'focusableDiv');
+  });
+
+  test('Tab navigate away from toolbox closes flyout', async function () {
+    // Two tabs should navigate to the toolbox. One tab back should leave it.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be closed since the toolbox lost focus. See:
+    // https://github.com/google/blockly/issues/8970.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tab navigate away from flyout to toolbox and away closes flyout', async function () {
+    // Three tabs should navigate to the flyout, and two tabs back should close.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await tabNavigateBackward(this.browser);
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be closed since the toolbox lost focus.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace should close the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    // The flyout should be closed since it lost focus.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace after selecting flyout block should close the flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped),
+    // then use right key to specifically navigate into the flyout before
+    // navigating to the workspace.
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    await keyRight(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The flyout should be closed since it lost focus. See:
+    // https://github.com/google/blockly-keyboard-experimentation/issues/547.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing to the workspace after selecting flyout block via workspace toolbox shortcut should close the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    await this.browser.keys('t');
+    await keyRight(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The flyout should now be closed and unfocused. See:
+    // https://github.com/google/blockly/pull/9079#issuecomment-2913759646.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isFalse(flyoutIsOpen);
+  });
+
+  test('Tabbing back from workspace should reopen the flyout', async function () {
+    await tabNavigateToWorkspace(this.browser);
+
+    await tabNavigateBackward(this.browser);
+
+    // The flyout should be open again since the toolbox is again focused. See:
+    // https://github.com/google/blockly/issues/8965.
+    const flyoutIsOpen = await checkIfFlyoutIsOpen(this.browser);
+    chai.assert.isTrue(flyoutIsOpen);
+  });
+
+  test('Navigation position in workspace should be retained when tabbing to flyout and back', async function () {
+    // Navigate to the workspace and select a non-default block.
+    await tabNavigateToWorkspace(this.browser);
+
+    // Note that two tabs are needed here to move past the flyout.
+    await keyDown(this.browser, 3);
+    await tabNavigateBackward(this.browser);
+    await tabNavigateForward(this.browser);
+    await tabNavigateForward(this.browser);
+
+    // The previously selected block should be retained upon returning. See:
+    // https://github.com/google/blockly/issues/8965#issuecomment-2900479280.
+    const blockType = await getFocusedBlockType(this.browser);
+    chai.assert.strictEqual(blockType, 'p5_draw');
path: test/webdriverio/test/flyout_test.ts
user: login: BenHenning
body: Looks like this was as simple as just adding IDs to the toolbox JSON. Fixed in latest.
created_at: 2025-05-29T18:21:35Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,290 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as chai from 'chai';
+import * as Blockly from 'blockly';
+import {
+  testSetup,
+  testFileLocations,
+  PAUSE_TIME,
+  tabNavigateForward,
+  getFocusedBlockType,
+  keyDown,
+  tabNavigateBackward,
+  tabNavigateToWorkspace,
+  keyRight,
+  getCurrentFocusNodeId,
+} from './test_setup.js';
+
+suite('Toolbox and flyout test', function () {
+  // Clear the workspace and load start blocks
+  setup(async function () {
+    this.browser = await testSetup(testFileLocations.BASE);
+    await this.browser.pause(PAUSE_TIME);
+  });
+
+  test('Tab navigating to toolbox should open flyout', async function () {
+    // Two tabs should navigate to the toolbox (initial element is skipped).
+    await tabNavigateForward(this.browser);
+
path: test/webdriverio/test/flyout_test.ts
user: login: cpcallen
body: That totally makes sense, and I approve of the arrangment.

Were it my test I might have had a separate comments for the "arrange" and "act" sections.
created_at: 2025-05-30T16:46:20Z
author_association: COLLABORATOR

PR comments:
user: login: BenHenning
created_at: 2025-05-29T01:40:58Z
body: This can be reviewed now, but it must wait until https://github.com/google/blockly/pull/9079 is merged before it can also be merged (since it relies on those changes in order for CI to pass).
author_association: COLLABORATOR
user: login: rachel-fenichel
created_at: 2025-05-29T17:16:45Z
body: Because this change refactors tests and several in-flight changes add or modify tests, please coordinate with the rest of the team on merge order.

Also, I just updated the PR title to match conventional commits
author_association: COLLABORATOR
user: login: BenHenning
created_at: 2025-05-29T17:36:41Z
body: > Because this change refactors tests and several in-flight changes add or modify tests, please coordinate with the rest of the team on merge order.
> 
> Also, I just updated the PR title to match conventional commits

I somehow forgot to update the title--nice catch and thanks for fixing.
author_association: COLLABORATOR
user: login: BenHenning
created_at: 2025-05-29T19:13:39Z
body: #9079 is merged and CI is now passing. Just double checking the latest changes before merging this.
author_association: COLLABORATOR