Skip to content

Commit fb5d36e

Browse files
committed
feat: add new E2E story tests and improve existing test configurations
1 parent 506e0fe commit fb5d36e

21 files changed

Lines changed: 953 additions & 324 deletions

package-lock.json

Lines changed: 6 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"type-check": "npm run type-check -w @eclipse-docks/core",
2626
"test": "npm run test -w @eclipse-docks/core",
2727
"test:e2e": "npm run test:e2e -w @eclipse-docks/app-e2e",
28+
"test:e2e:stories": "npm run test:e2e:stories -w @eclipse-docks/app-e2e",
2829
"test:e2e:ui": "npm run test:e2e:ui -w @eclipse-docks/app-e2e",
2930
"playwright:install-chromium": "npm exec --workspace=@eclipse-docks/app-e2e -- playwright install chromium",
3031
"playwright:install-chromium-ci": "npm exec --workspace=@eclipse-docks/app-e2e -- playwright install chromium --with-deps",

packages/app-e2e/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ Minimal Docks shell for Playwright only (not the public demo app in `packages/ap
55
## Commands
66

77
- **From repo root:** `npm run test:e2e` / `npm run test:e2e:ui` (UI mode).
8+
- **Story specs** (`story-*.spec.ts`, clips + coverage): `npm run test:e2e:stories` — see [e2e/story-tests.md](./e2e/story-tests.md).
89
- **Browsers:** first-time setup from repo root: `npm run playwright:install-chromium`. On Linux CI images use `npm run playwright:install-chromium-ci` (installs Chromium plus system dependencies).
910

1011
## Config
1112

12-
- [playwright.config.ts](./playwright.config.ts)`webServer` builds core + this package, then serves preview on `127.0.0.1:4173`; `use.baseURL` matches that origin.
13+
- [playwright.config.ts](./playwright.config.ts)`webServer` runs **`npm run build -w @eclipse-docks/core && npm run build -w @eclipse-docks/app-e2e`**, then `vite preview` on `127.0.0.1:4173`; `use.baseURL` matches that origin.
14+
- **When that runs:** Playwright executes `webServer.command` whenever it **starts** the preview server. **Locally**, if something is already responding on `4173`, `reuseExistingServer` skips that command (faster reruns, but **no rebuild** — you may be testing stale assets). Stop the old preview or set **`PW_E2E_REUSE_SERVER=0`** when you change harness or core and need a fresh build. **CI** never reuses a server.
15+
- **`E2E_STORY`:** `npm run test:e2e` runs with **`E2E_STORY` cleared** so a leftover export in your shell does not turn on story video/pacing. `npm run test:e2e:stories` sets `E2E_STORY=1` and runs only tests whose title matches **`--grep Storyboard`** (use a `test.describe('Storyboard: …')` block in story specs).
1316
- **Artifacts:** `screenshot: 'on'`, `trace: 'on-first-retry'`; outputs under `test-results/` (gitignored).
1417

1518
## CI
@@ -21,7 +24,8 @@ Minimal Docks shell for Playwright only (not the public demo app in `packages/ap
2124
Entry is [src/main.ts](./src/main.ts): `registerApp` plus auxiliary/toolbar contributions as specs require.
2225

2326
- **Extensions:** list them in `AppDefinition.extensions` (e.g. `@eclipse-docks/extension-ai-system`, `@eclipse-docks/extension-monaco-editor` for workspace file editing in E2E).
24-
- **Side-effect imports:** [vite.config.ts](./vite.config.ts) uses `resolveDepVersionsPlugin()` (automatic `extension-*` / `@scope/extension-*` side-effect imports are on by default). [src/main.ts](./src/main.ts) additionally imports the in-repo [`extension-ai-system/src/ai-system-extension.ts`](../../packages/extension-ai-system/src/ai-system-extension.ts) so that registration runs after the package entry and **before** the rest of `main.ts` adds E2E auxiliary tabs — order **`[aiview, …e2e tabs]`** — avoiding `wa-tab-group` “first tab” fallbacks being mistaken for successful `coupledEditors` coupling.
27+
- **Harness vs preview build:** Do not import `ai-system-extension` directly in [src/main.ts](./src/main.ts) while also listing `@eclipse-docks/extension-ai-system` in `AppDefinition.extensions` — the bundler can load that module twice (different chunks), and Lit `@customElement` registration throws (“already been used”). E2E auxiliary contributions are registered in **`initialize()`** so they run **after** extensions load, preserving tab order **`[aiview, …e2e tabs]`** without a duplicate import.
28+
- **Side-effect imports:** [vite.config.ts](./vite.config.ts) uses `resolveDepVersionsPlugin()` (automatic `extension-*` / `@scope/extension-*` side-effect imports are on by default).
2529

2630
### `coupledEditors` example
2731

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { test, expect, type Locator } from './fixtures.js';
2+
import {
3+
closeActiveMainAreaEditor,
4+
closeMainAreaEditorByTabHasText,
5+
closeMainAreaEditorByTabName,
6+
MAIN_EDITOR_TABS,
7+
} from './editor-tab-utils.js';
8+
import {
9+
beat,
10+
dwell,
11+
dismissOpenPromptDialogs,
12+
jupyterNotebookCreateMenuItem,
13+
storyPaceExtraMs,
14+
} from './story-utils.js';
15+
16+
/**
17+
* Walkthrough: theme (light/dark, end on light), text file in workspace, layout switch, Jupyter notebook + JS kernel.
18+
* `Storyboard:` prefix keeps this file in `npm run test:e2e:stories` (`--grep Storyboard`).
19+
*/
20+
21+
const FILE_NAME = 'hello-docks.txt';
22+
const CONTENT = 'hello world';
23+
24+
/** Max wait for individual UI expectations and actions (fast fail). */
25+
const UI_MS = 3000;
26+
27+
/** `wa-tree-item.filter({ hasText })` matches folder ancestors; target the label row instead. */
28+
function treeItemForFileName(fileBrowser: Locator, fileName: string): Locator {
29+
const escaped = fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30+
return fileBrowser
31+
.locator('.tree-label-text', { hasText: new RegExp(`^${escaped}$`) })
32+
.locator('xpath=ancestor::wa-tree-item[1]');
33+
}
34+
35+
test.describe('Storyboard: Docks walkthrough', () => {
36+
test.describe.configure({ timeout: 120_000 });
37+
38+
test('theme → text file → layout → extensions → Jupyter notebook + JavaScript', async ({ page }) => {
39+
const fileBrowser = page.locator('docks-filebrowser');
40+
const editorPlain = page.locator('docks-tabs#editor-area-main docks-plain-editor');
41+
const layoutSwitcher = page.locator('docks-layout-switcher');
42+
const themeSwitcher = page
43+
.locator('docks-toolbar#app-toolbars-main-right')
44+
.locator('wa-button[title="Theme Switcher"]');
45+
const htmlRoot = page.locator('html');
46+
47+
await test.step('Theme: switch light/dark, stay on light', async () => {
48+
await page.goto('/', { timeout: UI_MS });
49+
await expect(page.locator('docks-standard-layout')).toBeVisible({ timeout: UI_MS });
50+
await expect(themeSwitcher).toBeVisible({ timeout: UI_MS });
51+
52+
if (await htmlRoot.evaluate((el) => el.classList.contains('wa-light'))) {
53+
await themeSwitcher.click();
54+
await expect(htmlRoot).toHaveClass(/wa-dark/);
55+
} else {
56+
await expect(htmlRoot).toHaveClass(/wa-dark/);
57+
}
58+
59+
await dwell(page, undefined, 'Switch from dark to light.');
60+
await themeSwitcher.click();
61+
await expect(htmlRoot).toHaveClass(/wa-light/);
62+
await beat(page, storyPaceExtraMs(450));
63+
64+
await dwell(page, undefined, 'Switch to dark mode.');
65+
await themeSwitcher.click();
66+
await expect(htmlRoot).toHaveClass(/wa-dark/);
67+
await beat(page, storyPaceExtraMs(450));
68+
69+
await dwell(page, undefined, 'Use light theme for the rest of the walkthrough.');
70+
await themeSwitcher.click();
71+
await expect(htmlRoot).toHaveClass(/wa-light/);
72+
await beat(page, storyPaceExtraMs(500));
73+
});
74+
75+
await test.step('Load shell and open File Browser', async () => {
76+
await page.locator('docks-tabs#sidebar-main').locator('wa-tab[panel="view.filebrowser"]').click();
77+
await expect(fileBrowser.locator('wa-tree')).toBeVisible({ timeout: UI_MS });
78+
await dwell(page, undefined, 'Open the workspace file browser.');
79+
await beat(page);
80+
});
81+
82+
await test.step('Create hello-docks.txt and open in plain editor', async () => {
83+
const rootFolder = fileBrowser.locator('wa-tree-item').filter({ hasText: 'My Folder' }).first();
84+
await expect(rootFolder).toBeVisible({ timeout: UI_MS });
85+
await rootFolder.click();
86+
await beat(page);
87+
await dwell(page, undefined, 'Create a new text file in this folder.');
88+
89+
await fileBrowser.locator('docks-command[dropdown="filebrowser.create"]').locator('wa-button[slot="trigger"]').click();
90+
await page.getByText('Create File...', { exact: true }).click();
91+
92+
const dialog = page.locator('wa-dialog[open][label="Input"]');
93+
await expect(dialog).toBeAttached({ timeout: UI_MS });
94+
await dialog.locator('docks-prompt-dialog-content wa-input').locator('input').fill(FILE_NAME);
95+
await beat(page);
96+
await dialog.locator('.dialog-service-footer wa-button').filter({ hasText: 'OK' }).click({ force: true });
97+
98+
await expect(treeItemForFileName(fileBrowser, FILE_NAME)).toBeVisible({
99+
timeout: UI_MS,
100+
});
101+
await beat(page, storyPaceExtraMs(300));
102+
103+
const fileRow = treeItemForFileName(fileBrowser, FILE_NAME);
104+
await fileRow.scrollIntoViewIfNeeded();
105+
await fileRow.click();
106+
await dwell(page, undefined, 'Open the new file.');
107+
await beat(page, storyPaceExtraMs(250));
108+
await fileRow.dblclick();
109+
110+
await expect(editorPlain).toBeVisible({ timeout: UI_MS });
111+
const innerTextarea = editorPlain.locator('docks-texteditor wa-textarea').locator('textarea').first();
112+
await expect(innerTextarea).toBeVisible({ timeout: UI_MS });
113+
await dwell(page);
114+
await innerTextarea.click();
115+
await page.keyboard.press('ControlOrMeta+a');
116+
await page.keyboard.type(CONTENT, { delay: process.env.E2E_STORY === '1' ? 40 : 0 });
117+
await expect(innerTextarea).toHaveValue(CONTENT, { timeout: UI_MS });
118+
119+
const saveBtn = editorPlain.locator('docks-toolbar').getByRole('button', { name: /Save active editor/i });
120+
await expect(saveBtn).toBeEnabled({ timeout: UI_MS });
121+
await dwell(page, undefined, 'Save from the editor toolbar.');
122+
await saveBtn.click();
123+
await beat(page, storyPaceExtraMs(400));
124+
125+
await closeActiveMainAreaEditor(page);
126+
await expect(editorPlain).toBeHidden({ timeout: UI_MS });
127+
await beat(page);
128+
});
129+
130+
await test.step('Switch layout to Standard (bottom panel)', async () => {
131+
await layoutSwitcher.locator('wa-button[slot="trigger"]').click();
132+
await dwell(page, undefined, 'Switch the workbench layout.');
133+
await layoutSwitcher.locator('wa-dropdown-item[value="standard-bottom-panel"]').click();
134+
await expect(page.locator('docks-tabs#panel-bottom')).toBeVisible({ timeout: UI_MS });
135+
await beat(page);
136+
});
137+
138+
const createTrigger = fileBrowser.locator('docks-command[dropdown="filebrowser.create"]').locator('wa-button[slot="trigger"]');
139+
140+
await test.step('Open Extensions and install Jupyter-like notebook extension', async () => {
141+
await page.locator('docks-toolbar#sidebar-main-toolbar').getByRole('button', { name: /^Extensions$/i }).click();
142+
const extPanel = page.locator('docks-extensions');
143+
await expect(extPanel).toBeVisible({ timeout: UI_MS });
144+
await dwell(page);
145+
146+
const jupyterTreeItem = extPanel.getByRole('treeitem', { name: /Jupyter-like Notebook Editor/i });
147+
await expect(jupyterTreeItem).toBeVisible({ timeout: UI_MS });
148+
await jupyterTreeItem.scrollIntoViewIfNeeded();
149+
await jupyterTreeItem.click();
150+
151+
const detail = extPanel.locator('.extensions-detail-content');
152+
await expect(detail.locator('h1')).toContainText(/Jupyter-like Notebook Editor/i, { timeout: UI_MS });
153+
await beat(page);
154+
155+
const installBtn = detail.getByRole('button', { name: /Install/i });
156+
await expect(installBtn).toBeVisible({ timeout: UI_MS });
157+
await installBtn.click();
158+
await beat(page, storyPaceExtraMs(1000));
159+
160+
await expect(async () => {
161+
await page.keyboard.press('Escape');
162+
await createTrigger.click();
163+
await expect(jupyterNotebookCreateMenuItem(fileBrowser)).toBeVisible({ timeout: UI_MS });
164+
}).toPass({ timeout: UI_MS });
165+
166+
await page.keyboard.press('Escape');
167+
await fileBrowser.locator('docks-command[cmd="refresh_resource"]').locator('wa-button').click();
168+
await beat(page);
169+
});
170+
171+
await test.step('Close Extensions view', async () => {
172+
await closeMainAreaEditorByTabName(page, /Extensions/i);
173+
await expect(page.locator(`${MAIN_EDITOR_TABS} docks-extensions`)).toBeHidden({
174+
timeout: UI_MS,
175+
});
176+
await beat(page);
177+
});
178+
179+
const notebookBaseName = `walkthrough-${Date.now()}`;
180+
const notebookEditor = page.locator('docks-tabs#editor-area-main docks-notebook-editor');
181+
182+
await test.step('Create Jupyter notebook and open it', async () => {
183+
await dismissOpenPromptDialogs(page);
184+
await page.keyboard.press('Escape');
185+
186+
await page.locator('docks-tabs#sidebar-main').locator('wa-tab[panel="view.filebrowser"]').click();
187+
await expect(fileBrowser).toBeVisible({ timeout: UI_MS });
188+
189+
const rootFolder = fileBrowser.locator('wa-tree-item').filter({ hasText: 'My Folder' }).first();
190+
await rootFolder.scrollIntoViewIfNeeded();
191+
await rootFolder.click();
192+
await beat(page);
193+
194+
await createTrigger.click();
195+
await expect(jupyterNotebookCreateMenuItem(fileBrowser)).toBeVisible({ timeout: UI_MS });
196+
await dwell(page, undefined, 'Create a new Jupyter notebook.');
197+
await jupyterNotebookCreateMenuItem(fileBrowser).click();
198+
199+
const nameDialog = page.locator('wa-dialog[open][label="Input"]');
200+
await expect(nameDialog).toBeAttached({ timeout: UI_MS });
201+
await nameDialog.locator('docks-prompt-dialog-content wa-input').locator('input').fill(`${notebookBaseName}.ipynb`);
202+
await beat(page);
203+
await nameDialog.locator('.dialog-service-footer wa-button').filter({ hasText: 'OK' }).click({ force: true });
204+
205+
const ipynbName = `${notebookBaseName}.ipynb`;
206+
const ipynbRow = treeItemForFileName(fileBrowser, ipynbName);
207+
await expect(ipynbRow).toBeVisible({ timeout: UI_MS });
208+
await beat(page, storyPaceExtraMs(400));
209+
await ipynbRow.scrollIntoViewIfNeeded();
210+
await ipynbRow.click();
211+
await beat(page, storyPaceExtraMs(150));
212+
await ipynbRow.dblclick();
213+
214+
await expect(notebookEditor).toBeVisible({ timeout: UI_MS });
215+
await dwell(page, undefined, 'The notebook opens in the main editor.');
216+
await beat(page, storyPaceExtraMs(400));
217+
});
218+
219+
await test.step('Select JavaScript kernel, run code, scroll to output', async () => {
220+
await expect(notebookEditor).toBeVisible({ timeout: UI_MS });
221+
222+
await notebookEditor.locator('.kernel-select').locator('wa-button[slot="trigger"]').click();
223+
await dwell(page, undefined, 'Choose a kernel for this notebook.');
224+
await notebookEditor.evaluate((host) => {
225+
const scroller = host.closest('wa-scroller');
226+
scroller?.scrollBy({ top: 220, behavior: 'instant' });
227+
});
228+
await beat(page, process.env.E2E_STORY === '1' ? storyPaceExtraMs(350) : 120);
229+
230+
const jsItem = notebookEditor.locator('wa-dropdown-item[value="javascript"]');
231+
await expect(jsItem).toBeVisible({ timeout: UI_MS });
232+
await jsItem.click();
233+
await beat(page, storyPaceExtraMs(500));
234+
235+
await expect(notebookEditor.locator('.kernel-select wa-button[slot="trigger"]')).toContainText(/JavaScript/i, {
236+
timeout: UI_MS,
237+
});
238+
await dwell(page, undefined, 'JavaScript is selected as the runtime.');
239+
await beat(page, storyPaceExtraMs(350));
240+
241+
const firstCodeCell = notebookEditor.locator('.code-cell').first();
242+
await notebookEditor.evaluate((host) => {
243+
const el = host as HTMLElement & { shadowRoot?: ShadowRoot };
244+
const codeCell = el.shadowRoot?.querySelector('.code-cell');
245+
const cellWrapper = codeCell?.closest('.cell-wrapper');
246+
const scroller = el.closest('wa-scroller');
247+
if (!cellWrapper || !scroller) return;
248+
const scrollerRect = scroller.getBoundingClientRect();
249+
const cellRect = cellWrapper.getBoundingClientRect();
250+
const scrollTop = scroller.scrollTop;
251+
const targetScroll =
252+
scrollTop +
253+
(cellRect.top - scrollerRect.top) -
254+
scrollerRect.height / 2 +
255+
cellRect.height / 2;
256+
scroller.scrollTo({ top: Math.max(0, targetScroll), behavior: 'instant' });
257+
});
258+
await beat(page, process.env.E2E_STORY === '1' ? storyPaceExtraMs(400) : 150);
259+
260+
await dwell(page, undefined, 'Run the first cell.');
261+
await firstCodeCell.locator('docks-command[cmd="notebook.runCell"]').click();
262+
await beat(page, process.env.E2E_STORY === '1' ? storyPaceExtraMs(500) : 0);
263+
264+
const output = firstCodeCell.locator('.cell-output');
265+
await expect(output).toBeVisible({ timeout: 15_000 });
266+
await expect(output.locator('pre code')).toContainText('Hello, World!', { timeout: UI_MS });
267+
await beat(page, storyPaceExtraMs(450));
268+
269+
await output.scrollIntoViewIfNeeded();
270+
await dwell(page, undefined, 'Scroll to see the cell output.');
271+
await beat(page);
272+
});
273+
274+
await test.step('Close notebook', async () => {
275+
await closeMainAreaEditorByTabHasText(page, /\.ipynb/, { confirmUnsaved: true });
276+
await expect(notebookEditor).toBeHidden({ timeout: UI_MS });
277+
await dwell(page);
278+
});
279+
});
280+
});

0 commit comments

Comments
 (0)