From 8a450222e08147df3b3ec7289a15552a3ee687fd Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 12:56:18 -0700 Subject: [PATCH 01/26] add a README describing the e2e tests Co-Authored-By: Claude Opus 5.5 --- web-console/README.md | 4 +- web-console/e2e-tests/README.md | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 web-console/e2e-tests/README.md diff --git a/web-console/README.md b/web-console/README.md index baa34e74bc61..1494eabd43f2 100644 --- a/web-console/README.md +++ b/web-console/README.md @@ -115,7 +115,7 @@ be skipped. `e2e-tests/util/debug.ts:saveScreenshotIfError()` is used to save a screenshot of the web console when the test fails. For example, if `e2e-tests/tutorial-batch.spec.ts` fails, it will create -`load-data-from-local-disk-error-screenshot.png`. +`load-data-from-local-disk-error-screenshot.jpeg`. #### Disabling headless mode @@ -142,7 +142,7 @@ Like so: `DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT=18081 npm run test-e2e` As part of this directory: - `assets/` - The images (and other assets) used within the console -- `e2e-tests/` - End-to-end tests for the console +- `e2e-tests/` - End-to-end tests for the console (see `e2e-tests/README.md`) - `public/` - The compiled destination for the files powering this console - `script/` - Some helper bash scripts for running this console - `src/` - This directory constitutes all the source code for this console diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md new file mode 100644 index 000000000000..e82406820e63 --- /dev/null +++ b/web-console/e2e-tests/README.md @@ -0,0 +1,101 @@ + + +# End-to-end tests + +These tests drive the web console in a real (headless) Chromium against a real Druid cluster. They catch what the unit +tests can't: the console and Druid's APIs disagreeing, a flow across several views breaking, or a request that only +fails against a live cluster. + +They use Jest as the test runner and the `playwright-chromium` library to control the browser (not the +`@playwright/test` runner). They run in CI as part of `.github/scripts/web-checks.sh`. + +## Running them + +The tests need a Druid cluster on the standard quickstart ports, started from this checkout (they read the +`examples/quickstart/tutorial` data files from it and post tasks with `examples/bin/post-index-task`): + +```bash +script/druid build # once, builds a distribution with the extensions the tests need +script/druid start +npm run test-e2e # all the tests, one file at a time (--runInBand) +script/druid stop +``` + +- **One file**: `npx jest --config jest.e2e.config.js e2e-tests/cancel-query.spec.ts` +- **See the browser**: `DRUID_E2E_TEST_HEADLESS=false npm run test-e2e` (also slows each action down by 20ms) +- **Test a dev server instead of the bundled console**: `DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT=18081 npm run test-e2e`. + Without it the tests run against the console that the router on :8888 serves, which was bundled when Druid was + built, not your working tree. +- **On failure** a test writes `-error-screenshot.jpeg` to the directory Jest was run from (git ignored), + and prints it to the log as a base64 data URL so it can be recovered from CI logs. Every HTTP response with a status + of 400 or more is also logged with its body. + +The tests create datasources and tasks and do not clean them up. Each datasource name ends in a timestamp so that +runs don't collide, but a cluster that runs the tests often will collect them. + +## The tests + +Each test has a 5 minute timeout. Polling for cluster state (a task finishing, segments loading) retries for up to a +minute. + +| Spec | What it does | +|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `tutorial-batch.spec.ts` | Follows the [batch loading tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-batch) through the classic **data loader**: connects to `wikiticker-2015-09-12-sampled.json.gz` on local disk, checks the first and last preview lines, sets the timestamp to `timestamp_parse("time") + 1` (so the `__time` check below proves the expression was used), turns rollup off, sets DAY granularity and submits. It then waits for the task to succeed on the **Tasks** view, for the datasource to be fully available as 1 segment with 39,244 rows on the **Datasources** view, and checks the first row of `SELECT *` in the **Query** view. The datasource name contains a string of special and non-Latin characters to test quoting and escaping. | +| `reindexing.spec.ts` | Loads `wikipedia-index.json` with `post-index-task` (1 segment), then reindexes it through the data loader's **Reindex from Druid** connector into range partitions on `channel` with 10,000 target rows per segment. Checks the preview rows, that the task succeeds, and that the datasource ends up as 4 segments with the same 39,244 rows. | +| `auto-compaction.spec.ts` | Follows the [compaction tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-compaction): loads 2 hours of `compaction-init-index.json` with `post-index-task` (3 segments, 1,412 rows), sets a compaction config (`skipOffsetFromLatest: PT0S`, hashed partitions) from the **Datasources** view, reopens the dialog until it reads back the same config, then forces compaction runs (from the Alt-click "more" menu) until there are 2 segments. | +| `multi-stage-query.spec.ts` | Runs an MSQ `SELECT` over `EXTERN(...)` on the tutorial file in the **Query** view, clicking "Run it anyway" if the cluster warns it lacks task slots, and checks the top 2 of the 10 channels by count. | +| `cancel-query.spec.ts` | Runs `SELECT sleep(40)` in the **Query** view, waits for its `POST` to `druid/v2`, clicks "Cancel query" and checks that the `DELETE` it sends is answered with `202 Accepted`. | + +## Layout + +``` +e2e-tests/ + *.spec.ts the tests, one describe per file + component/ page objects: one class per view, wrapping its selectors + datasources/ Datasources view (reads the table, edits and triggers compaction) + ingestion/ Tasks view (reads the table) + load-data/ the classic data loader, step by step + data-connector/ the input sources it can connect to (local disk, reindex from Druid) + config/ what to set at each step (timestamp, schema, partition, publish) + query/ Query view: run a query, cancel a query + workbench/ Query view: run a query, accepting the task slot warning + util/ + druid.ts console URL (from DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT), tutorial data dir, runIndexTask + playwright.ts browser and page setup, and helpers that find inputs and buttons by their label or text + table.ts extractTable: reads a table's rows as text, skipping blank rows + retry.ts retry a callback on a failed expect (or on any error) every second + debug.ts saveScreenshotIfError + setup.ts waitTillWebConsoleReady: waits for the console to stop showing its "cannot connect" message +``` + +### Writing a test + +- Start from an existing spec: `beforeAll` waits for the console and launches the browser, `beforeEach` opens a fresh + page, and the body goes inside `saveScreenshotIfError(testName, page, ...)`. +- Put selectors in a page object under `component/` rather than in the spec. The helpers in `util/playwright.ts` find + form fields by their visible label (`setLabeledInput(page, 'Datasource name', ...)`) and buttons by their text, so + renaming a label or a button in the console breaks the tests. +- The query editor is CodeMirror; type into it with `setQueryInput`, which fills its `.cm-content`. +- Table helpers read columns by position (see the column enums in `component/datasources/overview.ts` and + `component/ingestion/overview.ts`), so adding, removing or reordering a default visible column needs the enum + updated. +- Wrap checks on cluster state in `retryIfJestAssertionError`: segments load and tasks finish some time after the + console reports success. +- Give anything you create a unique name (append `new Date().toISOString()`). From 4c8cfe657a186f06eb866001bd139e15349a770f Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 12:57:16 -0700 Subject: [PATCH 02/26] add a temporary plan for improving the e2e tests Co-Authored-By: Claude Opus 5.5 --- web-console/E2E_BETTERMENT_PLAN.md | 77 ++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 web-console/E2E_BETTERMENT_PLAN.md diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md new file mode 100644 index 000000000000..625df9adfb68 --- /dev/null +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -0,0 +1,77 @@ + + +# E2E test betterment plan (temporary, delete before merging) + +The e2e tests (`e2e-tests/`) were written in a Puppeteer style: Jest as the runner, the `playwright-chromium` library, +XPath selectors and element handles. Much of `util/` re-implements what Playwright now provides. This is the plan to +modernize them, in order. Each step should leave the suite passing. + +## 1. Move to the `@playwright/test` runner + +- Add `@playwright/test` (same version as `playwright-chromium`, which stays for now because its install script + downloads the Chromium the runner uses, so CI needs no extra `playwright install` step). +- Add `playwright.config.ts`: `testDir: 'e2e-tests'`, `workers: 1` (the specs share one cluster), the 5 minute + timeout, `baseURL` from `DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT`, `headless` from `DRUID_E2E_TEST_HEADLESS`, + `screenshot: 'only-on-failure'`, `trace: 'retain-on-failure'`, a `globalSetup` that waits for the console. +- `npm run test-e2e` becomes `playwright test`; drop `jest.e2e.config.js`. +- Replaces: the `beforeAll`/`beforeEach`/`afterAll` boilerplate in every spec (`page` fixture), + `util/setup.ts` (`globalSetup`), `util/debug.ts` (failure screenshots and traces), `util/retry.ts` + (`expect.poll` / `expect(...).toPass()`), and `createBrowser`/`createPage` (the page fixture; the logging of failed + responses moves to a fixture). +- Update `e2e-tests/README.md`, `README.md` (running e2e tests), `AGENTS.md`, `.gitignore` (`test-results/`, + `playwright-report/` instead of `/*.jpeg`), and the eslint override. + +## 2. Locators instead of XPath and element handles + +- `util/playwright.ts`: `getByRole('button', { name })`, labeled fields found by their `FormGroup` label rather than + `//*[text()="..."]/following-sibling::div//input`, no `page.$` / `$$eval` / `waitForSelector` / `ElementHandle`. +- Web-first assertions (`expect(locator).toBeVisible()` / `toHaveText()`) instead of `waitForSelector`. +- `openEditActions`: find the datasource's row and click its actions button, rather than the Nth `.bp6-icon-more`. +- `selectSuggestibleInput`: no legacy `'"value"'` text selector. + +## 3. Don't read tables by column position + +- The `DatasourceColumn` / `TaskColumn` enums break when a default visible column changes. Check cluster state + (task succeeded, N segments available, row count) through the SQL API (`sys.tasks`, `sys.segments`) where the UI + isn't what's being tested, or find columns by their header text. + +## 4. Set up data through the HTTP API + +- `runIndexTask` shells out to `sed` + `examples/bin/post-index-task` (bash + Python) and hard-codes the Coordinator + on :8081. Read the spec JSON, set the datasource name / interval in code and `POST` it to `/druid/indexer/v1/task` + with the `request` fixture, then poll the task status. + +## 5. Clean up after each test + +- Mark the datasources a test created as unused (and kill its tasks) in an `afterEach`/fixture teardown. + +## 6. Simplify the code + +- Merge `component/query/overview.ts` and `component/workbench/overview.ts` (both drive `#workbench`). +- Plain object types instead of the `Object.assign(this, props)` + interface merging data classes (`PublishConfig`, + `Datasource`, `IngestionTask`, ...); then the `no-unsafe-declaration-merging` eslint override can go. +- No `goto` + `reload({ waitUntil: 'networkidle' })`. +- `cancel-query`: one `expect(status).toBe(202)`. +- Drop `--disable-local-storage` (each test gets a fresh context anyway). + +## 7. More coverage (later, separately) + +- SQL-based ingestion (the SQL data loader, `INSERT` / `REPLACE` in the Query view), Explore, Supervisors, + Segments, Services, Lookups. From d67ba55d7111346bba4b18bbae916480716d13fb Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 13:25:44 -0700 Subject: [PATCH 03/26] move the e2e tests to the Playwright test runner and locators - run the e2e tests with @playwright/test (playwright.config.ts) instead of Jest and the playwright-chromium library: the page fixture replaces the per-spec browser setup, a global setup waits for the console, failed tests keep a screenshot and a trace, and expect().toPass() replaces the retry helpers - use locators (getByRole, form groups found by their label) instead of XPath, page.$ and waitForSelector - open the Datasources and Tasks views filtered to the test's datasource, so the tests work on a cluster with more than a page of datasources or tasks - fail a query attempt as soon as it errors, so that it can be retried while the Broker catches up Co-Authored-By: Claude Opus 5.5 --- web-console/.gitignore | 5 +- web-console/AGENTS.md | 4 +- web-console/E2E_BETTERMENT_PLAN.md | 10 +- web-console/README.md | 30 +--- web-console/e2e-tests/README.md | 54 +++--- web-console/e2e-tests/auto-compaction.spec.ts | 120 ++++--------- web-console/e2e-tests/cancel-query.spec.ts | 45 +---- .../component/datasources/overview.ts | 91 ++++------ .../e2e-tests/component/ingestion/overview.ts | 19 +- .../component/load-data/config/partition.ts | 31 ++-- .../data-connector/data-connector.ts | 4 +- .../load-data/data-connector/local-file.ts | 6 +- .../load-data/data-connector/reindex.ts | 6 +- .../component/load-data/data-loader.ts | 98 +++++------ .../e2e-tests/component/query/overview.ts | 52 +++--- .../e2e-tests/component/workbench/overview.ts | 29 ++- .../e2e-tests/multi-stage-query.spec.ts | 43 +---- web-console/e2e-tests/reindexing.spec.ts | 73 +++----- web-console/e2e-tests/tutorial-batch.spec.ts | 83 +++------ web-console/e2e-tests/util/debug.ts | 41 ----- web-console/e2e-tests/util/druid.ts | 2 - web-console/e2e-tests/util/fixtures.ts | 47 +++++ .../util/{setup.ts => global-setup.ts} | 24 +-- web-console/e2e-tests/util/playwright.ts | 166 ++++++++---------- web-console/e2e-tests/util/retry.ts | 61 ------- web-console/e2e-tests/util/table.ts | 51 ++---- web-console/package-lock.json | 33 ++++ web-console/package.json | 7 +- ...est.e2e.config.js => playwright.config.ts} | 27 ++- web-console/tsconfig.json | 2 +- 30 files changed, 516 insertions(+), 748 deletions(-) delete mode 100644 web-console/e2e-tests/util/debug.ts create mode 100644 web-console/e2e-tests/util/fixtures.ts rename web-console/e2e-tests/util/{setup.ts => global-setup.ts} (58%) delete mode 100644 web-console/e2e-tests/util/retry.ts rename web-console/{jest.e2e.config.js => playwright.config.ts} (51%) diff --git a/web-console/.gitignore b/web-console/.gitignore index 940182a3e402..19e698794299 100644 --- a/web-console/.gitignore +++ b/web-console/.gitignore @@ -19,8 +19,9 @@ tscommand-*.tmp.txt licenses.json .druid.pid -# Screenshots the e2e tests write on failure -/*.jpeg +# What the e2e tests write (screenshots and traces of failed tests, the HTML report) +/test-results/ +/playwright-report/ # Generated by script/build-grammars.mjs src/editor-languages/*.parser.ts diff --git a/web-console/AGENTS.md b/web-console/AGENTS.md index ef2cf1821f34..454bd580fc75 100644 --- a/web-console/AGENTS.md +++ b/web-console/AGENTS.md @@ -40,8 +40,8 @@ The Apache Druid web console: a React + TypeScript single page app built with Bl bundle first. - `npm run autofix` fixes ESLint, stylelint and Prettier issues. - **End-to-end tests** (`e2e-tests/`, Playwright) need a running Druid: `script/druid build && script/druid start`, - then `npm run test-e2e`. See the README for running one test or with a visible browser, and below for running them - against your own changes. + then `npm run test-e2e`. See `e2e-tests/README.md` for running one test or with a visible browser, and below for + running them against your own changes. ## Developing against a running Druid diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index 625df9adfb68..f30b23c3b337 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -23,7 +23,7 @@ The e2e tests (`e2e-tests/`) were written in a Puppeteer style: Jest as the runn XPath selectors and element handles. Much of `util/` re-implements what Playwright now provides. This is the plan to modernize them, in order. Each step should leave the suite passing. -## 1. Move to the `@playwright/test` runner +## 1. Move to the `@playwright/test` runner (done) - Add `@playwright/test` (same version as `playwright-chromium`, which stays for now because its install script downloads the Chromium the runner uses, so CI needs no extra `playwright install` step). @@ -38,13 +38,19 @@ modernize them, in order. Each step should leave the suite passing. - Update `e2e-tests/README.md`, `README.md` (running e2e tests), `AGENTS.md`, `.gitignore` (`test-results/`, `playwright-report/` instead of `/*.jpeg`), and the eslint override. -## 2. Locators instead of XPath and element handles +## 2. Locators instead of XPath and element handles (done) - `util/playwright.ts`: `getByRole('button', { name })`, labeled fields found by their `FormGroup` label rather than `//*[text()="..."]/following-sibling::div//input`, no `page.$` / `$$eval` / `waitForSelector` / `ElementHandle`. - Web-first assertions (`expect(locator).toBeVisible()` / `toHaveText()`) instead of `waitForSelector`. - `openEditActions`: find the datasource's row and click its actions button, rather than the Nth `.bp6-icon-more`. - `selectSuggestibleInput`: no legacy `'"value"'` text selector. +- Found on the way: the Datasources and Tasks tables page at 50 rows, so on a cluster with leftover datasources the + test's own row could be on page 2. `openView` now filters the view through its hash route. A filter can't hold + `|`, `#` or `?`, so it matches on the end of the name after the last of those (`filterableText`). +- Found on the way (console bug, not fixed here): `TableFilters.toString()` doesn't encode `#` or `?`, but + `parseHashRoute` ends the route at them, so going from a datasource whose name has `#` or `?` to its tasks or + segments drops the rest of the name. ## 3. Don't read tables by column position diff --git a/web-console/README.md b/web-console/README.md index 1494eabd43f2..92a28f925bd6 100644 --- a/web-console/README.md +++ b/web-console/README.md @@ -111,31 +111,15 @@ From the web-console directory: If you already have a druid cluster running on the standard ports, the steps to build/start/stop a druid cluster can be skipped. -#### Screenshots for debugging +#### Running one test, watching or debugging -`e2e-tests/util/debug.ts:saveScreenshotIfError()` is used to save a screenshot of the web console -when the test fails. For example, if `e2e-tests/tutorial-batch.spec.ts` fails, it will create -`load-data-from-local-disk-error-screenshot.jpeg`. +- One spec: `npm run test-e2e -- cancel-query` +- With a visible browser: `npm run test-e2e -- --headed`, or step through with `--debug` or `--ui` +- Against a console on another port (like the dev server from `npm start` on `18081`): + `DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT=18081 npm run test-e2e` -#### Disabling headless mode - -Disabling headless mode while running the tests can be helpful. This can be done via the `DRUID_E2E_TEST_HEADLESS` -environment variable, which defaults to `true`. - -Like so: `DRUID_E2E_TEST_HEADLESS=false npm run test-e2e` - -#### Running against alternate web console - -The environment variable `DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT` can be used to target a web console running on a -non-default port (i.e., not port `8888`). For example, this environment variable can be used to target the -development mode of the web console (started via `npm start`), which runs on port `18081`. - -Like so: `DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT=18081 npm run test-e2e` - -#### Running and debugging a single e2e test using Jest and Playwright - -- Run - `jest --config jest.e2e.config.js e2e-tests/tutorial-batch.spec.ts` -- Debug - `PWDEBUG=console jest --config jest.e2e.config.js e2e-tests/tutorial-batch.spec.ts` +A failed test leaves a screenshot and a trace in `test-results/` (open the trace with `npx playwright show-trace`). +See `e2e-tests/README.md` for what the tests cover and how they are structured. ## Description of the directory structure diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index e82406820e63..c00b8973bdc6 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -23,8 +23,9 @@ These tests drive the web console in a real (headless) Chromium against a real D tests can't: the console and Druid's APIs disagreeing, a flow across several views breaking, or a request that only fails against a live cluster. -They use Jest as the test runner and the `playwright-chromium` library to control the browser (not the -`@playwright/test` runner). They run in CI as part of `.github/scripts/web-checks.sh`. +They use the Playwright test runner (`@playwright/test`, configured in `../playwright.config.ts`). The Chromium it +drives is downloaded by the `playwright-chromium` package's install script, so keep the two packages on the same +version. They run in CI as part of `.github/scripts/web-checks.sh`. ## Running them @@ -34,26 +35,30 @@ The tests need a Druid cluster on the standard quickstart ports, started from th ```bash script/druid build # once, builds a distribution with the extensions the tests need script/druid start -npm run test-e2e # all the tests, one file at a time (--runInBand) +npm run test-e2e # all the tests, one at a time (they share the cluster) script/druid stop ``` -- **One file**: `npx jest --config jest.e2e.config.js e2e-tests/cancel-query.spec.ts` -- **See the browser**: `DRUID_E2E_TEST_HEADLESS=false npm run test-e2e` (also slows each action down by 20ms) +Before the tests start, a global setup (`util/global-setup.ts`) waits for the console's SQL endpoint to answer. + +- **One file**: `npm run test-e2e -- cancel-query` (any `playwright test` arguments go after `--`) +- **See the browser**: `npm run test-e2e -- --headed`, step through a test with `--debug`, or pick and watch tests in + the UI mode with `--ui` - **Test a dev server instead of the bundled console**: `DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT=18081 npm run test-e2e`. Without it the tests run against the console that the router on :8888 serves, which was bundled when Druid was built, not your working tree. -- **On failure** a test writes `-error-screenshot.jpeg` to the directory Jest was run from (git ignored), - and prints it to the log as a base64 data URL so it can be recovered from CI logs. Every HTTP response with a status - of 400 or more is also logged with its body. +- **On failure** a test leaves a screenshot, a trace (`npx playwright show-trace `: every action with the DOM, + console and network at that point) and an `error-context.md` (the page as an accessibility tree) in `test-results/` + (git ignored). Every HTTP response with a status of 400 or more is also logged with its body + (`util/fixtures.ts`). The tests create datasources and tasks and do not clean them up. Each datasource name ends in a timestamp so that runs don't collide, but a cluster that runs the tests often will collect them. ## The tests -Each test has a 5 minute timeout. Polling for cluster state (a task finishing, segments loading) retries for up to a -minute. +Each test has a 5 minute timeout. Polling for cluster state (a task finishing, segments loading) retries every second +for up to 2 minutes. | Spec | What it does | |------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -77,25 +82,30 @@ e2e-tests/ query/ Query view: run a query, cancel a query workbench/ Query view: run a query, accepting the task slot warning util/ - druid.ts console URL (from DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT), tutorial data dir, runIndexTask - playwright.ts browser and page setup, and helpers that find inputs and buttons by their label or text + fixtures.ts the `test` and `expect` to import in specs (`test` logs failed responses) + global-setup.ts waits for the console's SQL endpoint before the tests start + druid.ts tutorial data dir, runIndexTask + playwright.ts openView, and helpers that find inputs and buttons by their label or text table.ts extractTable: reads a table's rows as text, skipping blank rows - retry.ts retry a callback on a failed expect (or on any error) every second - debug.ts saveScreenshotIfError - setup.ts waitTillWebConsoleReady: waits for the console to stop showing its "cannot connect" message ``` ### Writing a test -- Start from an existing spec: `beforeAll` waits for the console and launches the browser, `beforeEach` opens a fresh - page, and the body goes inside `saveScreenshotIfError(testName, page, ...)`. -- Put selectors in a page object under `component/` rather than in the spec. The helpers in `util/playwright.ts` find - form fields by their visible label (`setLabeledInput(page, 'Datasource name', ...)`) and buttons by their text, so - renaming a label or a button in the console breaks the tests. +- Import `test` and `expect` from `util/fixtures.ts` (not from `@playwright/test`) and take the `page` fixture: + `test('...', async ({ page }) => { ... })`. Each test gets a fresh browser context, so no local storage carries + over. +- Put selectors in a page object under `component/` rather than in the spec, and use locators (`page.locator`, + `getByRole`), not `page.$` / `waitForSelector` / XPath. The helpers in `util/playwright.ts` find form fields by + their visible label (`setLabeledInput(page, 'Datasource name', ...)`; the labels are not linked to their inputs, + so `getByLabel` does not work) and buttons by their exact text, so renaming a label or a button in the console + breaks the tests. Scope a locator (to a dialog, `.next-bar`, ...) when the same text appears twice. +- Open a view with `openView(page, 'datasources', { datasource: ... })`. Filtering to the test's own rows keeps the + test working on a cluster with more datasources or tasks than fit on one page. - The query editor is CodeMirror; type into it with `setQueryInput`, which fills its `.cm-content`. - Table helpers read columns by position (see the column enums in `component/datasources/overview.ts` and `component/ingestion/overview.ts`), so adding, removing or reordering a default visible column needs the enum updated. -- Wrap checks on cluster state in `retryIfJestAssertionError`: segments load and tasks finish some time after the - console reports success. +- Wrap checks on cluster state in `await expect(async () => { ... }).toPass()`: segments load and tasks finish some + time after the console reports success. Make each attempt fail fast (like `QueryOverview.runQuery` throwing on a + query error) rather than wait out a timeout. - Give anything you create a unique name (append `new Date().toISOString()`). diff --git a/web-console/e2e-tests/auto-compaction.spec.ts b/web-console/e2e-tests/auto-compaction.spec.ts index 6659ab50c574..2b3e4dd40faa 100644 --- a/web-console/e2e-tests/auto-compaction.spec.ts +++ b/web-console/e2e-tests/auto-compaction.spec.ts @@ -16,73 +16,45 @@ * limitations under the License. */ +import type { Page } from '@playwright/test'; import path from 'path'; -import type * as playwright from 'playwright-chromium'; import { CompactionConfig } from './component/datasources/compaction'; import type { Datasource } from './component/datasources/datasource'; import { DatasourcesOverview } from './component/datasources/overview'; import { HashedPartitionsSpec } from './component/load-data/config/partition'; -import { saveScreenshotIfError } from './util/debug'; -import { - DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, - runIndexTask, - UNIFIED_CONSOLE_URL, -} from './util/druid'; -import { createBrowser, createPage } from './util/playwright'; -import { retryIfJestAssertionError } from './util/retry'; -import { waitTillWebConsoleReady } from './util/setup'; - -jest.setTimeout(5 * 60 * 1000); +import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, runIndexTask } from './util/druid'; +import { expect, test } from './util/fixtures'; // The workflow in these tests is based on the compaction tutorial: // https://druid.apache.org/docs/latest/tutorials/tutorial-compaction.html -describe('Auto-compaction', () => { - let browser: playwright.Browser; - let page: playwright.Page; - - beforeAll(async () => { - await waitTillWebConsoleReady(); - browser = await createBrowser(); - }); - - beforeEach(async () => { - page = await createPage(browser); - }); - - afterAll(async () => { - await browser.close(); - }); - - it('Compacts segments from dynamic to hash partitions', async () => { - const testName = 'autocompaction-dynamic-to-hash'; - const datasourceName = testName + new Date().toISOString(); +test.describe('Auto-compaction', () => { + test('Compacts segments from dynamic to hash partitions', async ({ page }) => { + const datasourceName = 'autocompaction-dynamic-to-hash' + new Date().toISOString(); loadInitialData(datasourceName); - await saveScreenshotIfError(testName, page, async () => { - const uncompactedNumSegment = 3; - const numRow = 1412; - await validateDatasourceStatus(page, datasourceName, uncompactedNumSegment, numRow); - - const compactionConfig = new CompactionConfig({ - skipOffsetFromLatest: 'PT0S', - partitionsSpec: new HashedPartitionsSpec({ - numShards: null, - }), - }); - await configureCompaction(page, datasourceName, compactionConfig); - - // Depending on the number of configured tasks slots, autocompaction may - // need several iterations if several time chunks need compaction - let currNumSegment = uncompactedNumSegment; - await retryIfJestAssertionError(async () => { - await triggerCompaction(page); - currNumSegment = await waitForCompaction(page, datasourceName, currNumSegment); + const uncompactedNumSegment = 3; + const numRow = 1412; + await validateDatasourceStatus(page, datasourceName, uncompactedNumSegment, numRow); - const compactedNumSegment = 2; - expect(currNumSegment).toBe(compactedNumSegment); - }); + const compactionConfig = new CompactionConfig({ + skipOffsetFromLatest: 'PT0S', + partitionsSpec: new HashedPartitionsSpec({ + numShards: null, + }), }); + await configureCompaction(page, datasourceName, compactionConfig); + + // Depending on the number of configured tasks slots, autocompaction may + // need several iterations if several time chunks need compaction + const datasourcesOverview = new DatasourcesOverview(page); + const compactedNumSegment = 2; + await expect(async () => { + await datasourcesOverview.triggerCompaction(); + await expect + .poll(() => getNumSegment(page, datasourceName), { timeout: 60 * 1000, intervals: [1000] }) + .toBe(compactedNumSegment); + }).toPass({ timeout: 4 * 60 * 1000 }); }); }); @@ -98,61 +70,43 @@ function loadInitialData(datasourceName: string) { } async function validateDatasourceStatus( - page: playwright.Page, + page: Page, datasourceName: string, expectedNumSegment: number, expectedNumRow: number, ) { - await retryIfJestAssertionError(async () => { + await expect(async () => { const datasource = await getDatasource(page, datasourceName); expect(datasource.availability).toMatch(`Fully available (${expectedNumSegment} segments)`); expect(datasource.totalRows).toBe(expectedNumRow); - }); + }).toPass(); } -async function getDatasource(page: playwright.Page, datasourceName: string): Promise { - const datasourcesOverview = new DatasourcesOverview(page, UNIFIED_CONSOLE_URL); - const datasources = await datasourcesOverview.getDatasources(); +async function getDatasource(page: Page, datasourceName: string): Promise { + const datasourcesOverview = new DatasourcesOverview(page); + const datasources = await datasourcesOverview.getDatasources(datasourceName); const datasource = datasources.find(t => t.name === datasourceName); expect(datasource).toBeDefined(); return datasource!; } async function configureCompaction( - page: playwright.Page, + page: Page, datasourceName: string, compactionConfig: CompactionConfig, ) { - const datasourcesOverview = new DatasourcesOverview(page, UNIFIED_CONSOLE_URL); + const datasourcesOverview = new DatasourcesOverview(page); await datasourcesOverview.setCompactionConfiguration(datasourceName, compactionConfig); // Saving the compaction config is not instantaneous - await retryIfJestAssertionError(async () => { + await expect(async () => { const savedCompactionConfig = await datasourcesOverview.getCompactionConfiguration(datasourceName); expect(savedCompactionConfig).toEqual(compactionConfig); - }); -} - -async function triggerCompaction(page: playwright.Page) { - const datasourcesOverview = new DatasourcesOverview(page, UNIFIED_CONSOLE_URL); - await datasourcesOverview.triggerCompaction(); -} - -async function waitForCompaction( - page: playwright.Page, - datasourceName: string, - prevNumSegment: number, -): Promise { - await retryIfJestAssertionError(async () => { - const currNumSegment = await getNumSegment(page, datasourceName); - expect(currNumSegment).toBeLessThan(prevNumSegment); - }); - - return getNumSegment(page, datasourceName); + }).toPass(); } -async function getNumSegment(page: playwright.Page, datasourceName: string): Promise { +async function getNumSegment(page: Page, datasourceName: string): Promise { const datasource = await getDatasource(page, datasourceName); const currNumSegmentString = /(\d+)/.exec(datasource.availability)![0]; return Number(currNumSegmentString); diff --git a/web-console/e2e-tests/cancel-query.spec.ts b/web-console/e2e-tests/cancel-query.spec.ts index e17ab36fc79d..8c4810176be0 100644 --- a/web-console/e2e-tests/cancel-query.spec.ts +++ b/web-console/e2e-tests/cancel-query.spec.ts @@ -16,46 +16,13 @@ * limitations under the License. */ -import type * as playwright from 'playwright-chromium'; - import { QueryOverview } from './component/query/overview'; -import { saveScreenshotIfError } from './util/debug'; -import { UNIFIED_CONSOLE_URL } from './util/druid'; -import { createBrowser, createPage } from './util/playwright'; -import { waitTillWebConsoleReady } from './util/setup'; - -jest.setTimeout(5 * 60 * 1000); - -describe('Cancel query', () => { - let browser: playwright.Browser; - let page: playwright.Page; - - beforeAll(async () => { - await waitTillWebConsoleReady(); - browser = await createBrowser(); - }); +import { expect, test } from './util/fixtures'; - beforeEach(async () => { - page = await createPage(browser); - }); - - afterAll(async () => { - await browser.close(); - }); - - it('delete accepted', async () => { - const testName = 'cancel-query'; - await saveScreenshotIfError(testName, page, async () => { - await validateCancelQuery(page); - }); +test.describe('Cancel query', () => { + test('delete accepted', async ({ page }) => { + const queryOverview = new QueryOverview(page); + const status = await queryOverview.cancelQuery('SELECT sleep(40)'); + expect(status).toBe(202); }); }); - -async function validateCancelQuery(page: playwright.Page) { - const queryOverview = new QueryOverview(page, UNIFIED_CONSOLE_URL); - const query = 'SELECT sleep(40)'; - const results = await queryOverview.cancelQuery(query); - expect(results).toBeDefined(); - expect(results).toBeGreaterThan(0); - expect(results).toStrictEqual(202); -} diff --git a/web-console/e2e-tests/component/datasources/overview.ts b/web-console/e2e-tests/component/datasources/overview.ts index 94bf3bab7c86..2da56d026e1d 100644 --- a/web-console/e2e-tests/component/datasources/overview.ts +++ b/web-console/e2e-tests/component/datasources/overview.ts @@ -16,9 +16,16 @@ * limitations under the License. */ -import type * as playwright from 'playwright-chromium'; - -import { clickButton, getLabeledInput, setLabeledInput } from '../../util/playwright'; +import type { Page } from '@playwright/test'; + +import { + clickButton, + clickMenuItem, + filterableText, + getLabeledInput, + openView, + setLabeledInput, +} from '../../util/playwright'; import { extractTable } from '../../util/table'; import { readPartitionSpec } from '../load-data/config/partition'; @@ -53,19 +60,21 @@ const SKIP_OFFSET_FROM_LATEST = 'Skip offset from latest'; * Represents datasource overview tab. */ export class DatasourcesOverview { - private readonly page: playwright.Page; - private readonly baseUrl: string; + private readonly page: Page; - constructor(page: playwright.Page, unifiedConsoleUrl: string) { + constructor(page: Page) { this.page = page; - this.baseUrl = unifiedConsoleUrl + '#datasources'; } - async getDatasources(): Promise { - await this.page.goto(this.baseUrl); - await this.page.reload({ waitUntil: 'networkidle' }); + private readonly rows = () => this.page.locator('.datasources-view .ct-tr-group'); + + /** + * The datasources whose name contains (the filterable end of) `datasourceName`. + */ + async getDatasources(datasourceName: string): Promise { + await openView(this.page, 'datasources', { datasource: filterableText(datasourceName) }); - const data = await extractTable(this.page, 'div div.ct-tr-group', 'div.ct-td'); + const data = await extractTable(this.rows(), '.ct-td'); return data.map( row => @@ -85,64 +94,38 @@ export class DatasourcesOverview { datasourceName: string, compactionConfig: CompactionConfig, ): Promise { - await this.openCompactionConfigurationDialog(datasourceName); + const dialog = await this.openCompactionConfigurationDialog(datasourceName); - await setLabeledInput( - this.page, - SKIP_OFFSET_FROM_LATEST, - compactionConfig.skipOffsetFromLatest, - ); + await setLabeledInput(dialog, SKIP_OFFSET_FROM_LATEST, compactionConfig.skipOffsetFromLatest); await compactionConfig.partitionsSpec.apply(this.page); - await clickButton(this.page, 'Submit'); - } - - private async openCompactionConfigurationDialog(datasourceName: string): Promise { - await this.openEditActions(datasourceName); - await this.clickMenuItem('Edit compaction configuration'); - await this.page.waitForSelector('div.compaction-config-dialog'); - } - - private async clickMenuItem(text: string): Promise { - const menuItemSelector = `//a[*[contains(text(),"${text}")]]`; - await this.page.click(menuItemSelector); + await clickButton(dialog, 'Submit'); } async getCompactionConfiguration(datasourceName: string): Promise { - await this.openCompactionConfigurationDialog(datasourceName); + const dialog = await this.openCompactionConfigurationDialog(datasourceName); - const skipOffsetFromLatest = await getLabeledInput(this.page, SKIP_OFFSET_FROM_LATEST); + const skipOffsetFromLatest = await getLabeledInput(dialog, SKIP_OFFSET_FROM_LATEST); const partitionsSpec = await readPartitionSpec(this.page); - await clickButton(this.page, 'Close'); + await clickButton(dialog.locator('.bp6-dialog-footer'), 'Close'); return new CompactionConfig({ skipOffsetFromLatest, partitionsSpec: partitionsSpec! }); } - private async openEditActions(datasourceName: string): Promise { - const datasources = await this.getDatasources(); - const index = datasources.findIndex(t => t.name === datasourceName); - if (index < 0) { - throw new Error(`Could not find datasource: ${datasourceName}`); - } - - const editActions = await this.page.$$('.action-cell span.bp6-icon-more'); - await editActions[index].click(); - await this.waitForPopupMenu(); - } + private async openCompactionConfigurationDialog(datasourceName: string) { + await openView(this.page, 'datasources', { datasource: filterableText(datasourceName) }); + await this.rows().locator('.action-cell .bp6-icon-more').click(); + await clickMenuItem(this.page, 'Edit compaction configuration'); - private async waitForPopupMenu(): Promise { - await this.page.waitForSelector('ul.bp6-menu'); + const dialog = this.page.locator('.compaction-config-dialog'); + await dialog.waitFor(); + return dialog; } async triggerCompaction(): Promise { - await this.page.goto(this.baseUrl); - await this.clickMoreButton({ modifiers: ['Alt'] }); - await this.clickMenuItem('Force compaction run'); - await clickButton(this.page, 'Force compaction run'); - } - - private async clickMoreButton(options: any): Promise { - await this.page.click('.more-button button', options); - await this.waitForPopupMenu(); + await openView(this.page, 'datasources'); + await this.page.locator('.more-button button').click({ modifiers: ['Alt'] }); + await clickMenuItem(this.page, 'Force compaction run'); + await clickButton(this.page.locator('.bp6-alert'), 'Force compaction run'); } } diff --git a/web-console/e2e-tests/component/ingestion/overview.ts b/web-console/e2e-tests/component/ingestion/overview.ts index efc2bdb17244..9cf232ae8a68 100644 --- a/web-console/e2e-tests/component/ingestion/overview.ts +++ b/web-console/e2e-tests/component/ingestion/overview.ts @@ -16,8 +16,9 @@ * limitations under the License. */ -import type * as playwright from 'playwright-chromium'; +import type { Page } from '@playwright/test'; +import { filterableText, openView } from '../../util/playwright'; import { extractTable } from '../../util/table'; import { IngestionTask } from './task'; @@ -40,19 +41,19 @@ enum TaskColumn { * Represents task tab. */ export class TasksOverview { - private readonly page: playwright.Page; - private readonly baseUrl: string; + private readonly page: Page; - constructor(page: playwright.Page, unifiedConsoleUrl: string) { + constructor(page: Page) { this.page = page; - this.baseUrl = unifiedConsoleUrl + '#tasks'; } - async getTasks(): Promise { - await this.page.goto(this.baseUrl); - await this.page.reload({ waitUntil: 'networkidle' }); + /** + * The tasks of the datasources whose name contains (the filterable end of) `datasourceName`. + */ + async getTasks(datasourceName: string): Promise { + await openView(this.page, 'tasks', { datasource: filterableText(datasourceName) }); - const data = await extractTable(this.page, 'div.ct-tr-group', 'div.ct-td'); + const data = await extractTable(this.page.locator('.tasks-view .ct-tr-group'), '.ct-td'); return data.map( row => diff --git a/web-console/e2e-tests/component/load-data/config/partition.ts b/web-console/e2e-tests/component/load-data/config/partition.ts index 8c45e923c8da..60763ae7641c 100644 --- a/web-console/e2e-tests/component/load-data/config/partition.ts +++ b/web-console/e2e-tests/component/load-data/config/partition.ts @@ -18,9 +18,10 @@ /* eslint-disable max-classes-per-file */ -import type * as playwright from 'playwright-chromium'; +import type { Page } from '@playwright/test'; import { + formGroup, getLabeledInput, getLabeledTextarea, selectSuggestibleInput, @@ -42,10 +43,10 @@ const PARTITIONING_TYPE = 'Partitioning type'; export interface PartitionsSpec { readonly type: string; - apply(page: playwright.Page): Promise; + apply(page: Page): Promise; } -export async function readPartitionSpec(page: playwright.Page): Promise { +export async function readPartitionSpec(page: Page): Promise { const type = await getLabeledInput(page, PARTITIONING_TYPE); switch (type) { case HashedPartitionsSpec.TYPE: @@ -62,12 +63,11 @@ export class HashedPartitionsSpec implements PartitionsSpec { readonly type: string; - static async read(page: playwright.Page): Promise { - // The shards control may not be visible in that case this is not an error, it is simply not set (null) - let numShards: number | null = null; - try { - numShards = await getLabeledInputAsNumber(page, HashedPartitionsSpec.NUM_SHARDS); - } catch {} + static async read(page: Page): Promise { + // The shards control is not always shown, then it is simply not set (null) + const numShards = (await formGroup(page, HashedPartitionsSpec.NUM_SHARDS).count()) + ? await getLabeledInputAsNumber(page, HashedPartitionsSpec.NUM_SHARDS) + : null; return new HashedPartitionsSpec({ numShards }); } @@ -76,7 +76,7 @@ export class HashedPartitionsSpec implements PartitionsSpec { this.type = HashedPartitionsSpec.TYPE; } - async apply(page: playwright.Page): Promise { + async apply(page: Page): Promise { await setLabeledInput(page, PARTITIONING_TYPE, this.type); if (this.numShards != null) { await setLabeledInput(page, HashedPartitionsSpec.NUM_SHARDS, String(this.numShards)); @@ -84,15 +84,12 @@ export class HashedPartitionsSpec implements PartitionsSpec { } } -async function getLabeledInputAsNumber( - page: playwright.Page, - label: string, -): Promise { +async function getLabeledInputAsNumber(page: Page, label: string): Promise { const valueString = await getLabeledInput(page, label); return valueString === '' ? null : Number(valueString); } -async function getLabeledTextareaAsArray(page: playwright.Page, label: string): Promise { +async function getLabeledTextareaAsArray(page: Page, label: string): Promise { const valueString = await getLabeledTextarea(page, label); return valueString === '' ? [] : valueString.split(',').map(v => v.trim()); } @@ -111,7 +108,7 @@ export class RangePartitionsSpec implements PartitionsSpec { readonly type: string; - static async read(page: playwright.Page): Promise { + static async read(page: Page): Promise { const partitionDimensions = await getLabeledTextareaAsArray( page, RangePartitionsSpec.PARTITION_DIMENSIONS, @@ -136,7 +133,7 @@ export class RangePartitionsSpec implements PartitionsSpec { this.type = RangePartitionsSpec.TYPE; } - async apply(page: playwright.Page): Promise { + async apply(page: Page): Promise { await selectSuggestibleInput(page, PARTITIONING_TYPE, this.type); await setLabeledTextarea( page, diff --git a/web-console/e2e-tests/component/load-data/data-connector/data-connector.ts b/web-console/e2e-tests/component/load-data/data-connector/data-connector.ts index c0da3f9ae957..0fd41b498ff2 100644 --- a/web-console/e2e-tests/component/load-data/data-connector/data-connector.ts +++ b/web-console/e2e-tests/component/load-data/data-connector/data-connector.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import type * as playwright from 'playwright-chromium'; +import type { Page } from '@playwright/test'; import { clickButton } from '../../../util/playwright'; @@ -29,6 +29,6 @@ export interface DataConnector { connect(): Promise; } -export async function clickApplyButton(page: playwright.Page): Promise { +export async function clickApplyButton(page: Page): Promise { await clickButton(page, 'Apply'); } diff --git a/web-console/e2e-tests/component/load-data/data-connector/local-file.ts b/web-console/e2e-tests/component/load-data/data-connector/local-file.ts index cd33b7766e15..9fa08dcb98eb 100644 --- a/web-console/e2e-tests/component/load-data/data-connector/local-file.ts +++ b/web-console/e2e-tests/component/load-data/data-connector/local-file.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import type * as playwright from 'playwright-chromium'; +import type { Page } from '@playwright/test'; import { setLabeledInput } from '../../../util/playwright'; @@ -29,9 +29,9 @@ import { clickApplyButton } from './data-connector'; export class LocalFileDataConnector implements DataConnector { readonly name: string; readonly needParse: boolean; - private readonly page: playwright.Page; + private readonly page: Page; - constructor(page: playwright.Page, props: LocalFileDataConnectorProps) { + constructor(page: Page, props: LocalFileDataConnectorProps) { Object.assign(this, props); this.name = 'Local disk'; this.needParse = true; diff --git a/web-console/e2e-tests/component/load-data/data-connector/reindex.ts b/web-console/e2e-tests/component/load-data/data-connector/reindex.ts index 35f1dc3d9b92..1847c33c2791 100644 --- a/web-console/e2e-tests/component/load-data/data-connector/reindex.ts +++ b/web-console/e2e-tests/component/load-data/data-connector/reindex.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import type * as playwright from 'playwright-chromium'; +import type { Page } from '@playwright/test'; import { setLabeledInput } from '../../../util/playwright'; @@ -29,9 +29,9 @@ import { clickApplyButton } from './data-connector'; export class ReindexDataConnector implements DataConnector { readonly name: string; readonly needParse: boolean; - private readonly page: playwright.Page; + private readonly page: Page; - constructor(page: playwright.Page, props: ReindexDataConnectorProps) { + constructor(page: Page, props: ReindexDataConnectorProps) { Object.assign(this, props); this.name = 'Reindex from Druid'; this.needParse = false; diff --git a/web-console/e2e-tests/component/load-data/data-loader.ts b/web-console/e2e-tests/component/load-data/data-loader.ts index a7a48639eb22..42e1d7f717c9 100644 --- a/web-console/e2e-tests/component/load-data/data-loader.ts +++ b/web-console/e2e-tests/component/load-data/data-loader.ts @@ -16,9 +16,9 @@ * limitations under the License. */ -import type * as playwright from 'playwright-chromium'; +import type { Page } from '@playwright/test'; -import { clickButton, setLabeledInput, setLabeledTextarea } from '../../util/playwright'; +import { clickButton, openView, setLabeledInput, setLabeledTextarea } from '../../util/playwright'; import type { ConfigureSchemaConfig } from './config/configure-schema'; import type { ConfigureTimestampConfig } from './config/configure-timestamp'; @@ -30,19 +30,15 @@ import type { DataConnector } from './data-connector/data-connector'; * Represents load data tab. */ export class DataLoader { - private readonly baseUrl: string; - constructor(props: DataLoaderProps) { Object.assign(this, props); - this.baseUrl = props.unifiedConsoleUrl + '#data-loader'; } /** * Execute each step to load data. */ async load() { - await this.page.goto(this.baseUrl); - await this.startNewSpecIfNeeded(); + await openView(this.page, 'data-loader'); await this.start(); await this.connect(this.connector, this.connectValidator); if (this.connector.needParse) { @@ -58,61 +54,57 @@ export class DataLoader { await this.editSpec(); } - private async startNewSpecIfNeeded() { - const startNewSpecLocator = this.page.locator(`//*[contains(text(),"Start a new")]`); - if (await startNewSpecLocator.count()) { - await startNewSpecLocator.click(); - } - } - private async start() { - const cardSelector = `//*[contains(@class,"bp6-card")][p[contains(text(),"${this.connector.name}")]]`; - await this.page.click(cardSelector); + await this.page + .locator('.bp6-card') + .filter({ has: this.page.locator('p', { hasText: this.connector.name }) }) + .click(); await clickButton(this.page, 'Connect data'); } + private async clickNext(step: string) { + await clickButton(this.page.locator('.next-bar'), `Next: ${step}`); + } + private async connect(connector: DataConnector, validator: (previewLines: string[]) => void) { await connector.connect(); await this.validateConnect(validator); - const next = this.connector.needParse ? 'Parse data' : 'Transform'; - await clickButton(this.page, `Next: ${next}`); + await this.clickNext(this.connector.needParse ? 'Parse data' : 'Transform'); } private async validateConnect(validator: (previewLines: string[]) => void) { - await this.page.waitForSelector('.raw-lines'); - const previewLines = await this.page.$$eval('.raw-lines .raw-line', els => - els.map(el => el.textContent ?? ''), - ); - validator(previewLines); + const rawLines = this.page.locator('.raw-lines .raw-line'); + await rawLines.first().waitFor(); + validator(await rawLines.allTextContents()); } private async parseData() { - await this.page.waitForSelector('.parse-data-table'); - await clickButton(this.page, 'Next: Parse time'); + await this.page.locator('.parse-data-table').waitFor(); + await this.clickNext('Parse time'); } private async parseTime(configureTimestampConfig?: ConfigureTimestampConfig) { - await this.page.waitForSelector('.parse-time-table'); + await this.page.locator('.parse-time-table').waitFor(); if (configureTimestampConfig) { await this.applyConfigureTimestampConfig(configureTimestampConfig); } - await clickButton(this.page, 'Next: Transform'); + await this.clickNext('Transform'); } private async transform() { - await this.page.waitForSelector('.transform-table'); - await clickButton(this.page, 'Next: Filter'); + await this.page.locator('.transform-table').waitFor(); + await this.clickNext('Filter'); } private async filter() { - await this.page.waitForSelector('.filter-table'); - await clickButton(this.page, 'Next: Configure schema'); + await this.page.locator('.filter-table').waitFor(); + await this.clickNext('Configure schema'); } private async configureSchema(configureSchemaConfig: ConfigureSchemaConfig) { - await this.page.waitForSelector('.schema-table'); + await this.page.locator('.schema-table').waitFor(); await this.applyConfigureSchemaConfig(configureSchemaConfig); - await clickButton(this.page, 'Next: Partition'); + await this.clickNext('Partition'); } private async applyConfigureTimestampConfig(configureTimestampConfig: ConfigureTimestampConfig) { @@ -122,24 +114,23 @@ export class DataLoader { } private async applyConfigureSchemaConfig(configureSchemaConfig: ConfigureSchemaConfig) { - const rollupSelector = '//*[text()="Rollup"]'; - const rollupInput = await this.page.$(`${rollupSelector}/input`); - const rollupChecked = await rollupInput!.evaluate(el => (el as HTMLInputElement).checked); - if (rollupChecked !== configureSchemaConfig.rollup) { - await this.page.click(rollupSelector); - const confirmationDialogSelector = '//*[contains(@class,"bp6-alert-body")]'; - await this.page.waitForSelector(confirmationDialogSelector); - await clickButton(this.page, 'Yes'); - const statusMessageSelector = '.recipe-toaster'; - await this.page.waitForSelector(statusMessageSelector); - await this.page.click(`${statusMessageSelector} button`); + const { rollup } = configureSchemaConfig; + const rollupSwitch = this.page.getByLabel('Rollup', { exact: true }); + if ((await rollupSwitch.isChecked()) !== rollup) { + // The switch's input is visually hidden, so click its label (which asks for confirmation) + await this.page.locator('label', { has: rollupSwitch }).click(); + await clickButton( + this.page.locator('.bp6-alert'), + `Yes - ${rollup ? 'enable' : 'disable'} rollup`, + ); + await this.page.locator('.recipe-toaster').getByRole('button').click(); } } private async partition(partitionConfig: PartitionConfig) { - await this.page.waitForSelector('div.load-data-view.partition'); + await this.page.locator('.load-data-view.partition').waitFor(); await this.applyPartitionConfig(partitionConfig); - await clickButton(this.page, 'Next: Tune'); + await this.clickNext('Tune'); } private async applyPartitionConfig(partitionConfig: PartitionConfig) { @@ -153,14 +144,14 @@ export class DataLoader { } private async tune() { - await this.page.waitForSelector('div.load-data-view.tuning'); - await clickButton(this.page, 'Next: Publish'); + await this.page.locator('.load-data-view.tuning').waitFor(); + await this.clickNext('Publish'); } private async publish(publishConfig: PublishConfig) { - await this.page.waitForSelector('div.load-data-view.publish'); + await this.page.locator('.load-data-view.publish').waitFor(); await this.applyPublishConfig(publishConfig); - await clickButton(this.page, 'Edit spec'); + await this.clickNext('Edit spec'); } private async applyPublishConfig(publishConfig: PublishConfig) { @@ -170,14 +161,13 @@ export class DataLoader { } private async editSpec() { - await this.page.waitForSelector('div.load-data-view.spec'); - await clickButton(this.page, 'Submit'); + await this.page.locator('.load-data-view.spec').waitFor(); + await clickButton(this.page.locator('.next-bar'), 'Submit task'); } } interface DataLoaderProps { - readonly page: playwright.Page; - readonly unifiedConsoleUrl: string; + readonly page: Page; readonly connector: DataConnector; readonly connectValidator: (previewLines: string[]) => void; readonly configureTimestampConfig?: ConfigureTimestampConfig; diff --git a/web-console/e2e-tests/component/query/overview.ts b/web-console/e2e-tests/component/query/overview.ts index 541941d18bd4..b5857e20e8fb 100644 --- a/web-console/e2e-tests/component/query/overview.ts +++ b/web-console/e2e-tests/component/query/overview.ts @@ -16,58 +16,52 @@ * limitations under the License. */ -import type * as playwright from 'playwright-chromium'; +import type { Page } from '@playwright/test'; -import { clickButton, clickText, setQueryInput } from '../../util/playwright'; +import { clickButton, openView, setQueryInput } from '../../util/playwright'; import { extractTable } from '../../util/table'; /** * Represents query overview tab. */ export class QueryOverview { - private readonly page: playwright.Page; - private readonly baseUrl: string; + private readonly page: Page; - constructor(page: playwright.Page, unifiedConsoleUrl: string) { + constructor(page: Page) { this.page = page; - this.baseUrl = unifiedConsoleUrl + '#workbench'; } async runQuery(query: string): Promise { - await this.page.goto(this.baseUrl); - await this.page.reload({ waitUntil: 'networkidle' }); + await openView(this.page, 'workbench'); await setQueryInput(this.page, query); - await clickButton(this.page, 'Run'); - await this.page.waitForSelector('div.result-table-pane'); - return await extractTable(this.page, 'div.result-table-pane div.ct-tr-group', 'div.ct-td'); + const results = this.page.locator('.result-table-pane'); + const error = this.page.locator('.execution-error-pane'); + await results.or(error).waitFor(); + if (await error.isVisible()) { + throw new Error(`Query failed: ${await error.innerText()}`); + } + return await extractTable(results.locator('.ct-tr-group'), '.ct-td'); } async cancelQuery(query: string): Promise { - await this.page.goto(this.baseUrl); - await this.page.reload({ waitUntil: 'networkidle' }); + await openView(this.page, 'workbench'); await setQueryInput(this.page, query); - await Promise.all([ - this.page.waitForRequest( - request => request.url().includes('druid/v2') && request.method() === 'POST', - ), - clickButton(this.page, 'Run'), - ]); - - await this.page.waitForSelector('.cancel-label'); - - const [resp] = await Promise.all([ - this.page.waitForResponse( - response => response.url().includes('druid/v2') && response.request().method() === 'DELETE', - ), + const queryRequest = this.page.waitForRequest( + request => request.url().includes('druid/v2') && request.method() === 'POST', + ); + await clickButton(this.page, 'Run'); + await queryRequest; - clickText(this.page, 'Cancel query'), - ]); + const cancelResponse = this.page.waitForResponse( + response => response.url().includes('druid/v2') && response.request().method() === 'DELETE', + ); + await this.page.locator('.cancel-label', { hasText: 'Cancel query' }).click(); - return resp.status(); + return (await cancelResponse).status(); } } diff --git a/web-console/e2e-tests/component/workbench/overview.ts b/web-console/e2e-tests/component/workbench/overview.ts index fc19a40812a3..5ad8660668a2 100644 --- a/web-console/e2e-tests/component/workbench/overview.ts +++ b/web-console/e2e-tests/component/workbench/overview.ts @@ -16,43 +16,38 @@ * limitations under the License. */ -import type * as playwright from 'playwright-chromium'; +import type { Page } from '@playwright/test'; -import { clickButton, setQueryInput } from '../../util/playwright'; +import { clickButton, openView, setQueryInput } from '../../util/playwright'; import { extractTable } from '../../util/table'; /** * Represents the workbench tab. */ export class WorkbenchOverview { - private readonly page: playwright.Page; - private readonly baseUrl: string; + private readonly page: Page; - constructor(page: playwright.Page, unifiedConsoleUrl: string) { + constructor(page: Page) { this.page = page; - this.baseUrl = unifiedConsoleUrl + '#workbench'; } async runQuery(query: string): Promise { - await this.page.goto(this.baseUrl); - await this.page.reload({ waitUntil: 'networkidle' }); + await openView(this.page, 'workbench'); await setQueryInput(this.page, query); await clickButton(this.page, 'Run'); - const results = this.page.locator('div.result-table-pane'); + const results = this.page.locator('.result-table-pane'); const capacityAlert = this.page.locator('.alert-dialog').filter({ hasText: 'The cluster does not currently have enough available task slots', }); - const first = await Promise.race([ - results.waitFor({ timeout: 4 * 60 * 1000 }).then(() => 'results'), - capacityAlert.waitFor({ timeout: 4 * 60 * 1000 }).then(() => 'capacityAlert'), - ]); - if (first === 'capacityAlert') { - await capacityAlert.getByRole('button', { name: 'Run it anyway' }).click(); - await results.waitFor({ timeout: 4 * 60 * 1000 }); + const timeout = 4 * 60 * 1000; + await results.or(capacityAlert).waitFor({ timeout }); + if (await capacityAlert.isVisible()) { + await clickButton(capacityAlert, 'Run it anyway'); + await results.waitFor({ timeout }); } - return await extractTable(this.page, 'div.result-table-pane div.ct-tr-group', 'div.ct-td'); + return await extractTable(results.locator('.ct-tr-group'), '.ct-td'); } } diff --git a/web-console/e2e-tests/multi-stage-query.spec.ts b/web-console/e2e-tests/multi-stage-query.spec.ts index 4647764266ac..c0ec3cadb6db 100644 --- a/web-console/e2e-tests/multi-stage-query.spec.ts +++ b/web-console/e2e-tests/multi-stage-query.spec.ts @@ -16,38 +16,15 @@ * limitations under the License. */ -import type * as playwright from 'playwright-chromium'; - import { WorkbenchOverview } from './component/workbench/overview'; -import { saveScreenshotIfError } from './util/debug'; -import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, UNIFIED_CONSOLE_URL } from './util/druid'; -import { createBrowser, createPage } from './util/playwright'; -import { waitTillWebConsoleReady } from './util/setup'; - -jest.setTimeout(5 * 60 * 1000); - -describe('Multi-stage query', () => { - let browser: playwright.Browser; - let page: playwright.Page; - - beforeAll(async () => { - await waitTillWebConsoleReady(); - browser = await createBrowser(); - }); - - beforeEach(async () => { - page = await createPage(browser); - }); - - afterAll(async () => { - await browser.close(); - }); +import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR } from './util/druid'; +import { expect, test } from './util/fixtures'; - it('runs a query that reads external data', async () => { - const workbench = new WorkbenchOverview(page, UNIFIED_CONSOLE_URL); +test.describe('Multi-stage query', () => { + test('runs a query that reads external data', async ({ page }) => { + const workbench = new WorkbenchOverview(page); - await saveScreenshotIfError('multi-stage-query', page, async () => { - const results = await workbench.runQuery(`WITH ext AS (SELECT * + const results = await workbench.runQuery(`WITH ext AS (SELECT * FROM TABLE( EXTERN( '{"type":"local","filter":"wikiticker-2015-09-12-sampled.json.gz","baseDir":${JSON.stringify( @@ -63,10 +40,8 @@ FROM ext GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 10`); - expect(results).toBeDefined(); - expect(results.length).toBe(10); - expect(results[0]).toStrictEqual(['#en.wikipedia', '11549']); - expect(results[1]).toStrictEqual(['#vi.wikipedia', '9747']); - }); + expect(results.length).toBe(10); + expect(results[0]).toStrictEqual(['#en.wikipedia', '11549']); + expect(results[1]).toStrictEqual(['#vi.wikipedia', '9747']); }); }); diff --git a/web-console/e2e-tests/reindexing.spec.ts b/web-console/e2e-tests/reindexing.spec.ts index caa419e98459..d5251c3a1a44 100644 --- a/web-console/e2e-tests/reindexing.spec.ts +++ b/web-console/e2e-tests/reindexing.spec.ts @@ -16,8 +16,8 @@ * limitations under the License. */ +import type { Page } from '@playwright/test'; import path from 'path'; -import type * as playwright from 'playwright-chromium'; import { DatasourcesOverview } from './component/datasources/overview'; import { TasksOverview } from './component/ingestion/overview'; @@ -30,38 +30,12 @@ import { import { PublishConfig } from './component/load-data/config/publish'; import { ReindexDataConnector } from './component/load-data/data-connector/reindex'; import { DataLoader } from './component/load-data/data-loader'; -import { saveScreenshotIfError } from './util/debug'; -import { - DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, - runIndexTask, - UNIFIED_CONSOLE_URL, -} from './util/druid'; -import { createBrowser, createPage } from './util/playwright'; -import { retryIfJestAssertionError } from './util/retry'; -import { waitTillWebConsoleReady } from './util/setup'; - -jest.setTimeout(5 * 60 * 1000); - -describe('Reindexing from Druid', () => { - let browser: playwright.Browser; - let page: playwright.Page; - - beforeAll(async () => { - await waitTillWebConsoleReady(); - browser = await createBrowser(); - }); - - beforeEach(async () => { - page = await createPage(browser); - }); - - afterAll(async () => { - await browser.close(); - }); +import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, runIndexTask } from './util/druid'; +import { expect, test } from './util/fixtures'; - it('Reindex datasource from dynamic to range partitions', async () => { - const testName = 'reindex-dynamic-to-range'; - const datasourceName = testName + new Date().toISOString(); +test.describe('Reindexing from Druid', () => { + test('Reindex datasource from dynamic to range partitions', async ({ page }) => { + const datasourceName = 'reindex-dynamic-to-range' + new Date().toISOString(); const interval = '2015-09-12/2015-09-13'; const dataConnector = new ReindexDataConnector(page, { datasourceName, @@ -81,7 +55,6 @@ describe('Reindexing from Druid', () => { const dataLoader = new DataLoader({ page: page, - unifiedConsoleUrl: UNIFIED_CONSOLE_URL, connector: dataConnector, connectValidator: validateConnectLocalData, configureSchemaConfig: configureSchemaConfig, @@ -91,16 +64,14 @@ describe('Reindexing from Druid', () => { loadInitialData(datasourceName); - await saveScreenshotIfError(testName, page, async () => { - const numInitialSegment = 1; - await validateDatasourceStatus(page, datasourceName, numInitialSegment); + const numInitialSegment = 1; + await validateDatasourceStatus(page, datasourceName, numInitialSegment); - await dataLoader.load(); - await validateTaskStatus(page, datasourceName); + await dataLoader.load(); + await validateTaskStatus(page, datasourceName); - const numReindexedSegment = 4; // 39k rows into segments of ~10k rows - await validateDatasourceStatus(page, datasourceName, numReindexedSegment); - }); + const numReindexedSegment = 4; // 39k rows into segments of ~10k rows + await validateDatasourceStatus(page, datasourceName, numReindexedSegment); }); }); @@ -153,30 +124,30 @@ function validateConnectLocalData(lines: string[]) { ); } -async function validateTaskStatus(page: playwright.Page, datasourceName: string) { - const tasksOverview = new TasksOverview(page, UNIFIED_CONSOLE_URL); +async function validateTaskStatus(page: Page, datasourceName: string) { + const tasksOverview = new TasksOverview(page); - await retryIfJestAssertionError(async () => { - const tasks = await tasksOverview.getTasks(); + await expect(async () => { + const tasks = await tasksOverview.getTasks(datasourceName); const task = tasks.find(t => t.datasource === datasourceName); expect(task).toBeDefined(); expect(task!.status).toMatch('SUCCESS'); - }); + }).toPass(); } async function validateDatasourceStatus( - page: playwright.Page, + page: Page, datasourceName: string, expectedNumSegment: number, ) { - const datasourcesOverview = new DatasourcesOverview(page, UNIFIED_CONSOLE_URL); + const datasourcesOverview = new DatasourcesOverview(page); const numSegmentString = `${expectedNumSegment} segment` + (expectedNumSegment !== 1 ? 's' : ''); - await retryIfJestAssertionError(async () => { - const datasources = await datasourcesOverview.getDatasources(); + await expect(async () => { + const datasources = await datasourcesOverview.getDatasources(datasourceName); const datasource = datasources.find(t => t.name === datasourceName); expect(datasource).toBeDefined(); expect(datasource!.availability).toMatch(`Fully available (${numSegmentString})`); expect(datasource!.totalRows).toBe(39244); - }); + }).toPass(); } diff --git a/web-console/e2e-tests/tutorial-batch.spec.ts b/web-console/e2e-tests/tutorial-batch.spec.ts index 6da01d57cc99..bbcc65c71bef 100644 --- a/web-console/e2e-tests/tutorial-batch.spec.ts +++ b/web-console/e2e-tests/tutorial-batch.spec.ts @@ -16,8 +16,8 @@ * limitations under the License. */ +import type { Page } from '@playwright/test'; import { T } from 'druid-query-toolkit'; -import type * as playwright from 'playwright-chromium'; import { DatasourcesOverview } from './component/datasources/overview'; import { TasksOverview } from './component/ingestion/overview'; @@ -28,39 +28,17 @@ import { PublishConfig } from './component/load-data/config/publish'; import { LocalFileDataConnector } from './component/load-data/data-connector/local-file'; import { DataLoader } from './component/load-data/data-loader'; import { QueryOverview } from './component/query/overview'; -import { saveScreenshotIfError } from './util/debug'; -import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, UNIFIED_CONSOLE_URL } from './util/druid'; -import { createBrowser, createPage } from './util/playwright'; -import { retryIfJestAssertionError, retryOnAnyError } from './util/retry'; -import { waitTillWebConsoleReady } from './util/setup'; - -jest.setTimeout(5 * 60 * 1000); +import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR } from './util/druid'; +import { expect, test } from './util/fixtures'; const ALL_SORTS_OF_CHARS = '<>|!@#$%^&`\'".,:;\\*()[]{}Україна 한국 中国!?~'; -describe('Tutorial: Loading a file', () => { - let browser: playwright.Browser; - let page: playwright.Page; - - beforeAll(async () => { - await waitTillWebConsoleReady(); - browser = await createBrowser(); - }); - - beforeEach(async () => { - page = await createPage(browser); - }); - - afterAll(async () => { - await browser.close(); - }); - - it('Loads data from local disk', async () => { - const testName = 'load-data-from-local-disk'; - const datasourceName = testName + ALL_SORTS_OF_CHARS + new Date().toISOString(); +test.describe('Tutorial: Loading a file', () => { + test('Loads data from local disk', async ({ page }) => { + const datasourceName = + 'load-data-from-local-disk' + ALL_SORTS_OF_CHARS + new Date().toISOString(); const dataLoader = new DataLoader({ page: page, - unifiedConsoleUrl: UNIFIED_CONSOLE_URL, connector: new LocalFileDataConnector(page, { baseDirectory: DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, fileFilter: 'wikiticker-2015-09-12-sampled.json.gz', @@ -78,12 +56,10 @@ describe('Tutorial: Loading a file', () => { publishConfig: new PublishConfig({ datasourceName: datasourceName }), }); - await saveScreenshotIfError(testName, page, async () => { - await dataLoader.load(); - await validateTaskStatus(page, datasourceName); - await validateDatasourceStatus(page, datasourceName); - await validateQuery(page, datasourceName); - }); + await dataLoader.load(); + await validateTaskStatus(page, datasourceName); + await validateDatasourceStatus(page, datasourceName); + await validateQuery(page, datasourceName); }); }); @@ -141,41 +117,38 @@ function validateConnectLocalData(lines: string[]) { ); } -async function validateTaskStatus(page: playwright.Page, datasourceName: string) { - const tasksOverview = new TasksOverview(page, UNIFIED_CONSOLE_URL); +async function validateTaskStatus(page: Page, datasourceName: string) { + const tasksOverview = new TasksOverview(page); - await retryIfJestAssertionError(async () => { - const tasks = await tasksOverview.getTasks(); + await expect(async () => { + const tasks = await tasksOverview.getTasks(datasourceName); const task = tasks.find(t => t.datasource === datasourceName); expect(task).toBeDefined(); expect(task!.status).toMatch('SUCCESS'); - }); + }).toPass(); } -async function validateDatasourceStatus(page: playwright.Page, datasourceName: string) { - const datasourcesOverview = new DatasourcesOverview(page, UNIFIED_CONSOLE_URL); +async function validateDatasourceStatus(page: Page, datasourceName: string) { + const datasourcesOverview = new DatasourcesOverview(page); - await retryIfJestAssertionError(async () => { - const datasources = await datasourcesOverview.getDatasources(); + await expect(async () => { + const datasources = await datasourcesOverview.getDatasources(datasourceName); const datasource = datasources.find(t => t.name === datasourceName); expect(datasource).toBeDefined(); expect(datasource!.availability).toMatch('Fully available (1 segment)'); expect(datasource!.totalRows).toBe(39244); - }); + }).toPass(); } -async function validateQuery(page: playwright.Page, datasourceName: string) { - const queryOverview = new QueryOverview(page, UNIFIED_CONSOLE_URL); +async function validateQuery(page: Page, datasourceName: string) { + const queryOverview = new QueryOverview(page); const query = `SELECT * FROM ${T(datasourceName)} ORDER BY __time`; let results!: string[][]; - await retryOnAnyError( - async () => { - results = await queryOverview.runQuery(query); - expect(results.length).toBeGreaterThan(0); - }, - 1000, - 3, - ); + // The datasource can be available before the Broker's SQL schema knows about it + await expect(async () => { + results = await queryOverview.runQuery(query); + expect(results.length).toBeGreaterThan(0); + }).toPass(); expect(results[0]).toStrictEqual([ /* __time */ '2015-09-12T00:46:58.772Z', /* time */ '2015-09-12T00:46:58.771Z', diff --git a/web-console/e2e-tests/util/debug.ts b/web-console/e2e-tests/util/debug.ts deleted file mode 100644 index bfd2e2db7006..000000000000 --- a/web-console/e2e-tests/util/debug.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { resolve } from 'path'; -import type * as playwright from 'playwright-chromium'; - -export async function saveScreenshotIfError( - filenamePrefix: string, - page: playwright.Page, - test: () => Promise, -) { - try { - await test(); - } catch (e) { - console.log(`Grabbing error screenshot for: ${filenamePrefix}`); - const resolvedPath = resolve(filenamePrefix + '-error-screenshot.jpeg'); - try { - const imageBuffer = await page.screenshot({ path: resolvedPath, type: 'jpeg', quality: 80 }); - console.log(`Image: data:image/jpeg;base64,${imageBuffer.toString('base64')}`); - console.log(`Written error screenshot to: ${resolvedPath}`); - } catch (screenshotError) { - console.log(`Failed to capture screenshot due to: ${screenshotError.message}`); - } - throw e; - } -} diff --git a/web-console/e2e-tests/util/druid.ts b/web-console/e2e-tests/util/druid.ts index 7345a66b9092..f11621bbf9ea 100644 --- a/web-console/e2e-tests/util/druid.ts +++ b/web-console/e2e-tests/util/druid.ts @@ -19,8 +19,6 @@ import { execSync } from 'child_process'; import path from 'path'; -const UNIFIED_CONSOLE_PORT = process.env['DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT'] || '8888'; -export const UNIFIED_CONSOLE_URL = `http://localhost:${UNIFIED_CONSOLE_PORT}/unified-console.html`; export const COORDINATOR_URL = 'http://localhost:8081'; const UTIL_DIR = __dirname; diff --git a/web-console/e2e-tests/util/fixtures.ts b/web-console/e2e-tests/util/fixtures.ts new file mode 100644 index 000000000000..0abac6acb40a --- /dev/null +++ b/web-console/e2e-tests/util/fixtures.ts @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test as base } from '@playwright/test'; + +export { expect } from '@playwright/test'; + +export const test = base.extend<{ logFailedResponses: void }>({ + // Logs every response with an error status (and its body) so that a failure can be understood from the log alone + logFailedResponses: [ + async ({ page }, use) => { + page.on('response', async response => { + if (response.status() < 400) return; + + let bodyText: string; + try { + bodyText = await response.text(); + } catch (e) { + bodyText = `Could not get the body of the error message due to: ${e.message}`; + } + + console.log(`==============================================`); + console.log(`Request failed on ${response.url()} (with status ${response.status()})`); + console.log(`Body: ${bodyText}`); + console.log(`==============================================`); + }); + + await use(); + }, + { auto: true }, + ], +}); diff --git a/web-console/e2e-tests/util/setup.ts b/web-console/e2e-tests/util/global-setup.ts similarity index 58% rename from web-console/e2e-tests/util/setup.ts rename to web-console/e2e-tests/util/global-setup.ts index 27bc807cd81c..7e6f732d0063 100644 --- a/web-console/e2e-tests/util/setup.ts +++ b/web-console/e2e-tests/util/global-setup.ts @@ -16,19 +16,21 @@ * limitations under the License. */ -import { UNIFIED_CONSOLE_URL } from './druid'; -import { createBrowser, createPage } from './playwright'; - -export async function waitTillWebConsoleReady() { - const browser = await createBrowser(); +import type { FullConfig } from '@playwright/test'; +import { expect, request } from '@playwright/test'; +/** + * Waits until the console can run SQL, which is what it needs to function. + */ +export default async function globalSetup(config: FullConfig) { + const { baseURL } = config.projects[0].use; + const api = await request.newContext({ baseURL }); try { - const page = await createPage(browser); - await page.goto(UNIFIED_CONSOLE_URL); - await page.waitForSelector('//*[contains(text(),"console will not function at the moment")]', { - state: 'hidden', - }); + await expect(async () => { + const response = await api.post('/druid/v2/sql', { data: { query: 'SELECT 1' } }); + expect(response.status()).toBe(200); + }).toPass({ timeout: 2 * 60 * 1000, intervals: [1000] }); } finally { - await browser.close(); + await api.dispose(); } } diff --git a/web-console/e2e-tests/util/playwright.ts b/web-console/e2e-tests/util/playwright.ts index efd79764b5a5..25469cdee0f2 100644 --- a/web-console/e2e-tests/util/playwright.ts +++ b/web-console/e2e-tests/util/playwright.ts @@ -16,129 +16,113 @@ * limitations under the License. */ -import * as playwright from 'playwright-chromium'; - -const TRUE = 'true'; -const WIDTH = 1250; -const HEIGHT = 760; -const PADDING = 128; - -export async function createBrowser(): Promise { - const headless = process.env['DRUID_E2E_TEST_HEADLESS'] || TRUE; - const debug = headless !== TRUE; - const launchOptions: any = { - args: [`--window-size=${WIDTH},${HEIGHT + PADDING}`, `--disable-local-storage`], - }; - if (debug) { - launchOptions.headless = false; - launchOptions.slowMo = 20; - } - return playwright.chromium.launch(launchOptions); +import type { Locator, Page } from '@playwright/test'; + +const CONSOLE_PATH = '/unified-console.html'; + +/** + * Opens a view of the console (`view` is its hash route, like 'datasources'), optionally with its table filtered to + * the rows where each `filter` column contains the given text (see `filterableText`). If the console is already open + * it is reloaded, so that the view shows fresh data. + */ +export async function openView( + page: Page, + view: string, + filter?: Record, +): Promise { + // Encoded like TableFilters.toString() in src/utils/table-filters + const filterParam = Object.entries(filter ?? {}) + .map(([column, text]) => `${column}~${text.replace(/[%&/]/g, encodeURIComponent)}`) + .join('&'); + const wasOpen = page.url().includes(CONSOLE_PATH); + await page.goto(`${CONSOLE_PATH}#${view}${filterParam ? `/${filterParam}` : ''}`); + if (wasOpen) await page.reload(); } -export async function createPage(browser: playwright.Browser): Promise { - const context = await browser.newContext(); - const page = await context.newPage(); - await page.setViewportSize({ width: WIDTH, height: HEIGHT }); - - page.on('response', async response => { - if (response.status() < 400) return; - - const request = response.request(); - let bodyText: string; - try { - bodyText = await response.text(); - } catch (e) { - bodyText = `Could not get the body of the error message due to: ${e.message}`; - } - - console.log(`==============================================`); - console.log(`Request failed on ${request.url()} (with status ${response.status()})`); - console.log(`Body: ${bodyText}`); - console.log(`==============================================`); +/** + * The longest end of `text` that a table filter in the hash route can hold: `|` separates a filter's values and the + * route ends at `#` or `?` (see TableFilter and parseHashRoute in src/utils). + */ +export function filterableText(text: string): string { + return text.split(/[#?|]/).pop()!; +} + +function escapeRegExp(text: string): string { + return text.replace(/[$()*+.?[\\\]^{|}]/g, '\\$&'); +} + +/** + * The form group (a Blueprint FormGroup, as rendered by AutoForm and FormGroupWithInfo) labeled `label`. The labels + * are not linked to their inputs, so `getByLabel` can not find them. + */ +export function formGroup(scope: Page | Locator, label: string): Locator { + // The `has` locator is matched relative to each form group, so it must not start from `scope` + const page = 'page' in scope ? scope.page() : scope; + return scope.locator('.bp6-form-group').filter({ + has: page.locator(':scope > .bp6-label', { + hasText: new RegExp(`^\\s*${escapeRegExp(label)}\\s*$`), + }), }); +} - return page; +export function labeledInput(scope: Page | Locator, label: string): Locator { + return formGroup(scope, label).locator('input'); } -export async function getLabeledInput(page: playwright.Page, label: string): Promise { - return await page.$eval( - `//*[text()="${label}"]/following-sibling::div//input`, - el => (el as HTMLInputElement).value, - ); +export function labeledTextarea(scope: Page | Locator, label: string): Locator { + return formGroup(scope, label).locator('textarea'); } -export async function getLabeledTextarea(page: playwright.Page, label: string): Promise { - return await page.$eval( - `//*[text()="${label}"]/following-sibling::div//textarea`, - el => (el as HTMLInputElement).value, - ); +export async function getLabeledInput(scope: Page | Locator, label: string): Promise { + return await labeledInput(scope, label).inputValue(); +} + +export async function getLabeledTextarea(scope: Page | Locator, label: string): Promise { + return await labeledTextarea(scope, label).inputValue(); } export async function setLabeledInput( - page: playwright.Page, + scope: Page | Locator, label: string, value: string, ): Promise { - return setLabeledElement(page, 'input', label, value); + await labeledInput(scope, label).fill(value); } export async function setLabeledTextarea( - page: playwright.Page, + scope: Page | Locator, label: string, value: string, ): Promise { - return setLabeledElement(page, 'textarea', label, value); + await labeledTextarea(scope, label).fill(value); } -async function setLabeledElement( - page: playwright.Page, - type: string, +/** + * Picks `value` from the suggestions of a labeled SuggestibleInput. + */ +export async function selectSuggestibleInput( + page: Page, label: string, value: string, ): Promise { - const element = await page.$(`//*[text()="${label}"]/following-sibling::div//${type}`); - await setInput(element!, value); + await formGroup(page, label).getByRole('button').click(); + await page.getByRole('menuitem', { name: value, exact: true }).click(); } -export async function setInput( - input: playwright.ElementHandle, - value: string, -): Promise { - await input.fill(value); -} - -export async function setQueryInput(page: playwright.Page, value: string): Promise { +export async function setQueryInput(page: Page, value: string): Promise { // The query input is a CodeMirror editor, its editable surface is a contenteditable div - const editor = await page.waitForSelector('div.flexible-query-input .cm-content'); - await editor.fill(value); -} - -function buttonSelector(text: string) { - return `//button/*[contains(text(),"${text}")]`; -} - -export async function clickButton(page: playwright.Page, text: string): Promise { - await page.click(buttonSelector(text)); + await page.locator('.flexible-query-input .cm-content').fill(value); } -export async function clickLabeledButton( - page: playwright.Page, - label: string, - text: string, -): Promise { - await page.click(`//*[text()="${label}"]/following-sibling::div${buttonSelector(text)}`); +export function button(scope: Page | Locator, text: string): Locator { + return scope.getByRole('button', { name: text, exact: true }); } -export async function clickText(page: playwright.Page, text: string): Promise { - await page.click(`//*[text()="${text}"]`); +export async function clickButton(scope: Page | Locator, text: string): Promise { + await button(scope, text).click(); } -export async function selectSuggestibleInput( - page: playwright.Page, - label: string, - value: string, -): Promise { - await page.click(`//*[text()="${label}"]/following-sibling::div//button`); - await page.click(`"${value}"`); +export async function clickMenuItem(page: Page, text: string): Promise { + // Not exact: a menu item's label (like "(debug)") is part of its name + await page.getByRole('menuitem', { name: text }).click(); } diff --git a/web-console/e2e-tests/util/retry.ts b/web-console/e2e-tests/util/retry.ts deleted file mode 100644 index b077ebda8c76..000000000000 --- a/web-console/e2e-tests/util/retry.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -function sleep(msec: number) { - return new Promise(resolve => setTimeout(resolve, msec)); -} - -export async function retryIfJestAssertionError( - callback: () => Promise, - msec = 1000, - maxTries = 60, -) { - let i = 0; - - while (true) { - try { - await callback(); - return; - } catch (e) { - i++; - if (e.constructor.name === 'JestAssertionError' && i < maxTries) { - await sleep(msec); - } else { - throw e; - } - } - } -} - -export async function retryOnAnyError(callback: () => Promise, msec = 1000, maxTries = 60) { - let i = 0; - - while (true) { - try { - await callback(); - return; - } catch (e) { - i++; - if (i < maxTries) { - await sleep(msec); - } else { - throw e; - } - } - } -} diff --git a/web-console/e2e-tests/util/table.ts b/web-console/e2e-tests/util/table.ts index e40148f42a85..f0bf0247abea 100644 --- a/web-console/e2e-tests/util/table.ts +++ b/web-console/e2e-tests/util/table.ts @@ -16,41 +16,26 @@ * limitations under the License. */ -import type * as playwright from 'playwright-chromium'; +import type { Locator } from '@playwright/test'; /** - * Extracts an HTML table into a text representation. - * @param page Playwright page from which to extract HTML table - * @param tableSelector Playwright selector for table - * @param rowSelector Playwright selector for table row + * Reads the rows of a table as text, skipping the blank rows that pad it. + * @param rows locator matching the table's rows + * @param cellSelector CSS selector for a cell within a row */ -export async function extractTable( - page: playwright.Page, - tableSelector: string, - rowSelector: string, -): Promise { - await page.waitForSelector(tableSelector); - - return page.evaluate( - ([tableSelector, rowSelector]) => { - const BLANK_VALUE = '\xa0'; - const data = []; - const rows = document.querySelectorAll(tableSelector); - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - const columns = row.querySelectorAll(rowSelector); - const values = Array.from(columns).map(c => { - const realTexts = Array.from(c.querySelectorAll('.real-text')); - return realTexts.length - ? (realTexts[0] as HTMLElement).innerText - : (c as HTMLElement).innerText; - }); - if (!values.every(value => value === BLANK_VALUE)) { - data.push(values); - } +export async function extractTable(rows: Locator, cellSelector: string): Promise { + return await rows.evaluateAll((rowElements, cellSelector) => { + const BLANK_VALUE = '\xa0'; + const data: string[][] = []; + for (const row of rowElements) { + const values = Array.from(row.querySelectorAll(cellSelector)).map(c => { + const realText = c.querySelector('.real-text'); + return ((realText ?? c) as HTMLElement).innerText; + }); + if (!values.every(value => value === BLANK_VALUE)) { + data.push(values); } - return data; - }, - [tableSelector, rowSelector], - ); + } + return data; + }, cellSelector); } diff --git a/web-console/package-lock.json b/web-console/package-lock.json index b27cefd9a46e..0cc26eef6329 100644 --- a/web-console/package-lock.json +++ b/web-console/package-lock.json @@ -61,6 +61,7 @@ "@awesome-code-style/stylelint-config": "^6.0.2", "@lezer/generator": "^1.8.1", "@lizenz/checker": "^0.0.2", + "@playwright/test": "^1.63.0", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.0.1", "@types/d3-array": "^3.2.1", @@ -7831,6 +7832,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -17535,6 +17552,22 @@ "node": ">=16.0.0" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/playwright-chromium": { "version": "1.63.0", "resolved": "https://registry.npmjs.org/playwright-chromium/-/playwright-chromium-1.63.0.tgz", diff --git a/web-console/package.json b/web-console/package.json index e32e196c7cd5..881e18bb0eeb 100644 --- a/web-console/package.json +++ b/web-console/package.json @@ -16,7 +16,7 @@ "test-base": "npm run eslint && npm run sasslint && npm run prettify-check && npm run jest", "test": "npm run test-base -- --silent 2>&1", "test-ci": "npm run test-base -- --coverage", - "test-e2e": "jest --runInBand --config jest.e2e.config.js e2e-tests", + "test-e2e": "playwright test", "test-unit": "./script/build-sql-docs && ./script/build-grammars.mjs && npm run typecheck && npm run eslint && npm run sasslint && npm run prettify-check && jest --config jest.config.js --testPathIgnorePatterns='e2e-tests'", "coverage": "jest --coverage src", "update-snapshots": "jest -u --config jest.config.js", @@ -30,8 +30,8 @@ "sasslint-fix": "npm run sasslint -- --fix", "sasslint-changed-only": "git diff --diff-filter=ACMR --name-only | grep -E \\.scss$ | xargs ./node_modules/.bin/stylelint", "sasslint-fix-changed-only": "npm run sasslint-changed-only -- --fix", - "prettify": "prettier --write '{src,e2e-tests}/**/*.{ts,tsx,scss}' './*.js'", - "prettify-check": "prettier --check '{src,e2e-tests}/**/*.{ts,tsx,scss}' './*.js'", + "prettify": "prettier --write '{src,e2e-tests}/**/*.{ts,tsx,scss}' './*.{js,ts}'", + "prettify-check": "prettier --check '{src,e2e-tests}/**/*.{ts,tsx,scss}' './*.{js,ts}'", "generate-licenses-file": "license-checker --production --json --out licenses.json", "check-licenses": "license-checker --production --onlyAllow 'Apache-1.1;Apache-2.0;BSD-2-Clause;BSD-3-Clause;0BSD;MIT;ISC;CC0-1.0;OFL-1.1' --summary", "start": "webpack serve" @@ -99,6 +99,7 @@ "@awesome-code-style/stylelint-config": "^6.0.2", "@lezer/generator": "^1.8.1", "@lizenz/checker": "^0.0.2", + "@playwright/test": "^1.63.0", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.0.1", "@types/d3-array": "^3.2.1", diff --git a/web-console/jest.e2e.config.js b/web-console/playwright.config.ts similarity index 51% rename from web-console/jest.e2e.config.js rename to web-console/playwright.config.ts index 50c7ad09470e..b0395fab425d 100644 --- a/web-console/jest.e2e.config.js +++ b/web-console/playwright.config.ts @@ -16,9 +16,28 @@ * limitations under the License. */ -const common = require('./jest.common.config'); +import { defineConfig } from '@playwright/test'; -module.exports = Object.assign(common, { - testMatch: ['**/?(*.)+(spec).ts?(x)'], - testEnvironment: 'node', +const UNIFIED_CONSOLE_PORT = process.env['DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT'] || '8888'; + +export default defineConfig({ + testDir: 'e2e-tests', + // The tests share one Druid cluster (and its task slots) + workers: 1, + timeout: 5 * 60 * 1000, + expect: { + timeout: 30 * 1000, + // For polling the cluster state: a task finishing, segments loading + toPass: { timeout: 2 * 60 * 1000, intervals: [1000] }, + }, + globalSetup: './e2e-tests/util/global-setup.ts', + reporter: 'list', + use: { + browserName: 'chromium', + baseURL: `http://localhost:${UNIFIED_CONSOLE_PORT}`, + viewport: { width: 1250, height: 760 }, + actionTimeout: 30 * 1000, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, }); diff --git a/web-console/tsconfig.json b/web-console/tsconfig.json index ca1c7539b621..90632120bd12 100644 --- a/web-console/tsconfig.json +++ b/web-console/tsconfig.json @@ -21,5 +21,5 @@ "target": "es2022", "types": ["jest"] }, - "include": ["src", "e2e-tests", "typings"] + "include": ["src", "e2e-tests", "typings", "playwright.config.ts"] } From 2d8b9d563c125685b7ec570ca84a5abe400aa27e Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 13:47:14 -0700 Subject: [PATCH 04/26] check the cluster state in the e2e tests through SQL, not table columns - wait for tasks to finish and segments to load by querying sys.tasks and sys.segments (util/sql.ts) instead of reading the Tasks and Datasources views by column position - read the console's tables by their column headers (extractTableRecords), and use it for the remaining checks of the Datasources and Tasks views in the tutorial test Co-Authored-By: Claude Opus 5.5 --- web-console/E2E_BETTERMENT_PLAN.md | 14 ++- web-console/e2e-tests/README.md | 19 ++-- web-console/e2e-tests/auto-compaction.spec.ts | 46 +++------ .../component/datasources/overview.ts | 40 ++------ .../e2e-tests/component/ingestion/overview.ts | 26 ++--- .../e2e-tests/component/query/overview.ts | 2 +- .../e2e-tests/component/workbench/overview.ts | 2 +- web-console/e2e-tests/reindexing.spec.ts | 50 +++------- web-console/e2e-tests/tutorial-batch.spec.ts | 26 +++-- web-console/e2e-tests/util/sql.ts | 94 +++++++++++++++++++ web-console/e2e-tests/util/table.ts | 48 ++++++---- 11 files changed, 204 insertions(+), 163 deletions(-) create mode 100644 web-console/e2e-tests/util/sql.ts diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index f30b23c3b337..c9658e782052 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -52,11 +52,15 @@ modernize them, in order. Each step should leave the suite passing. `parseHashRoute` ends the route at them, so going from a datasource whose name has `#` or `?` to its tasks or segments drops the rest of the name. -## 3. Don't read tables by column position - -- The `DatasourceColumn` / `TaskColumn` enums break when a default visible column changes. Check cluster state - (task succeeded, N segments available, row count) through the SQL API (`sys.tasks`, `sys.segments`) where the UI - isn't what's being tested, or find columns by their header text. +## 3. Don't read tables by column position (done) + +- The `DatasourceColumn` / `TaskColumn` enums broke when a default visible column changed. +- The tests now wait for the cluster state through SQL (`util/sql.ts`: top-level task statuses from `sys.tasks`, + used and available segments and rows from `sys.segments`), with `expect.poll`. Only the tutorial test still checks + the Datasources and Tasks views, once the cluster is in the state they should show, reading the tables by their + headers (`extractTableRecords`). +- Learned: `index_parallel` subtasks share the datasource (filter on `task_id = group_id`), `sys.segments` returns + no row for an aggregate over no segments, and `expect.poll` stops (does not retry) when its callback throws. ## 4. Set up data through the HTTP API diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index c00b8973bdc6..d32640e0a816 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -62,7 +62,7 @@ for up to 2 minutes. | Spec | What it does | |------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `tutorial-batch.spec.ts` | Follows the [batch loading tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-batch) through the classic **data loader**: connects to `wikiticker-2015-09-12-sampled.json.gz` on local disk, checks the first and last preview lines, sets the timestamp to `timestamp_parse("time") + 1` (so the `__time` check below proves the expression was used), turns rollup off, sets DAY granularity and submits. It then waits for the task to succeed on the **Tasks** view, for the datasource to be fully available as 1 segment with 39,244 rows on the **Datasources** view, and checks the first row of `SELECT *` in the **Query** view. The datasource name contains a string of special and non-Latin characters to test quoting and escaping. | +| `tutorial-batch.spec.ts` | Follows the [batch loading tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-batch) through the classic **data loader**: connects to `wikiticker-2015-09-12-sampled.json.gz` on local disk, checks the first and last preview lines, sets the timestamp to `timestamp_parse("time") + 1` (so the `__time` check below proves the expression was used), turns rollup off, sets DAY granularity and submits. It then waits (through SQL on `sys.tasks` and `sys.segments`) for the task to succeed and for the datasource to be fully available as 1 segment with 39,244 rows, checks that the **Tasks** and **Datasources** views show the same, and checks the first row of `SELECT *` in the **Query** view. The datasource name contains a string of special and non-Latin characters to test quoting and escaping. | | `reindexing.spec.ts` | Loads `wikipedia-index.json` with `post-index-task` (1 segment), then reindexes it through the data loader's **Reindex from Druid** connector into range partitions on `channel` with 10,000 target rows per segment. Checks the preview rows, that the task succeeds, and that the datasource ends up as 4 segments with the same 39,244 rows. | | `auto-compaction.spec.ts` | Follows the [compaction tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-compaction): loads 2 hours of `compaction-init-index.json` with `post-index-task` (3 segments, 1,412 rows), sets a compaction config (`skipOffsetFromLatest: PT0S`, hashed partitions) from the **Datasources** view, reopens the dialog until it reads back the same config, then forces compaction runs (from the Alt-click "more" menu) until there are 2 segments. | | `multi-stage-query.spec.ts` | Runs an MSQ `SELECT` over `EXTERN(...)` on the tutorial file in the **Query** view, clicking "Run it anyway" if the cluster warns it lacks task slots, and checks the top 2 of the 10 channels by count. | @@ -85,8 +85,9 @@ e2e-tests/ fixtures.ts the `test` and `expect` to import in specs (`test` logs failed responses) global-setup.ts waits for the console's SQL endpoint before the tests start druid.ts tutorial data dir, runIndexTask + sql.ts querySql, and the cluster state the tests wait for: task statuses, a datasource's segments playwright.ts openView, and helpers that find inputs and buttons by their label or text - table.ts extractTable: reads a table's rows as text, skipping blank rows + table.ts extractTable / extractTableRecords: read a table's rows as text (by position / by header) ``` ### Writing a test @@ -102,10 +103,12 @@ e2e-tests/ - Open a view with `openView(page, 'datasources', { datasource: ... })`. Filtering to the test's own rows keeps the test working on a cluster with more datasources or tasks than fit on one page. - The query editor is CodeMirror; type into it with `setQueryInput`, which fills its `.cm-content`. -- Table helpers read columns by position (see the column enums in `component/datasources/overview.ts` and - `component/ingestion/overview.ts`), so adding, removing or reordering a default visible column needs the enum - updated. -- Wrap checks on cluster state in `await expect(async () => { ... }).toPass()`: segments load and tasks finish some - time after the console reports success. Make each attempt fail fast (like `QueryOverview.runQuery` throwing on a - query error) rather than wait out a timeout. +- Wait for the cluster state (a task finishing, segments loading) through SQL rather than by reading a view: + `await expect.poll(() => getDatasourceSegments(request, name), CLUSTER_STATE_POLL).toEqual({ ... })`, with the + `request` fixture. Check a view only when the view is what you are testing, and once the cluster is in the state + it should show. +- Read the console's tables with `extractTableRecords`, which keys each row by its column headers, so a column + being added, hidden or moved doesn't break the test. +- Retry UI checks that depend on data loading with `await expect(async () => { ... }).toPass()`. Make each attempt + fail fast (like `QueryOverview.runQuery` throwing on a query error) rather than wait out a timeout. - Give anything you create a unique name (append `new Date().toISOString()`). diff --git a/web-console/e2e-tests/auto-compaction.spec.ts b/web-console/e2e-tests/auto-compaction.spec.ts index 2b3e4dd40faa..37e98f0ae84e 100644 --- a/web-console/e2e-tests/auto-compaction.spec.ts +++ b/web-console/e2e-tests/auto-compaction.spec.ts @@ -20,22 +20,23 @@ import type { Page } from '@playwright/test'; import path from 'path'; import { CompactionConfig } from './component/datasources/compaction'; -import type { Datasource } from './component/datasources/datasource'; import { DatasourcesOverview } from './component/datasources/overview'; import { HashedPartitionsSpec } from './component/load-data/config/partition'; import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, runIndexTask } from './util/druid'; import { expect, test } from './util/fixtures'; +import { CLUSTER_STATE_POLL, getDatasourceSegments } from './util/sql'; // The workflow in these tests is based on the compaction tutorial: // https://druid.apache.org/docs/latest/tutorials/tutorial-compaction.html test.describe('Auto-compaction', () => { - test('Compacts segments from dynamic to hash partitions', async ({ page }) => { + test('Compacts segments from dynamic to hash partitions', async ({ page, request }) => { const datasourceName = 'autocompaction-dynamic-to-hash' + new Date().toISOString(); loadInitialData(datasourceName); - const uncompactedNumSegment = 3; - const numRow = 1412; - await validateDatasourceStatus(page, datasourceName, uncompactedNumSegment, numRow); + const numRows = 1412; + await expect + .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual({ numSegments: 3, numAvailableSegments: 3, numRows }); const compactionConfig = new CompactionConfig({ skipOffsetFromLatest: 'PT0S', @@ -48,12 +49,14 @@ test.describe('Auto-compaction', () => { // Depending on the number of configured tasks slots, autocompaction may // need several iterations if several time chunks need compaction const datasourcesOverview = new DatasourcesOverview(page); - const compactedNumSegment = 2; await expect(async () => { await datasourcesOverview.triggerCompaction(); await expect - .poll(() => getNumSegment(page, datasourceName), { timeout: 60 * 1000, intervals: [1000] }) - .toBe(compactedNumSegment); + .poll(() => getDatasourceSegments(request, datasourceName), { + ...CLUSTER_STATE_POLL, + timeout: 60 * 1000, + }) + .toEqual({ numSegments: 2, numAvailableSegments: 2, numRows }); }).toPass({ timeout: 4 * 60 * 1000 }); }); }); @@ -69,27 +72,6 @@ function loadInitialData(datasourceName: string) { runIndexTask(ingestionSpec, sedCommands); } -async function validateDatasourceStatus( - page: Page, - datasourceName: string, - expectedNumSegment: number, - expectedNumRow: number, -) { - await expect(async () => { - const datasource = await getDatasource(page, datasourceName); - expect(datasource.availability).toMatch(`Fully available (${expectedNumSegment} segments)`); - expect(datasource.totalRows).toBe(expectedNumRow); - }).toPass(); -} - -async function getDatasource(page: Page, datasourceName: string): Promise { - const datasourcesOverview = new DatasourcesOverview(page); - const datasources = await datasourcesOverview.getDatasources(datasourceName); - const datasource = datasources.find(t => t.name === datasourceName); - expect(datasource).toBeDefined(); - return datasource!; -} - async function configureCompaction( page: Page, datasourceName: string, @@ -105,9 +87,3 @@ async function configureCompaction( expect(savedCompactionConfig).toEqual(compactionConfig); }).toPass(); } - -async function getNumSegment(page: Page, datasourceName: string): Promise { - const datasource = await getDatasource(page, datasourceName); - const currNumSegmentString = /(\d+)/.exec(datasource.availability)![0]; - return Number(currNumSegmentString); -} diff --git a/web-console/e2e-tests/component/datasources/overview.ts b/web-console/e2e-tests/component/datasources/overview.ts index 2da56d026e1d..ee9acd57dac5 100644 --- a/web-console/e2e-tests/component/datasources/overview.ts +++ b/web-console/e2e-tests/component/datasources/overview.ts @@ -26,34 +26,12 @@ import { openView, setLabeledInput, } from '../../util/playwright'; -import { extractTable } from '../../util/table'; +import { extractTableRecords } from '../../util/table'; import { readPartitionSpec } from '../load-data/config/partition'; import { CompactionConfig } from './compaction'; import { Datasource } from './datasource'; -/** - * Datasource overview table column identifiers. - */ -enum DatasourceColumn { - NAME = 0, - AVAILABILITY, - SEGMENT_LOAD_DROP, - TOTAL_DATA_SIZE, - RUNNING_TASKS, - SEGMENT_ROWS, - // SEGMENT_SIZE, (Hidden by default) - // SEGMENT_GRANULARITY, (Hidden by default) - TOTAL_ROWS, - AVG_ROW_SIZE, - REPLICATED_SIZE, - COMPACTION, - PERCENT_COMPACTED, - LEFT_TO_BE_COMPACTED, - RETENTION, - ACTIONS, -} - const SKIP_OFFSET_FROM_LATEST = 'Skip offset from latest'; /** @@ -66,7 +44,7 @@ export class DatasourcesOverview { this.page = page; } - private readonly rows = () => this.page.locator('.datasources-view .ct-tr-group'); + private readonly table = () => this.page.locator('.datasources-view .console-table'); /** * The datasources whose name contains (the filterable end of) `datasourceName`. @@ -74,14 +52,14 @@ export class DatasourcesOverview { async getDatasources(datasourceName: string): Promise { await openView(this.page, 'datasources', { datasource: filterableText(datasourceName) }); - const data = await extractTable(this.rows(), '.ct-td'); + const records = await extractTableRecords(this.table()); - return data.map( - row => + return records.map( + record => new Datasource({ - name: row[DatasourceColumn.NAME], - availability: row[DatasourceColumn.AVAILABILITY], - totalRows: DatasourcesOverview.parseNumber(row[DatasourceColumn.TOTAL_ROWS]), + name: record['Datasource name'], + availability: record['Availability'], + totalRows: DatasourcesOverview.parseNumber(record['Total rows']), }), ); } @@ -114,7 +92,7 @@ export class DatasourcesOverview { private async openCompactionConfigurationDialog(datasourceName: string) { await openView(this.page, 'datasources', { datasource: filterableText(datasourceName) }); - await this.rows().locator('.action-cell .bp6-icon-more').click(); + await this.table().locator('.ct-tbody .action-cell .bp6-icon-more').click(); await clickMenuItem(this.page, 'Edit compaction configuration'); const dialog = this.page.locator('.compaction-config-dialog'); diff --git a/web-console/e2e-tests/component/ingestion/overview.ts b/web-console/e2e-tests/component/ingestion/overview.ts index 9cf232ae8a68..b0fb379bd429 100644 --- a/web-console/e2e-tests/component/ingestion/overview.ts +++ b/web-console/e2e-tests/component/ingestion/overview.ts @@ -19,24 +19,10 @@ import type { Page } from '@playwright/test'; import { filterableText, openView } from '../../util/playwright'; -import { extractTable } from '../../util/table'; +import { extractTableRecords } from '../../util/table'; import { IngestionTask } from './task'; -/** - * Ingestion overview task table column identifiers. - */ -enum TaskColumn { - TASK_ID = 0, - GROUP_ID, - TYPE, - DATASOURCE, - STATUS, - CREATED_TIME, - DURATION, - LOCATION, -} - /** * Represents task tab. */ @@ -53,13 +39,13 @@ export class TasksOverview { async getTasks(datasourceName: string): Promise { await openView(this.page, 'tasks', { datasource: filterableText(datasourceName) }); - const data = await extractTable(this.page.locator('.tasks-view .ct-tr-group'), '.ct-td'); + const records = await extractTableRecords(this.page.locator('.tasks-view .console-table')); - return data.map( - row => + return records.map( + record => new IngestionTask({ - datasource: row[TaskColumn.DATASOURCE], - status: row[TaskColumn.STATUS], + datasource: record['Datasource'], + status: record['Status'], }), ); } diff --git a/web-console/e2e-tests/component/query/overview.ts b/web-console/e2e-tests/component/query/overview.ts index b5857e20e8fb..31aa9e7be274 100644 --- a/web-console/e2e-tests/component/query/overview.ts +++ b/web-console/e2e-tests/component/query/overview.ts @@ -43,7 +43,7 @@ export class QueryOverview { if (await error.isVisible()) { throw new Error(`Query failed: ${await error.innerText()}`); } - return await extractTable(results.locator('.ct-tr-group'), '.ct-td'); + return await extractTable(results); } async cancelQuery(query: string): Promise { diff --git a/web-console/e2e-tests/component/workbench/overview.ts b/web-console/e2e-tests/component/workbench/overview.ts index 5ad8660668a2..ce97920b1621 100644 --- a/web-console/e2e-tests/component/workbench/overview.ts +++ b/web-console/e2e-tests/component/workbench/overview.ts @@ -48,6 +48,6 @@ export class WorkbenchOverview { await results.waitFor({ timeout }); } - return await extractTable(results.locator('.ct-tr-group'), '.ct-td'); + return await extractTable(results); } } diff --git a/web-console/e2e-tests/reindexing.spec.ts b/web-console/e2e-tests/reindexing.spec.ts index d5251c3a1a44..4f770d4acf86 100644 --- a/web-console/e2e-tests/reindexing.spec.ts +++ b/web-console/e2e-tests/reindexing.spec.ts @@ -16,11 +16,8 @@ * limitations under the License. */ -import type { Page } from '@playwright/test'; import path from 'path'; -import { DatasourcesOverview } from './component/datasources/overview'; -import { TasksOverview } from './component/ingestion/overview'; import { ConfigureSchemaConfig } from './component/load-data/config/configure-schema'; import { PartitionConfig, @@ -32,9 +29,10 @@ import { ReindexDataConnector } from './component/load-data/data-connector/reind import { DataLoader } from './component/load-data/data-loader'; import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, runIndexTask } from './util/druid'; import { expect, test } from './util/fixtures'; +import { CLUSTER_STATE_POLL, getDatasourceSegments, getTaskStatuses } from './util/sql'; test.describe('Reindexing from Druid', () => { - test('Reindex datasource from dynamic to range partitions', async ({ page }) => { + test('Reindex datasource from dynamic to range partitions', async ({ page, request }) => { const datasourceName = 'reindex-dynamic-to-range' + new Date().toISOString(); const interval = '2015-09-12/2015-09-13'; const dataConnector = new ReindexDataConnector(page, { @@ -64,14 +62,20 @@ test.describe('Reindexing from Druid', () => { loadInitialData(datasourceName); - const numInitialSegment = 1; - await validateDatasourceStatus(page, datasourceName, numInitialSegment); + await expect + .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual({ numSegments: 1, numAvailableSegments: 1, numRows: 39244 }); await dataLoader.load(); - await validateTaskStatus(page, datasourceName); + // The initial load and the reindexing + await expect + .poll(() => getTaskStatuses(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual(['SUCCESS', 'SUCCESS']); - const numReindexedSegment = 4; // 39k rows into segments of ~10k rows - await validateDatasourceStatus(page, datasourceName, numReindexedSegment); + // 39k rows into segments of ~10k rows + await expect + .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual({ numSegments: 4, numAvailableSegments: 4, numRows: 39244 }); }); }); @@ -123,31 +127,3 @@ function validateConnectLocalData(lines: string[]) { '}]', ); } - -async function validateTaskStatus(page: Page, datasourceName: string) { - const tasksOverview = new TasksOverview(page); - - await expect(async () => { - const tasks = await tasksOverview.getTasks(datasourceName); - const task = tasks.find(t => t.datasource === datasourceName); - expect(task).toBeDefined(); - expect(task!.status).toMatch('SUCCESS'); - }).toPass(); -} - -async function validateDatasourceStatus( - page: Page, - datasourceName: string, - expectedNumSegment: number, -) { - const datasourcesOverview = new DatasourcesOverview(page); - const numSegmentString = `${expectedNumSegment} segment` + (expectedNumSegment !== 1 ? 's' : ''); - - await expect(async () => { - const datasources = await datasourcesOverview.getDatasources(datasourceName); - const datasource = datasources.find(t => t.name === datasourceName); - expect(datasource).toBeDefined(); - expect(datasource!.availability).toMatch(`Fully available (${numSegmentString})`); - expect(datasource!.totalRows).toBe(39244); - }).toPass(); -} diff --git a/web-console/e2e-tests/tutorial-batch.spec.ts b/web-console/e2e-tests/tutorial-batch.spec.ts index bbcc65c71bef..eb269a34c2e9 100644 --- a/web-console/e2e-tests/tutorial-batch.spec.ts +++ b/web-console/e2e-tests/tutorial-batch.spec.ts @@ -30,11 +30,12 @@ import { DataLoader } from './component/load-data/data-loader'; import { QueryOverview } from './component/query/overview'; import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR } from './util/druid'; import { expect, test } from './util/fixtures'; +import { CLUSTER_STATE_POLL, getDatasourceSegments, getTaskStatuses } from './util/sql'; const ALL_SORTS_OF_CHARS = '<>|!@#$%^&`\'".,:;\\*()[]{}Україна 한국 中国!?~'; test.describe('Tutorial: Loading a file', () => { - test('Loads data from local disk', async ({ page }) => { + test('Loads data from local disk', async ({ page, request }) => { const datasourceName = 'load-data-from-local-disk' + ALL_SORTS_OF_CHARS + new Date().toISOString(); const dataLoader = new DataLoader({ @@ -57,8 +58,17 @@ test.describe('Tutorial: Loading a file', () => { }); await dataLoader.load(); - await validateTaskStatus(page, datasourceName); - await validateDatasourceStatus(page, datasourceName); + + await expect + .poll(() => getTaskStatuses(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual(['SUCCESS']); + await expect + .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual({ numSegments: 1, numAvailableSegments: 1, numRows: 39244 }); + + // Now that the cluster has the data, the views should show it + await validateTasksView(page, datasourceName); + await validateDatasourcesView(page, datasourceName); await validateQuery(page, datasourceName); }); }); @@ -117,27 +127,29 @@ function validateConnectLocalData(lines: string[]) { ); } -async function validateTaskStatus(page: Page, datasourceName: string) { +async function validateTasksView(page: Page, datasourceName: string) { const tasksOverview = new TasksOverview(page); + // Retried as the view loads its data after it opens await expect(async () => { const tasks = await tasksOverview.getTasks(datasourceName); const task = tasks.find(t => t.datasource === datasourceName); expect(task).toBeDefined(); expect(task!.status).toMatch('SUCCESS'); - }).toPass(); + }).toPass({ timeout: 30 * 1000 }); } -async function validateDatasourceStatus(page: Page, datasourceName: string) { +async function validateDatasourcesView(page: Page, datasourceName: string) { const datasourcesOverview = new DatasourcesOverview(page); + // Retried as the view loads its data after it opens await expect(async () => { const datasources = await datasourcesOverview.getDatasources(datasourceName); const datasource = datasources.find(t => t.name === datasourceName); expect(datasource).toBeDefined(); expect(datasource!.availability).toMatch('Fully available (1 segment)'); expect(datasource!.totalRows).toBe(39244); - }).toPass(); + }).toPass({ timeout: 30 * 1000 }); } async function validateQuery(page: Page, datasourceName: string) { diff --git a/web-console/e2e-tests/util/sql.ts b/web-console/e2e-tests/util/sql.ts new file mode 100644 index 000000000000..cb6e7ea1d1e4 --- /dev/null +++ b/web-console/e2e-tests/util/sql.ts @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { APIRequestContext } from '@playwright/test'; + +/** + * For expect.poll() on the cluster state: a task finishing, segments loading. + */ +export const CLUSTER_STATE_POLL = { timeout: 2 * 60 * 1000, intervals: [1000] }; + +/** + * Runs a SQL query through the console's service (`request` is the Playwright request fixture, which uses the + * console's URL), with `parameters` for its `?` placeholders. + */ +export async function querySql( + request: APIRequestContext, + query: string, + parameters: string[] = [], +): Promise[]> { + const response = await request.post('/druid/v2/sql', { + data: { query, parameters: parameters.map(value => ({ type: 'VARCHAR', value })) }, + }); + if (!response.ok()) { + throw new Error(`SQL query failed with status ${response.status()}: ${await response.text()}`); + } + return (await response.json()) as Record[]; +} + +/** + * The statuses of the tasks of a datasource, oldest first. Only the tasks that were submitted, not the subtasks they + * started (which are in the group of the task that started them). + */ +export async function getTaskStatuses( + request: APIRequestContext, + datasource: string, +): Promise { + const rows = await querySql( + request, + `SELECT "status" +FROM sys.tasks +WHERE "datasource" = ? AND "task_id" = "group_id" +ORDER BY "created_time"`, + [datasource], + ); + return rows.map(row => String(row['status'])); +} + +export interface DatasourceSegments { + readonly numSegments: number; + readonly numAvailableSegments: number; + readonly numRows: number | null; +} + +/** + * The used segments (published and not overshadowed) of a datasource, how many of them are available (loaded) and + * the rows in them. + */ +export async function getDatasourceSegments( + request: APIRequestContext, + datasource: string, +): Promise { + const [row] = await querySql( + request, + `SELECT + COUNT(*) AS "numSegments", + COALESCE(SUM("is_available"), 0) AS "numAvailableSegments", + SUM("num_rows") AS "numRows" +FROM sys.segments +WHERE "datasource" = ? AND "is_published" = 1 AND "is_overshadowed" = 0`, + [datasource], + ); + // Druid returns no row (rather than a row of zeros) when there are no segments + if (!row) return { numSegments: 0, numAvailableSegments: 0, numRows: null }; + return { + numSegments: Number(row['numSegments']), + numAvailableSegments: Number(row['numAvailableSegments']), + numRows: row['numRows'] == null ? null : Number(row['numRows']), + }; +} diff --git a/web-console/e2e-tests/util/table.ts b/web-console/e2e-tests/util/table.ts index f0bf0247abea..5697ba8d89fe 100644 --- a/web-console/e2e-tests/util/table.ts +++ b/web-console/e2e-tests/util/table.ts @@ -19,23 +19,35 @@ import type { Locator } from '@playwright/test'; /** - * Reads the rows of a table as text, skipping the blank rows that pad it. - * @param rows locator matching the table's rows - * @param cellSelector CSS selector for a cell within a row + * Reads the data rows (not the rows that pad the table) of a ConsoleTable as text, one array of cells per row. + * @param table locator of the table (its `.console-table` element, or one containing it) */ -export async function extractTable(rows: Locator, cellSelector: string): Promise { - return await rows.evaluateAll((rowElements, cellSelector) => { - const BLANK_VALUE = '\xa0'; - const data: string[][] = []; - for (const row of rowElements) { - const values = Array.from(row.querySelectorAll(cellSelector)).map(c => { - const realText = c.querySelector('.real-text'); - return ((realText ?? c) as HTMLElement).innerText; - }); - if (!values.every(value => value === BLANK_VALUE)) { - data.push(values); - } - } - return data; - }, cellSelector); +export async function extractTable(table: Locator): Promise { + return (await readTable(table)).rows; +} + +/** + * Reads the data rows of a ConsoleTable as text, each row keyed by its column headers. Headers on two lines are read + * with a space for the line break (like "Datasource name"). + * @param table locator of the table (its `.console-table` element, or one containing it) + */ +export async function extractTableRecords(table: Locator): Promise[]> { + const { headers, rows } = await readTable(table); + return rows.map(row => Object.fromEntries(headers.map((header, i) => [header, row[i]]))); +} + +async function readTable(table: Locator): Promise<{ headers: string[]; rows: string[][] }> { + return await table.evaluate(tableElement => { + const cellText = (cell: Element) => + ((cell.querySelector('.real-text') ?? cell) as HTMLElement).innerText; + + return { + headers: Array.from(tableElement.querySelectorAll('.ct-thead.-header .ct-th'), header => + (header as HTMLElement).innerText.replace(/\s+/g, ' ').trim(), + ), + rows: Array.from(tableElement.querySelectorAll('.ct-tbody .ct-tr:not(.-padRow)'), row => + Array.from(row.querySelectorAll('.ct-td'), cellText), + ), + }; + }); } From 9eae4845ba79bc0e6091d73fb27725bc2a427aa4 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 13:53:51 -0700 Subject: [PATCH 05/26] let table filters in the URL hold any value A filter on a value with a #, ? or | in it (like a link from a datasource to its tasks or segments) filtered on the wrong thing: - TableFilters.toString() did not encode # and ?, which end the hash route, so the rest of the value was dropped; now they are encoded (and decoded) like & % and / - | separates a filter's values and could not be escaped, so a value with a | was split into several; now \| and \\ stand for a literal | and \ in a value, and TableFilters.eq and the filter menu of a table cell filter on their value as one value Co-Authored-By: Claude Opus 5.5 --- .../console-table/table-filter-inputs.tsx | 4 +- .../table-filterable-cell.tsx | 2 +- web-console/src/utils/hash-routing.spec.ts | 12 +++++ .../utils/table-filters/table-filter.spec.ts | 26 +++++++++++ .../src/utils/table-filters/table-filter.ts | 44 +++++++++++++++++-- .../utils/table-filters/table-filters.spec.ts | 18 ++++++++ .../src/utils/table-filters/table-filters.ts | 7 ++- 7 files changed, 102 insertions(+), 11 deletions(-) diff --git a/web-console/src/components/console-table/table-filter-inputs.tsx b/web-console/src/components/console-table/table-filter-inputs.tsx index cf86bf954af0..dc12f14ccb3f 100644 --- a/web-console/src/components/console-table/table-filter-inputs.tsx +++ b/web-console/src/components/console-table/table-filter-inputs.tsx @@ -153,8 +153,8 @@ export function suggestibleFilterInput(suggestions: string[]) { TableFilter.combineModeAndNeedle( '=', valuesFilteredOn - ? toggle(valuesFilteredOn, suggestion).join('|') - : suggestion, + ? TableFilter.joinNeedle(toggle(valuesFilteredOn, suggestion)) + : TableFilter.joinNeedle([suggestion]), true, ), ) diff --git a/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx b/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx index bbab1463c3b1..dc574407c678 100644 --- a/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx +++ b/web-console/src/components/table-filterable-cell/table-filterable-cell.tsx @@ -59,7 +59,7 @@ export const TableFilterableCell = React.memo(function TableFilterableCell( icon={TableFilter.modeToIcon(mode)} text={displayValue ?? value} onClick={() => - onFiltersChange(filters.addOrUpdate(new TableFilter(field, mode, value))) + onFiltersChange(filters.addOrUpdate(new TableFilter(field, mode, [value]))) } /> ))} diff --git a/web-console/src/utils/hash-routing.spec.ts b/web-console/src/utils/hash-routing.spec.ts index 1cc2002fa9c7..24e73cd8d881 100644 --- a/web-console/src/utils/hash-routing.spec.ts +++ b/web-console/src/utils/hash-routing.spec.ts @@ -42,6 +42,18 @@ describe('hash-routing', () => { expect(TableFilters.fromString(route.param).toString()).toEqual(filters.toString()); }); + it('keeps table filters with every sort of character', () => { + const filters = TableFilters.eq({ + datasource: '<>|!@#$%^&`\'".,:;\\*()[]{}Україна 한국 中国!?~', + }); + // As the browser has it in the URL, which percent-encodes the non-ASCII and some ASCII characters + const route = parseHashRoute(encodeURI(`datasources/${filters.toString()}`)); + expect(route.view).toEqual('datasources'); + expect(TableFilters.fromString(route.param).toArray()[0].values).toEqual( + filters.toArray()[0].values, + ); + }); + it('decodes non reserved characters', () => { expect(parseHashRoute('segments/datasource=wiki%20pedia')).toEqual({ view: 'segments', diff --git a/web-console/src/utils/table-filters/table-filter.spec.ts b/web-console/src/utils/table-filters/table-filter.spec.ts index b7fdec612095..8895ed7c1914 100644 --- a/web-console/src/utils/table-filters/table-filter.spec.ts +++ b/web-console/src/utils/table-filters/table-filter.spec.ts @@ -84,6 +84,32 @@ describe('TableFilter', () => { }); }); + describe('splitNeedle / joinNeedle', () => { + it('splits on |', () => { + expect(TableFilter.splitNeedle('a|b')).toEqual(['a', 'b']); + expect(TableFilter.splitNeedle('a')).toEqual(['a']); + expect(TableFilter.splitNeedle('')).toEqual(['']); + }); + + it('unescapes \\| and \\\\, and keeps any other \\', () => { + expect(TableFilter.splitNeedle('a\\|b|c')).toEqual(['a|b', 'c']); + expect(TableFilter.splitNeedle('a\\\\|b')).toEqual(['a\\', 'b']); + expect(TableFilter.splitNeedle('C:\\dir')).toEqual(['C:\\dir']); + }); + + it('round trips any values', () => { + const values = ['a|b', 'c\\', '\\|', '', 'C:\\dir']; + expect(TableFilter.splitNeedle(TableFilter.joinNeedle(values))).toEqual(values); + }); + + it('keeps a literal value that contains | as one value', () => { + const filter = new TableFilter('datasource', '=', ['a|b']); + expect(filter.value).toEqual('a\\|b'); + expect(TableFilter.fromFilter(filter.toFilter()).values).toEqual(['a|b']); + expect(String(filter.toSqlExpression())).toEqual(`"datasource" = 'a|b'`); + }); + }); + describe('equals', () => { it('compares two filters', () => { const filter1 = new TableFilter('x', '=', 'y'); diff --git a/web-console/src/utils/table-filters/table-filter.ts b/web-console/src/utils/table-filters/table-filter.ts index 528ac4076b40..03442d3807fe 100644 --- a/web-console/src/utils/table-filters/table-filter.ts +++ b/web-console/src/utils/table-filters/table-filter.ts @@ -33,9 +33,41 @@ export class TableFilter { public readonly mode: FilterMode; public readonly values: string[]; + /** + * Splits a needle into its values, which are separated by `|`. In a value, `\|` is a literal `|` and `\\` a literal + * `\` (any other `\` is literal too). + */ + static splitNeedle(needle: string): string[] { + const values: string[] = []; + let current = ''; + for (let i = 0; i < needle.length; i++) { + const char = needle[i]; + const next = needle[i + 1]; + if (char === '\\' && (next === '|' || next === '\\')) { + current += next; + i++; + } else if (char === '|') { + values.push(current); + current = ''; + } else { + current += char; + } + } + values.push(current); + return values; + } + + /** + * Joins values into a needle, the inverse of `splitNeedle`. + */ + static joinNeedle(values: readonly string[]): string { + return values.map(value => value.replace(/[\\|]/g, '\\$&')).join('|'); + } + static fromSingleTableFilterString(str: string): TableFilter | undefined { + // Undo the encoding of TableFilters.toString() const m = /^(\w+)((?:~|=|!=|<(?!=)|<=|>(?!=)|>=).*)$/.exec( - str.replace(/%2[56F]/g, decodeURIComponent), + str.replace(/%2[356F]|%3F/g, decodeURIComponent), ); if (!m) return; @@ -64,14 +96,18 @@ export class TableFilter { return new TableFilter(filter.id, mode, value); } + /** + * @param value the values to filter on, or a needle (as typed in a filter input) of values separated by `|` (see + * `splitNeedle`). To filter on one literal value, that may contain a `|`, pass it as `[value]`. + */ constructor(key: string, mode: FilterMode, value: string | string[]) { this.key = key; this.mode = mode; - this.values = typeof value === 'string' ? value.split('|') : value; + this.values = typeof value === 'string' ? TableFilter.splitNeedle(value) : value; } public get value(): string { - return this.values.join('|'); + return TableFilter.joinNeedle(this.values); } public toFilter(): ColumnFilter { @@ -208,7 +244,7 @@ export class TableFilter { return { mode, needle, - needleParts: needle.split('|'), + needleParts: TableFilter.splitNeedle(needle), }; } } diff --git a/web-console/src/utils/table-filters/table-filters.spec.ts b/web-console/src/utils/table-filters/table-filters.spec.ts index 1a9dfa2ef774..3b8b633d7bfe 100644 --- a/web-console/src/utils/table-filters/table-filters.spec.ts +++ b/web-console/src/utils/table-filters/table-filters.spec.ts @@ -28,6 +28,20 @@ describe('TableFilters', () => { ]); expect(filters.toString()).toEqual('x~y&z=w%26%25%2F'); }); + + it('encodes # and ?, which end the hash route', () => { + expect(TableFilters.eq({ x: 'a#b?c' }).toString()).toEqual('x=a%23b%3Fc'); + }); + }); + + describe('toString and fromString', () => { + it('round trip values with every sort of character', () => { + const filters = TableFilters.eq({ + datasource: '<>|!@#$%^&`\'".,:;\\*()[]{}Україна 한국 中国!?~', + }); + const parsed = TableFilters.fromString(filters.toString()); + expect(parsed.toArray()[0].values).toEqual(filters.toArray()[0].values); + }); }); describe('fromString', () => { @@ -82,6 +96,10 @@ describe('TableFilters', () => { expect(filterArray[1].mode).toBe('='); expect(filterArray[1].value).toBe('index'); }); + + it('filters on a value that contains | (rather than on its parts)', () => { + expect(TableFilters.eq({ datasource: 'a|b' }).toArray()[0].values).toEqual(['a|b']); + }); }); describe('empty', () => { diff --git a/web-console/src/utils/table-filters/table-filters.ts b/web-console/src/utils/table-filters/table-filters.ts index ee9a827ebd98..6cb4972be131 100644 --- a/web-console/src/utils/table-filters/table-filters.ts +++ b/web-console/src/utils/table-filters/table-filters.ts @@ -40,7 +40,7 @@ export class TableFilters { static eq(keyValue: Record): TableFilters { const filters = Object.entries(keyValue).map( - ([key, value]) => new TableFilter(key, '=', value), + ([key, value]) => new TableFilter(key, '=', [value]), ); return new TableFilters(filters); } @@ -62,12 +62,11 @@ export class TableFilters { } toString(): string { + // Encodes what would end the filter (&), the hash route's param (/) or the hash route (# and ?) return this.filters .map( filter => - `${filter.key}${filter.mode}${filter.values - .join('|') - .replace(/[&%/]/g, encodeURIComponent)}`, + `${filter.key}${filter.mode}${filter.value.replace(/[#%&/?]/g, encodeURIComponent)}`, ) .join('&'); } From 217d550316f7d840280df3e51352d7d1204101ef Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 13:53:51 -0700 Subject: [PATCH 06/26] filter the e2e tests' views on the exact datasource name Now that table filters in the URL can hold any value, open the Datasources and Tasks views filtered on the test's full datasource name, rather than on the part of it that a filter could hold. Co-Authored-By: Claude Opus 5.5 --- web-console/E2E_BETTERMENT_PLAN.md | 10 +++++----- .../component/datasources/overview.ts | 7 +++---- .../e2e-tests/component/ingestion/overview.ts | 6 +++--- web-console/e2e-tests/util/playwright.ts | 20 ++++++++----------- 4 files changed, 19 insertions(+), 24 deletions(-) diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index c9658e782052..b8f51fee63c1 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -46,11 +46,11 @@ modernize them, in order. Each step should leave the suite passing. - `openEditActions`: find the datasource's row and click its actions button, rather than the Nth `.bp6-icon-more`. - `selectSuggestibleInput`: no legacy `'"value"'` text selector. - Found on the way: the Datasources and Tasks tables page at 50 rows, so on a cluster with leftover datasources the - test's own row could be on page 2. `openView` now filters the view through its hash route. A filter can't hold - `|`, `#` or `?`, so it matches on the end of the name after the last of those (`filterableText`). -- Found on the way (console bug, not fixed here): `TableFilters.toString()` doesn't encode `#` or `?`, but - `parseHashRoute` ends the route at them, so going from a datasource whose name has `#` or `?` to its tasks or - segments drops the rest of the name. + test's own row could be on page 2. `openView` now filters the view to the datasource through its hash route. +- Found on the way (console bugs, fixed in their own commit, could be their own PR): `TableFilters.toString()` didn't + encode `#` or `?`, which end the hash route, and a filter value couldn't contain `|`, which separates values (so + going from a datasource with any of those in its name to its tasks or segments filtered on the wrong thing). + Now `#` and `?` are encoded, and `\|` / `\\` escape `|` / `\` in a value. ## 3. Don't read tables by column position (done) diff --git a/web-console/e2e-tests/component/datasources/overview.ts b/web-console/e2e-tests/component/datasources/overview.ts index ee9acd57dac5..708705b30ec1 100644 --- a/web-console/e2e-tests/component/datasources/overview.ts +++ b/web-console/e2e-tests/component/datasources/overview.ts @@ -21,7 +21,6 @@ import type { Page } from '@playwright/test'; import { clickButton, clickMenuItem, - filterableText, getLabeledInput, openView, setLabeledInput, @@ -47,10 +46,10 @@ export class DatasourcesOverview { private readonly table = () => this.page.locator('.datasources-view .console-table'); /** - * The datasources whose name contains (the filterable end of) `datasourceName`. + * The datasource named `datasourceName` (none, if it doesn't exist yet), as the view shows it. */ async getDatasources(datasourceName: string): Promise { - await openView(this.page, 'datasources', { datasource: filterableText(datasourceName) }); + await openView(this.page, 'datasources', { datasource: datasourceName }); const records = await extractTableRecords(this.table()); @@ -91,7 +90,7 @@ export class DatasourcesOverview { } private async openCompactionConfigurationDialog(datasourceName: string) { - await openView(this.page, 'datasources', { datasource: filterableText(datasourceName) }); + await openView(this.page, 'datasources', { datasource: datasourceName }); await this.table().locator('.ct-tbody .action-cell .bp6-icon-more').click(); await clickMenuItem(this.page, 'Edit compaction configuration'); diff --git a/web-console/e2e-tests/component/ingestion/overview.ts b/web-console/e2e-tests/component/ingestion/overview.ts index b0fb379bd429..1c732253578f 100644 --- a/web-console/e2e-tests/component/ingestion/overview.ts +++ b/web-console/e2e-tests/component/ingestion/overview.ts @@ -18,7 +18,7 @@ import type { Page } from '@playwright/test'; -import { filterableText, openView } from '../../util/playwright'; +import { openView } from '../../util/playwright'; import { extractTableRecords } from '../../util/table'; import { IngestionTask } from './task'; @@ -34,10 +34,10 @@ export class TasksOverview { } /** - * The tasks of the datasources whose name contains (the filterable end of) `datasourceName`. + * The tasks of the datasource `datasourceName`, as the view shows them. */ async getTasks(datasourceName: string): Promise { - await openView(this.page, 'tasks', { datasource: filterableText(datasourceName) }); + await openView(this.page, 'tasks', { datasource: datasourceName }); const records = await extractTableRecords(this.page.locator('.tasks-view .console-table')); diff --git a/web-console/e2e-tests/util/playwright.ts b/web-console/e2e-tests/util/playwright.ts index 25469cdee0f2..4f66c8aea605 100644 --- a/web-console/e2e-tests/util/playwright.ts +++ b/web-console/e2e-tests/util/playwright.ts @@ -22,31 +22,27 @@ const CONSOLE_PATH = '/unified-console.html'; /** * Opens a view of the console (`view` is its hash route, like 'datasources'), optionally with its table filtered to - * the rows where each `filter` column contains the given text (see `filterableText`). If the console is already open - * it is reloaded, so that the view shows fresh data. + * the rows where each `filter` column equals the given value. If the console is already open it is reloaded, so that + * the view shows fresh data. */ export async function openView( page: Page, view: string, filter?: Record, ): Promise { - // Encoded like TableFilters.toString() in src/utils/table-filters + // Encoded like TableFilters.eq(filter).toString() in src/utils/table-filters (which can't be imported here as it + // brings in the whole of src/utils) const filterParam = Object.entries(filter ?? {}) - .map(([column, text]) => `${column}~${text.replace(/[%&/]/g, encodeURIComponent)}`) + .map( + ([column, value]) => + `${column}=${value.replace(/[\\|]/g, '\\$&').replace(/[#%&/?]/g, encodeURIComponent)}`, + ) .join('&'); const wasOpen = page.url().includes(CONSOLE_PATH); await page.goto(`${CONSOLE_PATH}#${view}${filterParam ? `/${filterParam}` : ''}`); if (wasOpen) await page.reload(); } -/** - * The longest end of `text` that a table filter in the hash route can hold: `|` separates a filter's values and the - * route ends at `#` or `?` (see TableFilter and parseHashRoute in src/utils). - */ -export function filterableText(text: string): string { - return text.split(/[#?|]/).pop()!; -} - function escapeRegExp(text: string): string { return text.replace(/[$()*+.?[\\\]^{|}]/g, '\\$&'); } From fd7513deb4a962ad60b5d1d35660c7ddb5f8f8c0 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 14:08:25 -0700 Subject: [PATCH 07/26] submit the e2e tests' setup tasks through the API Rather than run sed and examples/bin/post-index-task (bash and Python, with the Coordinator hard-coded on :8081), read the tutorial ingestion spec, set the datasource name (and interval) on it, POST it through the console's service with the request fixture, and poll the task's status, failing with the task's error if it fails. The spec's input now points at this checkout's tutorial directory rather than one relative to where Druid runs. Co-Authored-By: Claude Opus 5.5 --- web-console/E2E_BETTERMENT_PLAN.md | 9 +-- web-console/e2e-tests/README.md | 12 ++-- web-console/e2e-tests/auto-compaction.spec.ts | 22 +++---- web-console/e2e-tests/reindexing.spec.ts | 15 +++-- web-console/e2e-tests/util/druid.ts | 63 ++++++++++++++----- 5 files changed, 77 insertions(+), 44 deletions(-) diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index b8f51fee63c1..00d8891325ab 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -62,11 +62,12 @@ modernize them, in order. Each step should leave the suite passing. - Learned: `index_parallel` subtasks share the datasource (filter on `task_id = group_id`), `sys.segments` returns no row for an aggregate over no segments, and `expect.poll` stops (does not retry) when its callback throws. -## 4. Set up data through the HTTP API +## 4. Set up data through the HTTP API (done) -- `runIndexTask` shells out to `sed` + `examples/bin/post-index-task` (bash + Python) and hard-codes the Coordinator - on :8081. Read the spec JSON, set the datasource name / interval in code and `POST` it to `/druid/indexer/v1/task` - with the `request` fixture, then poll the task status. +- `runIndexTask` shelled out to `sed` + `examples/bin/post-index-task` (bash + Python) and hard-coded the Coordinator + on :8081. Now `readTutorialIngestionSpec` reads the spec JSON (pointing its input at this checkout), the spec sets + the datasource name / interval on it, and `runIndexTask` `POST`s it to `/druid/indexer/v1/task` with the `request` + fixture (so through the console's service) and polls the task status, failing with the task's error if it fails. ## 5. Clean up after each test diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index d32640e0a816..ebd1b2e03869 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -29,8 +29,8 @@ version. They run in CI as part of `.github/scripts/web-checks.sh`. ## Running them -The tests need a Druid cluster on the standard quickstart ports, started from this checkout (they read the -`examples/quickstart/tutorial` data files from it and post tasks with `examples/bin/post-index-task`): +The tests need a Druid cluster with its Router on :8888 (every request goes through the console's service), running +on the same machine (its tasks read the `examples/quickstart/tutorial` data files of this checkout): ```bash script/druid build # once, builds a distribution with the extensions the tests need @@ -63,8 +63,8 @@ for up to 2 minutes. | Spec | What it does | |------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `tutorial-batch.spec.ts` | Follows the [batch loading tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-batch) through the classic **data loader**: connects to `wikiticker-2015-09-12-sampled.json.gz` on local disk, checks the first and last preview lines, sets the timestamp to `timestamp_parse("time") + 1` (so the `__time` check below proves the expression was used), turns rollup off, sets DAY granularity and submits. It then waits (through SQL on `sys.tasks` and `sys.segments`) for the task to succeed and for the datasource to be fully available as 1 segment with 39,244 rows, checks that the **Tasks** and **Datasources** views show the same, and checks the first row of `SELECT *` in the **Query** view. The datasource name contains a string of special and non-Latin characters to test quoting and escaping. | -| `reindexing.spec.ts` | Loads `wikipedia-index.json` with `post-index-task` (1 segment), then reindexes it through the data loader's **Reindex from Druid** connector into range partitions on `channel` with 10,000 target rows per segment. Checks the preview rows, that the task succeeds, and that the datasource ends up as 4 segments with the same 39,244 rows. | -| `auto-compaction.spec.ts` | Follows the [compaction tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-compaction): loads 2 hours of `compaction-init-index.json` with `post-index-task` (3 segments, 1,412 rows), sets a compaction config (`skipOffsetFromLatest: PT0S`, hashed partitions) from the **Datasources** view, reopens the dialog until it reads back the same config, then forces compaction runs (from the Alt-click "more" menu) until there are 2 segments. | +| `reindexing.spec.ts` | Loads `wikipedia-index.json` through the API (1 segment), then reindexes it through the data loader's **Reindex from Druid** connector into range partitions on `channel` with 10,000 target rows per segment. Checks the preview rows, that the task succeeds, and that the datasource ends up as 4 segments with the same 39,244 rows. | +| `auto-compaction.spec.ts` | Follows the [compaction tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-compaction): loads 2 hours of `compaction-init-index.json` through the API (3 segments, 1,412 rows), sets a compaction config (`skipOffsetFromLatest: PT0S`, hashed partitions) from the **Datasources** view, reopens the dialog until it reads back the same config, then forces compaction runs (from the Alt-click "more" menu) until there are 2 segments. | | `multi-stage-query.spec.ts` | Runs an MSQ `SELECT` over `EXTERN(...)` on the tutorial file in the **Query** view, clicking "Run it anyway" if the cluster warns it lacks task slots, and checks the top 2 of the 10 channels by count. | | `cancel-query.spec.ts` | Runs `SELECT sleep(40)` in the **Query** view, waits for its `POST` to `druid/v2`, clicks "Cancel query" and checks that the `DELETE` it sends is answered with `202 Accepted`. | @@ -84,7 +84,7 @@ e2e-tests/ util/ fixtures.ts the `test` and `expect` to import in specs (`test` logs failed responses) global-setup.ts waits for the console's SQL endpoint before the tests start - druid.ts tutorial data dir, runIndexTask + druid.ts the tutorial data dir and ingestion specs, runIndexTask (submits a task and waits for it) sql.ts querySql, and the cluster state the tests wait for: task statuses, a datasource's segments playwright.ts openView, and helpers that find inputs and buttons by their label or text table.ts extractTable / extractTableRecords: read a table's rows as text (by position / by header) @@ -111,4 +111,6 @@ e2e-tests/ being added, hidden or moved doesn't break the test. - Retry UI checks that depend on data loading with `await expect(async () => { ... }).toPass()`. Make each attempt fail fast (like `QueryOverview.runQuery` throwing on a query error) rather than wait out a timeout. +- Set up data that the test is not about through the API, not the UI: `readTutorialIngestionSpec`, change what you + need (the datasource name at least) and `runIndexTask(request, ingestionSpec)`. - Give anything you create a unique name (append `new Date().toISOString()`). diff --git a/web-console/e2e-tests/auto-compaction.spec.ts b/web-console/e2e-tests/auto-compaction.spec.ts index 37e98f0ae84e..f61604c6bd37 100644 --- a/web-console/e2e-tests/auto-compaction.spec.ts +++ b/web-console/e2e-tests/auto-compaction.spec.ts @@ -16,13 +16,12 @@ * limitations under the License. */ -import type { Page } from '@playwright/test'; -import path from 'path'; +import type { APIRequestContext, Page } from '@playwright/test'; import { CompactionConfig } from './component/datasources/compaction'; import { DatasourcesOverview } from './component/datasources/overview'; import { HashedPartitionsSpec } from './component/load-data/config/partition'; -import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, runIndexTask } from './util/druid'; +import { readTutorialIngestionSpec, runIndexTask } from './util/druid'; import { expect, test } from './util/fixtures'; import { CLUSTER_STATE_POLL, getDatasourceSegments } from './util/sql'; @@ -31,7 +30,7 @@ import { CLUSTER_STATE_POLL, getDatasourceSegments } from './util/sql'; test.describe('Auto-compaction', () => { test('Compacts segments from dynamic to hash partitions', async ({ page, request }) => { const datasourceName = 'autocompaction-dynamic-to-hash' + new Date().toISOString(); - loadInitialData(datasourceName); + await loadInitialData(request, datasourceName); const numRows = 1412; await expect @@ -61,15 +60,12 @@ test.describe('Auto-compaction', () => { }); }); -function loadInitialData(datasourceName: string) { - const ingestionSpec = path.join( - DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, - 'compaction-init-index.json', - ); - const setDatasourceName = `s/compaction-tutorial/${datasourceName}/`; - const setIntervals = 's|2015-09-12/2015-09-13|2015-09-12/2015-09-12T02:00|'; // shorten to reduce test duration - const sedCommands = [setDatasourceName, setIntervals]; - runIndexTask(ingestionSpec, sedCommands); +async function loadInitialData(request: APIRequestContext, datasourceName: string) { + const ingestionSpec = readTutorialIngestionSpec('compaction-init-index.json'); + const { dataSchema } = ingestionSpec.spec; + dataSchema.dataSource = datasourceName; + dataSchema.granularitySpec!.intervals = ['2015-09-12/2015-09-12T02:00']; // 2 hours rather than the day, to be faster + await runIndexTask(request, ingestionSpec); } async function configureCompaction( diff --git a/web-console/e2e-tests/reindexing.spec.ts b/web-console/e2e-tests/reindexing.spec.ts index 4f770d4acf86..64d4cf45a855 100644 --- a/web-console/e2e-tests/reindexing.spec.ts +++ b/web-console/e2e-tests/reindexing.spec.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import path from 'path'; +import type { APIRequestContext } from '@playwright/test'; import { ConfigureSchemaConfig } from './component/load-data/config/configure-schema'; import { @@ -27,7 +27,7 @@ import { import { PublishConfig } from './component/load-data/config/publish'; import { ReindexDataConnector } from './component/load-data/data-connector/reindex'; import { DataLoader } from './component/load-data/data-loader'; -import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, runIndexTask } from './util/druid'; +import { readTutorialIngestionSpec, runIndexTask } from './util/druid'; import { expect, test } from './util/fixtures'; import { CLUSTER_STATE_POLL, getDatasourceSegments, getTaskStatuses } from './util/sql'; @@ -60,7 +60,7 @@ test.describe('Reindexing from Druid', () => { publishConfig: publishConfig, }); - loadInitialData(datasourceName); + await loadInitialData(request, datasourceName); await expect .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) @@ -79,11 +79,10 @@ test.describe('Reindexing from Druid', () => { }); }); -function loadInitialData(datasourceName: string) { - const ingestionSpec = path.join(DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, 'wikipedia-index.json'); - const setDatasourceName = `s/wikipedia/${datasourceName}/`; - const sedCommands = [setDatasourceName]; - runIndexTask(ingestionSpec, sedCommands); +async function loadInitialData(request: APIRequestContext, datasourceName: string) { + const ingestionSpec = readTutorialIngestionSpec('wikipedia-index.json'); + ingestionSpec.spec.dataSchema.dataSource = datasourceName; + await runIndexTask(request, ingestionSpec); } function validateConnectLocalData(lines: string[]) { diff --git a/web-console/e2e-tests/util/druid.ts b/web-console/e2e-tests/util/druid.ts index f11621bbf9ea..0340db7a5e30 100644 --- a/web-console/e2e-tests/util/druid.ts +++ b/web-console/e2e-tests/util/druid.ts @@ -16,10 +16,12 @@ * limitations under the License. */ -import { execSync } from 'child_process'; +import type { APIRequestContext } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { readFileSync } from 'fs'; import path from 'path'; -export const COORDINATOR_URL = 'http://localhost:8081'; +import type { IngestionSpec } from '../../src/druid-models'; const UTIL_DIR = __dirname; const E2E_TEST_DIR = path.dirname(UTIL_DIR); @@ -32,16 +34,49 @@ export const DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR = path.join( 'tutorial', ); -export function runIndexTask(ingestionSpecPath: string, sedCommands: string[]) { - const postIndexTask = path.join(DRUID_DIR, 'examples', 'bin', 'post-index-task'); - const sedCommandsString = sedCommands.map(sedCommand => `-e '${sedCommand}'`).join(' '); - execSync( - `${postIndexTask} \ - --file <(sed ${sedCommandsString} ${ingestionSpecPath}) \ - --url ${COORDINATOR_URL}`, - { - shell: 'bash', - timeout: 3 * 60 * 1000, - }, - ); +/** + * Reads one of the ingestion specs of the tutorials (in examples/quickstart/tutorial), reading its input from this + * checkout (rather than from `quickstart/tutorial/` relative to where Druid runs). + */ +export function readTutorialIngestionSpec(fileName: string): IngestionSpec { + const ingestionSpec = JSON.parse( + readFileSync(path.join(DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, fileName), 'utf-8'), + ) as IngestionSpec; + ingestionSpec.spec.ioConfig.inputSource!.baseDir = DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR; + return ingestionSpec; +} + +/** + * Submits a task (through the console's service, which `request`, the Playwright request fixture, uses) and waits + * for it to succeed. Its segments are loaded some time after that. + */ +export async function runIndexTask( + request: APIRequestContext, + ingestionSpec: IngestionSpec, +): Promise { + const submitResponse = await request.post('/druid/indexer/v1/task', { data: ingestionSpec }); + expect(submitResponse.ok(), await submitResponse.text()).toBe(true); + const taskId = ((await submitResponse.json()) as { task: string }).task; + + let statusCode: string | undefined; + let errorMsg: string | undefined; + await expect + .poll( + async () => { + const statusResponse = await request.get( + `/druid/indexer/v1/task/${encodeURIComponent(taskId)}/status`, + ); + expect(statusResponse.ok(), await statusResponse.text()).toBe(true); + const { status } = (await statusResponse.json()) as { + status: { statusCode: string; errorMsg?: string }; + }; + ({ statusCode, errorMsg } = status); + return statusCode; + }, + { timeout: 3 * 60 * 1000, intervals: [1000], message: `task ${taskId} to finish` }, + ) + .not.toBe('RUNNING'); + + // Fail here, with why the task failed, rather than time out waiting for its segments + expect(statusCode, errorMsg).toBe('SUCCESS'); } From 2b9c7e79b2e5504d81002b7ddabfc5096da96135 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 14:20:32 -0700 Subject: [PATCH 08/26] encode ^ and | in URL paths Api.encodePath left ^ and | as they are, which the browser sends as is and Jetty rejects with "400 Illegal Path Character", so every action on a datasource with one of those in its name (mark unused, kill, retention rules, compaction config) failed. Co-Authored-By: Claude Opus 5.5 --- web-console/src/singletons/api.spec.ts | 1 + web-console/src/singletons/api.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/web-console/src/singletons/api.spec.ts b/web-console/src/singletons/api.spec.ts index 1dda6d8611ef..34b5024feb8a 100644 --- a/web-console/src/singletons/api.spec.ts +++ b/web-console/src/singletons/api.spec.ts @@ -22,6 +22,7 @@ describe('Api', () => { it('escapes stuff', () => { expect(Api.encodePath('wikipedia')).toEqual('wikipedia'); expect(Api.encodePath(`wi%ki?pe#dia&'[;]`)).toEqual('wi%25ki%3Fpe%23dia%26%27%5B%3B%5D'); + expect(Api.encodePath('wiki\\pe^di|a')).toEqual('wiki%5Cpe%5Edi%7Ca'); }); describe(`with BigInt`, () => { diff --git a/web-console/src/singletons/api.ts b/web-console/src/singletons/api.ts index c7b123df76c7..2f330568a5fd 100644 --- a/web-console/src/singletons/api.ts +++ b/web-console/src/singletons/api.ts @@ -71,8 +71,12 @@ export class Api { }; } + /** + * Encodes a value (like a datasource name) for a URL path. Encodes what would change the URL's meaning, and what + * the browser sends as is but the server rejects (^ and |); the browser encodes everything else that needs it. + */ static encodePath(path: string): string { - return path.replace(/[?#%;&'[\]\\]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); + return path.replace(/[#%&';?[\\\]^|]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); } static isNetworkError(e: Error): boolean { From d718d5132a9caf04082f8b31057ca3234feba80e Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 14:20:32 -0700 Subject: [PATCH 09/26] delete the datasources an e2e test created when it passes The newDatasourceName fixture makes a unique datasource name and, after the test passes, shuts down the datasource's tasks, deletes its compaction config, marks its segments unused and kills them. After a failure the datasource is kept to look into, and the log says which. Co-Authored-By: Claude Opus 5.5 --- web-console/E2E_BETTERMENT_PLAN.md | 11 ++-- web-console/e2e-tests/README.md | 16 +++--- web-console/e2e-tests/auto-compaction.spec.ts | 12 +++-- web-console/e2e-tests/reindexing.spec.ts | 12 +++-- web-console/e2e-tests/tutorial-batch.spec.ts | 5 +- web-console/e2e-tests/util/druid.ts | 53 ++++++++++++++++--- web-console/e2e-tests/util/fixtures.ts | 31 ++++++++++- 7 files changed, 113 insertions(+), 27 deletions(-) diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index 00d8891325ab..bf1a8b372764 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -69,9 +69,14 @@ modernize them, in order. Each step should leave the suite passing. the datasource name / interval on it, and `runIndexTask` `POST`s it to `/druid/indexer/v1/task` with the `request` fixture (so through the console's service) and polls the task status, failing with the task's error if it fails. -## 5. Clean up after each test - -- Mark the datasources a test created as unused (and kill its tasks) in an `afterEach`/fixture teardown. +## 5. Clean up after each test (done) + +- The `newDatasourceName(prefix)` fixture makes a unique name and, after the test passes, deletes the datasource + (`deleteDatasource`: shut down its tasks, delete its compaction config, mark its segments unused, run a `kill` task + and wait for it). After a failure the datasource is kept to look into. +- Found on the way (console bug, fixed in its own commit, could be its own PR): `Api.encodePath` left `^` and `|` as + they are, which the browser sends as is and Jetty rejects (400 Illegal Path Character), so every action on a + datasource with one of those in its name (mark unused, kill, retention rules, compaction config) failed. ## 6. Simplify the code diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index ebd1b2e03869..a779306337be 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -52,8 +52,9 @@ Before the tests start, a global setup (`util/global-setup.ts`) waits for the co (git ignored). Every HTTP response with a status of 400 or more is also logged with its body (`util/fixtures.ts`). -The tests create datasources and tasks and do not clean them up. Each datasource name ends in a timestamp so that -runs don't collide, but a cluster that runs the tests often will collect them. +A test deletes the datasources it created when it passes: it stops their tasks, removes their compaction config and +permanently deletes their segments (with a `kill` task). When it fails, they are kept to look into (the log says which) +and have to be deleted by hand, from the Datasources view. ## The tests @@ -82,9 +83,11 @@ e2e-tests/ query/ Query view: run a query, cancel a query workbench/ Query view: run a query, accepting the task slot warning util/ - fixtures.ts the `test` and `expect` to import in specs (`test` logs failed responses) + fixtures.ts the `test` and `expect` to import in specs (`test` logs failed responses, and has the + newDatasourceName fixture) global-setup.ts waits for the console's SQL endpoint before the tests start - druid.ts the tutorial data dir and ingestion specs, runIndexTask (submits a task and waits for it) + druid.ts the tutorial data dir and ingestion specs, runTask (submits a task and waits for it), + deleteDatasource sql.ts querySql, and the cluster state the tests wait for: task statuses, a datasource's segments playwright.ts openView, and helpers that find inputs and buttons by their label or text table.ts extractTable / extractTableRecords: read a table's rows as text (by position / by header) @@ -112,5 +115,6 @@ e2e-tests/ - Retry UI checks that depend on data loading with `await expect(async () => { ... }).toPass()`. Make each attempt fail fast (like `QueryOverview.runQuery` throwing on a query error) rather than wait out a timeout. - Set up data that the test is not about through the API, not the UI: `readTutorialIngestionSpec`, change what you - need (the datasource name at least) and `runIndexTask(request, ingestionSpec)`. -- Give anything you create a unique name (append `new Date().toISOString()`). + need (the datasource name at least) and `runTask(request, ingestionSpec)`. +- Name a datasource you create with the `newDatasourceName` fixture: `newDatasourceName('my-test')` makes a unique + name (the prefix and the time) and deletes the datasource after the test passes. diff --git a/web-console/e2e-tests/auto-compaction.spec.ts b/web-console/e2e-tests/auto-compaction.spec.ts index f61604c6bd37..7048ad5d73ae 100644 --- a/web-console/e2e-tests/auto-compaction.spec.ts +++ b/web-console/e2e-tests/auto-compaction.spec.ts @@ -21,15 +21,19 @@ import type { APIRequestContext, Page } from '@playwright/test'; import { CompactionConfig } from './component/datasources/compaction'; import { DatasourcesOverview } from './component/datasources/overview'; import { HashedPartitionsSpec } from './component/load-data/config/partition'; -import { readTutorialIngestionSpec, runIndexTask } from './util/druid'; +import { readTutorialIngestionSpec, runTask } from './util/druid'; import { expect, test } from './util/fixtures'; import { CLUSTER_STATE_POLL, getDatasourceSegments } from './util/sql'; // The workflow in these tests is based on the compaction tutorial: // https://druid.apache.org/docs/latest/tutorials/tutorial-compaction.html test.describe('Auto-compaction', () => { - test('Compacts segments from dynamic to hash partitions', async ({ page, request }) => { - const datasourceName = 'autocompaction-dynamic-to-hash' + new Date().toISOString(); + test('Compacts segments from dynamic to hash partitions', async ({ + page, + request, + newDatasourceName, + }) => { + const datasourceName = newDatasourceName('autocompaction-dynamic-to-hash'); await loadInitialData(request, datasourceName); const numRows = 1412; @@ -65,7 +69,7 @@ async function loadInitialData(request: APIRequestContext, datasourceName: strin const { dataSchema } = ingestionSpec.spec; dataSchema.dataSource = datasourceName; dataSchema.granularitySpec!.intervals = ['2015-09-12/2015-09-12T02:00']; // 2 hours rather than the day, to be faster - await runIndexTask(request, ingestionSpec); + await runTask(request, ingestionSpec); } async function configureCompaction( diff --git a/web-console/e2e-tests/reindexing.spec.ts b/web-console/e2e-tests/reindexing.spec.ts index 64d4cf45a855..1b80266ac3af 100644 --- a/web-console/e2e-tests/reindexing.spec.ts +++ b/web-console/e2e-tests/reindexing.spec.ts @@ -27,13 +27,17 @@ import { import { PublishConfig } from './component/load-data/config/publish'; import { ReindexDataConnector } from './component/load-data/data-connector/reindex'; import { DataLoader } from './component/load-data/data-loader'; -import { readTutorialIngestionSpec, runIndexTask } from './util/druid'; +import { readTutorialIngestionSpec, runTask } from './util/druid'; import { expect, test } from './util/fixtures'; import { CLUSTER_STATE_POLL, getDatasourceSegments, getTaskStatuses } from './util/sql'; test.describe('Reindexing from Druid', () => { - test('Reindex datasource from dynamic to range partitions', async ({ page, request }) => { - const datasourceName = 'reindex-dynamic-to-range' + new Date().toISOString(); + test('Reindex datasource from dynamic to range partitions', async ({ + page, + request, + newDatasourceName, + }) => { + const datasourceName = newDatasourceName('reindex-dynamic-to-range'); const interval = '2015-09-12/2015-09-13'; const dataConnector = new ReindexDataConnector(page, { datasourceName, @@ -82,7 +86,7 @@ test.describe('Reindexing from Druid', () => { async function loadInitialData(request: APIRequestContext, datasourceName: string) { const ingestionSpec = readTutorialIngestionSpec('wikipedia-index.json'); ingestionSpec.spec.dataSchema.dataSource = datasourceName; - await runIndexTask(request, ingestionSpec); + await runTask(request, ingestionSpec); } function validateConnectLocalData(lines: string[]) { diff --git a/web-console/e2e-tests/tutorial-batch.spec.ts b/web-console/e2e-tests/tutorial-batch.spec.ts index eb269a34c2e9..5b9cfe3995f9 100644 --- a/web-console/e2e-tests/tutorial-batch.spec.ts +++ b/web-console/e2e-tests/tutorial-batch.spec.ts @@ -35,9 +35,8 @@ import { CLUSTER_STATE_POLL, getDatasourceSegments, getTaskStatuses } from './ut const ALL_SORTS_OF_CHARS = '<>|!@#$%^&`\'".,:;\\*()[]{}Україна 한국 中国!?~'; test.describe('Tutorial: Loading a file', () => { - test('Loads data from local disk', async ({ page, request }) => { - const datasourceName = - 'load-data-from-local-disk' + ALL_SORTS_OF_CHARS + new Date().toISOString(); + test('Loads data from local disk', async ({ page, request, newDatasourceName }) => { + const datasourceName = newDatasourceName('load-data-from-local-disk' + ALL_SORTS_OF_CHARS); const dataLoader = new DataLoader({ page: page, connector: new LocalFileDataConnector(page, { diff --git a/web-console/e2e-tests/util/druid.ts b/web-console/e2e-tests/util/druid.ts index 0340db7a5e30..170f8aa1cded 100644 --- a/web-console/e2e-tests/util/druid.ts +++ b/web-console/e2e-tests/util/druid.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import type { APIRequestContext } from '@playwright/test'; +import type { APIRequestContext, APIResponse } from '@playwright/test'; import { expect } from '@playwright/test'; import { readFileSync } from 'fs'; import path from 'path'; @@ -46,15 +46,23 @@ export function readTutorialIngestionSpec(fileName: string): IngestionSpec { return ingestionSpec; } +/** + * Encodes a datasource name (or other value) for a URL path, like Api.encodePath in src/singletons/api.ts (which + * can't be imported here). + */ +function encodePath(path: string): string { + return path.replace(/[#%&';?[\\\]^|]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); +} + /** * Submits a task (through the console's service, which `request`, the Playwright request fixture, uses) and waits - * for it to succeed. Its segments are loaded some time after that. + * for it to succeed. For an ingestion task, its segments are loaded some time after that. */ -export async function runIndexTask( +export async function runTask( request: APIRequestContext, - ingestionSpec: IngestionSpec, + task: IngestionSpec | Record, ): Promise { - const submitResponse = await request.post('/druid/indexer/v1/task', { data: ingestionSpec }); + const submitResponse = await request.post('/druid/indexer/v1/task', { data: task }); expect(submitResponse.ok(), await submitResponse.text()).toBe(true); const taskId = ((await submitResponse.json()) as { task: string }).task; @@ -64,7 +72,7 @@ export async function runIndexTask( .poll( async () => { const statusResponse = await request.get( - `/druid/indexer/v1/task/${encodeURIComponent(taskId)}/status`, + `/druid/indexer/v1/task/${encodePath(taskId)}/status`, ); expect(statusResponse.ok(), await statusResponse.text()).toBe(true); const { status } = (await statusResponse.json()) as { @@ -80,3 +88,36 @@ export async function runIndexTask( // Fail here, with why the task failed, rather than time out waiting for its segments expect(statusCode, errorMsg).toBe('SUCCESS'); } + +/** + * Removes a datasource that a test created, and everything about it: stops its tasks, removes its compaction config + * and permanently deletes its segments. + */ +export async function deleteDatasource( + request: APIRequestContext, + datasource: string, +): Promise { + const encodedDatasource = encodePath(datasource); + const expectOk = async (response: APIResponse, allowNotFound = false) => { + if (allowNotFound && response.status() === 404) return; + expect(response.ok(), `${response.url()}: ${await response.text()}`).toBe(true); + }; + + await expectOk( + await request.post(`/druid/indexer/v1/datasources/${encodedDatasource}/shutdownAllTasks`), + true, // It has no running tasks + ); + await expectOk( + await request.delete(`/druid/indexer/v1/compaction/config/datasources/${encodedDatasource}`), + true, // It has no compaction config + ); + await expectOk( + await request.delete(`/druid/indexer/v1/datasources/${encodedDatasource}`), + true, // It has no segments + ); + await runTask(request, { + type: 'kill', + dataSource: datasource, + interval: '1000-01-01/3000-01-01', + }); +} diff --git a/web-console/e2e-tests/util/fixtures.ts b/web-console/e2e-tests/util/fixtures.ts index 0abac6acb40a..0394602561d7 100644 --- a/web-console/e2e-tests/util/fixtures.ts +++ b/web-console/e2e-tests/util/fixtures.ts @@ -18,9 +18,14 @@ import { test as base } from '@playwright/test'; +import { deleteDatasource } from './druid'; + export { expect } from '@playwright/test'; -export const test = base.extend<{ logFailedResponses: void }>({ +export const test = base.extend<{ + logFailedResponses: void; + newDatasourceName: (prefix: string) => string; +}>({ // Logs every response with an error status (and its body) so that a failure can be understood from the log alone logFailedResponses: [ async ({ page }, use) => { @@ -44,4 +49,28 @@ export const test = base.extend<{ logFailedResponses: void }>({ }, { auto: true }, ], + + // Makes a unique datasource name (the prefix and the time), for the test to create. After the test passes, the + // datasource is deleted (with its tasks, compaction config and segments). After it fails, it is kept to look into. + newDatasourceName: [ + async ({ request }, use, testInfo) => { + const datasourceNames: string[] = []; + await use(prefix => { + const datasourceName = prefix + new Date().toISOString(); + datasourceNames.push(datasourceName); + return datasourceName; + }); + + if (testInfo.status !== testInfo.expectedStatus) { + for (const datasourceName of datasourceNames) { + console.log(`Keeping datasource ${datasourceName} of the failed test`); + } + return; + } + for (const datasourceName of datasourceNames) { + await deleteDatasource(request, datasourceName); + } + }, + { timeout: 2 * 60 * 1000 }, + ], }); From c8c7600b691cfc3724bfda49d8a1df0dc159576d Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 14:31:54 -0700 Subject: [PATCH 10/26] simplify the e2e page objects and their data - merge component/query into component/workbench (both drove the Query view) - plain object types for data (rows read from views, what to set in forms) instead of classes using Object.assign and interface merging, so the no-unsafe-declaration-merging eslint override goes; union types with functions for the partitions specs and data connectors - the data loader is a loadData(page, config) function with one flat config instead of a class with a config object per step Co-Authored-By: Claude Opus 5.5 --- web-console/E2E_BETTERMENT_PLAN.md | 16 +- web-console/e2e-tests/README.md | 13 +- web-console/e2e-tests/auto-compaction.spec.ts | 11 +- web-console/e2e-tests/cancel-query.spec.ts | 6 +- .../component/datasources/compaction.ts | 35 --- .../component/datasources/datasource.ts | 34 --- .../component/datasources/overview.ts | 44 ++-- .../e2e-tests/component/ingestion/overview.ts | 19 +- .../e2e-tests/component/ingestion/task.ts | 33 --- .../load-data/config/configure-schema.ts | 32 --- .../load-data/config/configure-timestamp.ts | 32 --- .../component/load-data/config/partition.ts | 183 -------------- .../component/load-data/config/publish.ts | 32 --- .../component/load-data/data-connector.ts | 65 +++++ .../data-connector/data-connector.ts | 34 --- .../load-data/data-connector/local-file.ts | 53 ---- .../load-data/data-connector/reindex.ts | 53 ---- .../component/load-data/data-loader.ts | 230 +++++++----------- .../component/load-data/partitions-spec.ts | 112 +++++++++ .../e2e-tests/component/query/overview.ts | 67 ----- .../e2e-tests/component/workbench/overview.ts | 36 ++- web-console/e2e-tests/reindexing.spec.ts | 50 ++-- web-console/e2e-tests/tutorial-batch.spec.ts | 39 +-- web-console/eslint.config.mjs | 6 - 24 files changed, 386 insertions(+), 849 deletions(-) delete mode 100644 web-console/e2e-tests/component/datasources/compaction.ts delete mode 100644 web-console/e2e-tests/component/datasources/datasource.ts delete mode 100644 web-console/e2e-tests/component/ingestion/task.ts delete mode 100644 web-console/e2e-tests/component/load-data/config/configure-schema.ts delete mode 100644 web-console/e2e-tests/component/load-data/config/configure-timestamp.ts delete mode 100644 web-console/e2e-tests/component/load-data/config/partition.ts delete mode 100644 web-console/e2e-tests/component/load-data/config/publish.ts create mode 100644 web-console/e2e-tests/component/load-data/data-connector.ts delete mode 100644 web-console/e2e-tests/component/load-data/data-connector/data-connector.ts delete mode 100644 web-console/e2e-tests/component/load-data/data-connector/local-file.ts delete mode 100644 web-console/e2e-tests/component/load-data/data-connector/reindex.ts create mode 100644 web-console/e2e-tests/component/load-data/partitions-spec.ts delete mode 100644 web-console/e2e-tests/component/query/overview.ts diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index bf1a8b372764..919a61d8b7fd 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -78,14 +78,14 @@ modernize them, in order. Each step should leave the suite passing. they are, which the browser sends as is and Jetty rejects (400 Illegal Path Character), so every action on a datasource with one of those in its name (mark unused, kill, retention rules, compaction config) failed. -## 6. Simplify the code - -- Merge `component/query/overview.ts` and `component/workbench/overview.ts` (both drive `#workbench`). -- Plain object types instead of the `Object.assign(this, props)` + interface merging data classes (`PublishConfig`, - `Datasource`, `IngestionTask`, ...); then the `no-unsafe-declaration-merging` eslint override can go. -- No `goto` + `reload({ waitUntil: 'networkidle' })`. -- `cancel-query`: one `expect(status).toBe(202)`. -- Drop `--disable-local-storage` (each test gets a fresh context anyway). +## 6. Simplify the code (done) + +- Merged `component/query/overview.ts` into `component/workbench/overview.ts` (both drove `#workbench`). +- Plain object types instead of the `Object.assign(this, props)` + interface merging data classes, and union types + with functions for the partitions specs and data connectors; the data loader is a `loadData(page, config)` + function with one flat config. The `no-unsafe-declaration-merging` eslint override is gone. +- Done earlier, in steps 1-2: no `goto` + `reload({ waitUntil: 'networkidle' })`, one `expect` in `cancel-query`, + no `--disable-local-storage`. ## 7. More coverage (later, separately) diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index a779306337be..5da0a28169f1 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -74,14 +74,12 @@ for up to 2 minutes. ``` e2e-tests/ *.spec.ts the tests, one describe per file - component/ page objects: one class per view, wrapping its selectors + component/ page objects: one per view, wrapping its selectors datasources/ Datasources view (reads the table, edits and triggers compaction) ingestion/ Tasks view (reads the table) - load-data/ the classic data loader, step by step - data-connector/ the input sources it can connect to (local disk, reindex from Druid) - config/ what to set at each step (timestamp, schema, partition, publish) - query/ Query view: run a query, cancel a query - workbench/ Query view: run a query, accepting the task slot warning + load-data/ the classic data loader: loadData goes through each step with a DataLoaderConfig + (data-connector.ts: local disk, reindex from Druid; partitions-spec.ts: hashed, range) + workbench/ Query view: run a query (accepting the task slot warning), cancel a query util/ fixtures.ts the `test` and `expect` to import in specs (`test` logs failed responses, and has the newDatasourceName fixture) @@ -98,7 +96,8 @@ e2e-tests/ - Import `test` and `expect` from `util/fixtures.ts` (not from `@playwright/test`) and take the `page` fixture: `test('...', async ({ page }) => { ... })`. Each test gets a fresh browser context, so no local storage carries over. -- Put selectors in a page object under `component/` rather than in the spec, and use locators (`page.locator`, +- Put selectors in a page object under `component/` rather than in the spec (and keep data, like what to set in a + form, as plain objects, with a union type when it comes in kinds), and use locators (`page.locator`, `getByRole`), not `page.$` / `waitForSelector` / XPath. The helpers in `util/playwright.ts` find form fields by their visible label (`setLabeledInput(page, 'Datasource name', ...)`; the labels are not linked to their inputs, so `getByLabel` does not work) and buttons by their exact text, so renaming a label or a button in the console diff --git a/web-console/e2e-tests/auto-compaction.spec.ts b/web-console/e2e-tests/auto-compaction.spec.ts index 7048ad5d73ae..85b4c64f4c7b 100644 --- a/web-console/e2e-tests/auto-compaction.spec.ts +++ b/web-console/e2e-tests/auto-compaction.spec.ts @@ -18,9 +18,8 @@ import type { APIRequestContext, Page } from '@playwright/test'; -import { CompactionConfig } from './component/datasources/compaction'; +import type { CompactionConfig } from './component/datasources/overview'; import { DatasourcesOverview } from './component/datasources/overview'; -import { HashedPartitionsSpec } from './component/load-data/config/partition'; import { readTutorialIngestionSpec, runTask } from './util/druid'; import { expect, test } from './util/fixtures'; import { CLUSTER_STATE_POLL, getDatasourceSegments } from './util/sql'; @@ -41,12 +40,10 @@ test.describe('Auto-compaction', () => { .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) .toEqual({ numSegments: 3, numAvailableSegments: 3, numRows }); - const compactionConfig = new CompactionConfig({ + const compactionConfig: CompactionConfig = { skipOffsetFromLatest: 'PT0S', - partitionsSpec: new HashedPartitionsSpec({ - numShards: null, - }), - }); + partitionsSpec: { type: 'hashed', numShards: null }, + }; await configureCompaction(page, datasourceName, compactionConfig); // Depending on the number of configured tasks slots, autocompaction may diff --git a/web-console/e2e-tests/cancel-query.spec.ts b/web-console/e2e-tests/cancel-query.spec.ts index 8c4810176be0..c7ecfa0ed55e 100644 --- a/web-console/e2e-tests/cancel-query.spec.ts +++ b/web-console/e2e-tests/cancel-query.spec.ts @@ -16,13 +16,13 @@ * limitations under the License. */ -import { QueryOverview } from './component/query/overview'; +import { WorkbenchOverview } from './component/workbench/overview'; import { expect, test } from './util/fixtures'; test.describe('Cancel query', () => { test('delete accepted', async ({ page }) => { - const queryOverview = new QueryOverview(page); - const status = await queryOverview.cancelQuery('SELECT sleep(40)'); + const workbench = new WorkbenchOverview(page); + const status = await workbench.cancelQuery('SELECT sleep(40)'); expect(status).toBe(202); }); }); diff --git a/web-console/e2e-tests/component/datasources/compaction.ts b/web-console/e2e-tests/component/datasources/compaction.ts deleted file mode 100644 index 175050406f6f..000000000000 --- a/web-console/e2e-tests/component/datasources/compaction.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { PartitionsSpec } from '../load-data/config/partition'; - -/** - * Datasource compaction configuration - */ -export class CompactionConfig { - constructor(props: CompactionConfigProps) { - Object.assign(this, props); - } -} - -interface CompactionConfigProps { - readonly skipOffsetFromLatest: string; - readonly partitionsSpec: PartitionsSpec; -} - -export interface CompactionConfig extends CompactionConfigProps {} diff --git a/web-console/e2e-tests/component/datasources/datasource.ts b/web-console/e2e-tests/component/datasources/datasource.ts deleted file mode 100644 index a6cdc586d023..000000000000 --- a/web-console/e2e-tests/component/datasources/datasource.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Represents datasource row in datasource overview table. - */ -export class Datasource { - constructor(props: DatasourceProps) { - Object.assign(this, props); - } -} - -interface DatasourceProps { - readonly name: string; - readonly availability: string; - readonly totalRows: number; -} - -export interface Datasource extends DatasourceProps {} diff --git a/web-console/e2e-tests/component/datasources/overview.ts b/web-console/e2e-tests/component/datasources/overview.ts index 708705b30ec1..ac778ad32bee 100644 --- a/web-console/e2e-tests/component/datasources/overview.ts +++ b/web-console/e2e-tests/component/datasources/overview.ts @@ -26,10 +26,25 @@ import { setLabeledInput, } from '../../util/playwright'; import { extractTableRecords } from '../../util/table'; -import { readPartitionSpec } from '../load-data/config/partition'; +import type { PartitionsSpec } from '../load-data/partitions-spec'; +import { applyPartitionsSpec, readPartitionsSpec } from '../load-data/partitions-spec'; -import { CompactionConfig } from './compaction'; -import { Datasource } from './datasource'; +/** + * A row of the Datasources view. + */ +export interface Datasource { + readonly name: string; + readonly availability: string; + readonly totalRows: number; +} + +/** + * What the tests set in the compaction config dialog. + */ +export interface CompactionConfig { + readonly skipOffsetFromLatest: string; + readonly partitionsSpec: PartitionsSpec; +} const SKIP_OFFSET_FROM_LATEST = 'Skip offset from latest'; @@ -53,18 +68,11 @@ export class DatasourcesOverview { const records = await extractTableRecords(this.table()); - return records.map( - record => - new Datasource({ - name: record['Datasource name'], - availability: record['Availability'], - totalRows: DatasourcesOverview.parseNumber(record['Total rows']), - }), - ); - } - - private static parseNumber(text: string): number { - return Number(text.replace(/,/g, '')); + return records.map(record => ({ + name: record['Datasource name'], + availability: record['Availability'], + totalRows: Number(record['Total rows'].replace(/,/g, '')), + })); } async setCompactionConfiguration( @@ -74,7 +82,7 @@ export class DatasourcesOverview { const dialog = await this.openCompactionConfigurationDialog(datasourceName); await setLabeledInput(dialog, SKIP_OFFSET_FROM_LATEST, compactionConfig.skipOffsetFromLatest); - await compactionConfig.partitionsSpec.apply(this.page); + await applyPartitionsSpec(this.page, compactionConfig.partitionsSpec); await clickButton(dialog, 'Submit'); } @@ -83,10 +91,10 @@ export class DatasourcesOverview { const dialog = await this.openCompactionConfigurationDialog(datasourceName); const skipOffsetFromLatest = await getLabeledInput(dialog, SKIP_OFFSET_FROM_LATEST); - const partitionsSpec = await readPartitionSpec(this.page); + const partitionsSpec = await readPartitionsSpec(this.page); await clickButton(dialog.locator('.bp6-dialog-footer'), 'Close'); - return new CompactionConfig({ skipOffsetFromLatest, partitionsSpec: partitionsSpec! }); + return { skipOffsetFromLatest, partitionsSpec: partitionsSpec! }; } private async openCompactionConfigurationDialog(datasourceName: string) { diff --git a/web-console/e2e-tests/component/ingestion/overview.ts b/web-console/e2e-tests/component/ingestion/overview.ts index 1c732253578f..79d90bf78a5c 100644 --- a/web-console/e2e-tests/component/ingestion/overview.ts +++ b/web-console/e2e-tests/component/ingestion/overview.ts @@ -21,7 +21,13 @@ import type { Page } from '@playwright/test'; import { openView } from '../../util/playwright'; import { extractTableRecords } from '../../util/table'; -import { IngestionTask } from './task'; +/** + * A row of the Tasks view. + */ +export interface IngestionTask { + readonly datasource: string; + readonly status: string; +} /** * Represents task tab. @@ -41,12 +47,9 @@ export class TasksOverview { const records = await extractTableRecords(this.page.locator('.tasks-view .console-table')); - return records.map( - record => - new IngestionTask({ - datasource: record['Datasource'], - status: record['Status'], - }), - ); + return records.map(record => ({ + datasource: record['Datasource'], + status: record['Status'], + })); } } diff --git a/web-console/e2e-tests/component/ingestion/task.ts b/web-console/e2e-tests/component/ingestion/task.ts deleted file mode 100644 index 254db429d285..000000000000 --- a/web-console/e2e-tests/component/ingestion/task.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Represents row in ingestion overview task table. - */ -export class IngestionTask { - constructor(props: IngestionTaskProps) { - Object.assign(this, props); - } -} - -interface IngestionTaskProps { - readonly datasource: string; - readonly status: string; -} - -export interface IngestionTask extends IngestionTaskProps {} diff --git a/web-console/e2e-tests/component/load-data/config/configure-schema.ts b/web-console/e2e-tests/component/load-data/config/configure-schema.ts deleted file mode 100644 index 887a0380ef38..000000000000 --- a/web-console/e2e-tests/component/load-data/config/configure-schema.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Data loader configure schema step configuration. - */ -export class ConfigureSchemaConfig { - constructor(props: ConfigureSchemaConfigProps) { - Object.assign(this, props); - } -} - -interface ConfigureSchemaConfigProps { - readonly rollup: boolean; -} - -export interface ConfigureSchemaConfig extends ConfigureSchemaConfigProps {} diff --git a/web-console/e2e-tests/component/load-data/config/configure-timestamp.ts b/web-console/e2e-tests/component/load-data/config/configure-timestamp.ts deleted file mode 100644 index 73e9bfdff30f..000000000000 --- a/web-console/e2e-tests/component/load-data/config/configure-timestamp.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Data loader configure timestamp step configuration. - */ -export class ConfigureTimestampConfig { - constructor(props: ConfigureTimestampConfigProps) { - Object.assign(this, props); - } -} - -interface ConfigureTimestampConfigProps { - readonly timestampExpression: string; -} - -export interface ConfigureTimestampConfig extends ConfigureTimestampConfigProps {} diff --git a/web-console/e2e-tests/component/load-data/config/partition.ts b/web-console/e2e-tests/component/load-data/config/partition.ts deleted file mode 100644 index 60763ae7641c..000000000000 --- a/web-console/e2e-tests/component/load-data/config/partition.ts +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* eslint-disable max-classes-per-file */ - -import type { Page } from '@playwright/test'; - -import { - formGroup, - getLabeledInput, - getLabeledTextarea, - selectSuggestibleInput, - setLabeledInput, - setLabeledTextarea, -} from '../../../util/playwright'; - -/** - * Possible values for partition step segment granularity. - */ -export enum SegmentGranularity { - HOUR = 'hour', - DAY = 'day', - MONTH = 'month', - YEAR = 'year', -} - -const PARTITIONING_TYPE = 'Partitioning type'; - -export interface PartitionsSpec { - readonly type: string; - apply(page: Page): Promise; -} - -export async function readPartitionSpec(page: Page): Promise { - const type = await getLabeledInput(page, PARTITIONING_TYPE); - switch (type) { - case HashedPartitionsSpec.TYPE: - return HashedPartitionsSpec.read(page); - case RangePartitionsSpec.TYPE: - return RangePartitionsSpec.read(page); - } - return null; -} - -export class HashedPartitionsSpec implements PartitionsSpec { - public static TYPE = 'hashed'; - private static readonly NUM_SHARDS = 'Num shards'; - - readonly type: string; - - static async read(page: Page): Promise { - // The shards control is not always shown, then it is simply not set (null) - const numShards = (await formGroup(page, HashedPartitionsSpec.NUM_SHARDS).count()) - ? await getLabeledInputAsNumber(page, HashedPartitionsSpec.NUM_SHARDS) - : null; - return new HashedPartitionsSpec({ numShards }); - } - - constructor(props: HashedPartitionsSpecProps) { - Object.assign(this, props); - this.type = HashedPartitionsSpec.TYPE; - } - - async apply(page: Page): Promise { - await setLabeledInput(page, PARTITIONING_TYPE, this.type); - if (this.numShards != null) { - await setLabeledInput(page, HashedPartitionsSpec.NUM_SHARDS, String(this.numShards)); - } - } -} - -async function getLabeledInputAsNumber(page: Page, label: string): Promise { - const valueString = await getLabeledInput(page, label); - return valueString === '' ? null : Number(valueString); -} - -async function getLabeledTextareaAsArray(page: Page, label: string): Promise { - const valueString = await getLabeledTextarea(page, label); - return valueString === '' ? [] : valueString.split(',').map(v => v.trim()); -} - -interface HashedPartitionsSpecProps { - readonly numShards: number | null; -} - -export interface HashedPartitionsSpec extends HashedPartitionsSpecProps {} - -export class RangePartitionsSpec implements PartitionsSpec { - public static TYPE = 'range'; - private static readonly PARTITION_DIMENSIONS = 'Partition dimensions'; - private static readonly TARGET_ROWS_PER_SEGMENT = 'Target rows per segment'; - private static readonly MAX_ROWS_PER_SEGMENT = 'Max rows per segment'; - - readonly type: string; - - static async read(page: Page): Promise { - const partitionDimensions = await getLabeledTextareaAsArray( - page, - RangePartitionsSpec.PARTITION_DIMENSIONS, - ); - const targetRowsPerSegment = await getLabeledInputAsNumber( - page, - RangePartitionsSpec.TARGET_ROWS_PER_SEGMENT, - ); - const maxRowsPerSegment = await getLabeledInputAsNumber( - page, - RangePartitionsSpec.MAX_ROWS_PER_SEGMENT, - ); - return new RangePartitionsSpec({ - partitionDimensions, - targetRowsPerSegment, - maxRowsPerSegment, - }); - } - - constructor(props: RangePartitionsSpecProps) { - Object.assign(this, props); - this.type = RangePartitionsSpec.TYPE; - } - - async apply(page: Page): Promise { - await selectSuggestibleInput(page, PARTITIONING_TYPE, this.type); - await setLabeledTextarea( - page, - RangePartitionsSpec.PARTITION_DIMENSIONS, - this.partitionDimensions.join(', '), - ); - if (this.targetRowsPerSegment) { - await setLabeledInput( - page, - RangePartitionsSpec.TARGET_ROWS_PER_SEGMENT, - String(this.targetRowsPerSegment), - ); - } - if (this.maxRowsPerSegment) { - await setLabeledInput( - page, - RangePartitionsSpec.MAX_ROWS_PER_SEGMENT, - String(this.maxRowsPerSegment), - ); - } - } -} - -interface RangePartitionsSpecProps { - readonly partitionDimensions: string[]; - readonly targetRowsPerSegment: number | null; - readonly maxRowsPerSegment: number | null; -} - -export interface RangePartitionsSpec extends RangePartitionsSpecProps {} - -/** - * Data loader partition step configuration. - */ -export class PartitionConfig { - constructor(props: PartitionConfigProps) { - Object.assign(this, props); - } -} - -interface PartitionConfigProps { - readonly segmentGranularity: SegmentGranularity; - readonly timeIntervals: string | null; - readonly partitionsSpec: PartitionsSpec | null; -} - -export interface PartitionConfig extends PartitionConfigProps {} diff --git a/web-console/e2e-tests/component/load-data/config/publish.ts b/web-console/e2e-tests/component/load-data/config/publish.ts deleted file mode 100644 index caaa05bf7d51..000000000000 --- a/web-console/e2e-tests/component/load-data/config/publish.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Data loader publish step configuration. - */ -export class PublishConfig { - constructor(props: PublishConfigProps) { - Object.assign(this, props); - } -} - -interface PublishConfigProps { - readonly datasourceName: string; -} - -export interface PublishConfig extends PublishConfigProps {} diff --git a/web-console/e2e-tests/component/load-data/data-connector.ts b/web-console/e2e-tests/component/load-data/data-connector.ts new file mode 100644 index 000000000000..6c5d87974ab7 --- /dev/null +++ b/web-console/e2e-tests/component/load-data/data-connector.ts @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from '@playwright/test'; + +import { clickButton, setLabeledInput } from '../../util/playwright'; + +/** + * Where the data loader reads the data from. + */ +export type DataConnector = + | { readonly type: 'local'; readonly baseDirectory: string; readonly fileFilter: string } + | { readonly type: 'reindex'; readonly datasourceName: string; readonly interval: string }; + +/** + * The title of the connector's card on the data loader's start page. + */ +export function connectorCardTitle(connector: DataConnector): string { + switch (connector.type) { + case 'local': + return 'Local disk'; + case 'reindex': + return 'Reindex from Druid'; + } +} + +/** + * Whether the data needs parsing, so goes through the Parse data and Parse time steps (data from Druid doesn't). + */ +export function connectorNeedsParse(connector: DataConnector): boolean { + return connector.type !== 'reindex'; +} + +/** + * Fills in the Connect step and applies it. + */ +export async function connect(page: Page, connector: DataConnector): Promise { + switch (connector.type) { + case 'local': + await setLabeledInput(page, 'Base directory', connector.baseDirectory); + await setLabeledInput(page, 'File filter', connector.fileFilter); + break; + + case 'reindex': + await setLabeledInput(page, 'Datasource', connector.datasourceName); + await setLabeledInput(page, 'Interval', connector.interval); + break; + } + await clickButton(page, 'Apply'); +} diff --git a/web-console/e2e-tests/component/load-data/data-connector/data-connector.ts b/web-console/e2e-tests/component/load-data/data-connector/data-connector.ts deleted file mode 100644 index 0fd41b498ff2..000000000000 --- a/web-console/e2e-tests/component/load-data/data-connector/data-connector.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Page } from '@playwright/test'; - -import { clickButton } from '../../../util/playwright'; - -/** - * Connector for data loader input data. - */ -export interface DataConnector { - readonly name: string; - readonly needParse: boolean; - connect(): Promise; -} - -export async function clickApplyButton(page: Page): Promise { - await clickButton(page, 'Apply'); -} diff --git a/web-console/e2e-tests/component/load-data/data-connector/local-file.ts b/web-console/e2e-tests/component/load-data/data-connector/local-file.ts deleted file mode 100644 index 9fa08dcb98eb..000000000000 --- a/web-console/e2e-tests/component/load-data/data-connector/local-file.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Page } from '@playwright/test'; - -import { setLabeledInput } from '../../../util/playwright'; - -import type { DataConnector } from './data-connector'; -import { clickApplyButton } from './data-connector'; - -/** - * Local file connector for data loader input data. - */ -export class LocalFileDataConnector implements DataConnector { - readonly name: string; - readonly needParse: boolean; - private readonly page: Page; - - constructor(page: Page, props: LocalFileDataConnectorProps) { - Object.assign(this, props); - this.name = 'Local disk'; - this.needParse = true; - this.page = page; - } - - async connect() { - await setLabeledInput(this.page, 'Base directory', this.baseDirectory); - await setLabeledInput(this.page, 'File filter', this.fileFilter); - await clickApplyButton(this.page); - } -} - -interface LocalFileDataConnectorProps { - readonly baseDirectory: string; - readonly fileFilter: string; -} - -export interface LocalFileDataConnector extends LocalFileDataConnectorProps {} diff --git a/web-console/e2e-tests/component/load-data/data-connector/reindex.ts b/web-console/e2e-tests/component/load-data/data-connector/reindex.ts deleted file mode 100644 index 1847c33c2791..000000000000 --- a/web-console/e2e-tests/component/load-data/data-connector/reindex.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Page } from '@playwright/test'; - -import { setLabeledInput } from '../../../util/playwright'; - -import type { DataConnector } from './data-connector'; -import { clickApplyButton } from './data-connector'; - -/** - * Reindexing connector for data loader input data. - */ -export class ReindexDataConnector implements DataConnector { - readonly name: string; - readonly needParse: boolean; - private readonly page: Page; - - constructor(page: Page, props: ReindexDataConnectorProps) { - Object.assign(this, props); - this.name = 'Reindex from Druid'; - this.needParse = false; - this.page = page; - } - - async connect() { - await setLabeledInput(this.page, 'Datasource', this.datasourceName); - await setLabeledInput(this.page, 'Interval', this.interval); - await clickApplyButton(this.page); - } -} - -interface ReindexDataConnectorProps { - readonly datasourceName: string; - readonly interval: string; -} - -export interface ReindexDataConnector extends ReindexDataConnectorProps {} diff --git a/web-console/e2e-tests/component/load-data/data-loader.ts b/web-console/e2e-tests/component/load-data/data-loader.ts index 42e1d7f717c9..dc8eb28e7a7d 100644 --- a/web-console/e2e-tests/component/load-data/data-loader.ts +++ b/web-console/e2e-tests/component/load-data/data-loader.ts @@ -20,160 +20,112 @@ import type { Page } from '@playwright/test'; import { clickButton, openView, setLabeledInput, setLabeledTextarea } from '../../util/playwright'; -import type { ConfigureSchemaConfig } from './config/configure-schema'; -import type { ConfigureTimestampConfig } from './config/configure-timestamp'; -import type { PartitionConfig } from './config/partition'; -import type { PublishConfig } from './config/publish'; -import type { DataConnector } from './data-connector/data-connector'; +import type { DataConnector } from './data-connector'; +import { connect, connectorCardTitle, connectorNeedsParse } from './data-connector'; +import type { PartitionsSpec } from './partitions-spec'; +import { applyPartitionsSpec } from './partitions-spec'; /** - * Represents load data tab. + * What to set at each step of the (classic) data loader. */ -export class DataLoader { - constructor(props: DataLoaderProps) { - Object.assign(this, props); - } - - /** - * Execute each step to load data. - */ - async load() { - await openView(this.page, 'data-loader'); - await this.start(); - await this.connect(this.connector, this.connectValidator); - if (this.connector.needParse) { - await this.parseData(); - await this.parseTime(this.configureTimestampConfig); - } - await this.transform(); - await this.filter(); - await this.configureSchema(this.configureSchemaConfig); - await this.partition(this.partitionConfig); - await this.tune(); - await this.publish(this.publishConfig); - await this.editSpec(); - } - - private async start() { - await this.page - .locator('.bp6-card') - .filter({ has: this.page.locator('p', { hasText: this.connector.name }) }) - .click(); - await clickButton(this.page, 'Connect data'); - } - - private async clickNext(step: string) { - await clickButton(this.page.locator('.next-bar'), `Next: ${step}`); - } - - private async connect(connector: DataConnector, validator: (previewLines: string[]) => void) { - await connector.connect(); - await this.validateConnect(validator); - await this.clickNext(this.connector.needParse ? 'Parse data' : 'Transform'); - } - - private async validateConnect(validator: (previewLines: string[]) => void) { - const rawLines = this.page.locator('.raw-lines .raw-line'); - await rawLines.first().waitFor(); - validator(await rawLines.allTextContents()); - } - - private async parseData() { - await this.page.locator('.parse-data-table').waitFor(); - await this.clickNext('Parse time'); - } +export interface DataLoaderConfig { + // Connect + readonly connector: DataConnector; + /** Checks the raw lines of the preview */ + readonly validateConnect: (previewLines: string[]) => void; + // Parse time (when the connector's data needs parsing) + readonly timestampExpression?: string; + // Configure schema + readonly rollup: boolean; + // Partition + readonly segmentGranularity: 'hour' | 'day' | 'month' | 'year'; + readonly timeIntervals?: string; + readonly partitionsSpec?: PartitionsSpec; + // Publish + readonly datasourceName: string; +} - private async parseTime(configureTimestampConfig?: ConfigureTimestampConfig) { - await this.page.locator('.parse-time-table').waitFor(); - if (configureTimestampConfig) { - await this.applyConfigureTimestampConfig(configureTimestampConfig); +/** + * Goes through each step of the data loader and submits the task. + */ +export async function loadData(page: Page, config: DataLoaderConfig): Promise { + const nextBar = page.locator('.next-bar'); + const clickNext = (step: string) => clickButton(nextBar, `Next: ${step}`); + + await openView(page, 'data-loader'); + await page + .locator('.bp6-card') + .filter({ has: page.locator('p', { hasText: connectorCardTitle(config.connector) }) }) + .click(); + await clickButton(page, 'Connect data'); + + // Connect + await connect(page, config.connector); + const rawLines = page.locator('.raw-lines .raw-line'); + await rawLines.first().waitFor(); + config.validateConnect(await rawLines.allTextContents()); + + if (connectorNeedsParse(config.connector)) { + await clickNext('Parse data'); + + // Parse data + await page.locator('.parse-data-table').waitFor(); + await clickNext('Parse time'); + + // Parse time + await page.locator('.parse-time-table').waitFor(); + if (config.timestampExpression) { + await clickButton(page, 'Expression'); + await setLabeledInput(page, 'Expression', config.timestampExpression); + await clickButton(page, 'Apply'); } - await this.clickNext('Transform'); - } - - private async transform() { - await this.page.locator('.transform-table').waitFor(); - await this.clickNext('Filter'); } + await clickNext('Transform'); - private async filter() { - await this.page.locator('.filter-table').waitFor(); - await this.clickNext('Configure schema'); - } + // Transform + await page.locator('.transform-table').waitFor(); + await clickNext('Filter'); - private async configureSchema(configureSchemaConfig: ConfigureSchemaConfig) { - await this.page.locator('.schema-table').waitFor(); - await this.applyConfigureSchemaConfig(configureSchemaConfig); - await this.clickNext('Partition'); - } + // Filter + await page.locator('.filter-table').waitFor(); + await clickNext('Configure schema'); - private async applyConfigureTimestampConfig(configureTimestampConfig: ConfigureTimestampConfig) { - await clickButton(this.page, 'Expression'); - await setLabeledInput(this.page, 'Expression', configureTimestampConfig.timestampExpression); - await clickButton(this.page, 'Apply'); - } + // Configure schema + await page.locator('.schema-table').waitFor(); + await setRollup(page, config.rollup); + await clickNext('Partition'); - private async applyConfigureSchemaConfig(configureSchemaConfig: ConfigureSchemaConfig) { - const { rollup } = configureSchemaConfig; - const rollupSwitch = this.page.getByLabel('Rollup', { exact: true }); - if ((await rollupSwitch.isChecked()) !== rollup) { - // The switch's input is visually hidden, so click its label (which asks for confirmation) - await this.page.locator('label', { has: rollupSwitch }).click(); - await clickButton( - this.page.locator('.bp6-alert'), - `Yes - ${rollup ? 'enable' : 'disable'} rollup`, - ); - await this.page.locator('.recipe-toaster').getByRole('button').click(); - } + // Partition + await page.locator('.load-data-view.partition').waitFor(); + await setLabeledInput(page, 'Segment granularity', config.segmentGranularity); + if (config.timeIntervals) { + await setLabeledTextarea(page, 'Time intervals', config.timeIntervals); } - - private async partition(partitionConfig: PartitionConfig) { - await this.page.locator('.load-data-view.partition').waitFor(); - await this.applyPartitionConfig(partitionConfig); - await this.clickNext('Tune'); + if (config.partitionsSpec) { + await applyPartitionsSpec(page, config.partitionsSpec); } + await clickNext('Tune'); - private async applyPartitionConfig(partitionConfig: PartitionConfig) { - await setLabeledInput(this.page, 'Segment granularity', partitionConfig.segmentGranularity); - if (partitionConfig.timeIntervals) { - await setLabeledTextarea(this.page, 'Time intervals', partitionConfig.timeIntervals); - } - if (partitionConfig.partitionsSpec != null) { - await partitionConfig.partitionsSpec.apply(this.page); - } - } + // Tune + await page.locator('.load-data-view.tuning').waitFor(); + await clickNext('Publish'); - private async tune() { - await this.page.locator('.load-data-view.tuning').waitFor(); - await this.clickNext('Publish'); - } + // Publish + await page.locator('.load-data-view.publish').waitFor(); + await setLabeledInput(page, 'Datasource name', config.datasourceName); + await clickNext('Edit spec'); - private async publish(publishConfig: PublishConfig) { - await this.page.locator('.load-data-view.publish').waitFor(); - await this.applyPublishConfig(publishConfig); - await this.clickNext('Edit spec'); - } - - private async applyPublishConfig(publishConfig: PublishConfig) { - if (publishConfig.datasourceName != null) { - await setLabeledInput(this.page, 'Datasource name', publishConfig.datasourceName); - } - } - - private async editSpec() { - await this.page.locator('.load-data-view.spec').waitFor(); - await clickButton(this.page.locator('.next-bar'), 'Submit task'); - } + // Edit spec + await page.locator('.load-data-view.spec').waitFor(); + await clickButton(nextBar, 'Submit task'); } -interface DataLoaderProps { - readonly page: Page; - readonly connector: DataConnector; - readonly connectValidator: (previewLines: string[]) => void; - readonly configureTimestampConfig?: ConfigureTimestampConfig; - readonly configureSchemaConfig: ConfigureSchemaConfig; - readonly partitionConfig: PartitionConfig; - readonly publishConfig: PublishConfig; -} +async function setRollup(page: Page, rollup: boolean): Promise { + const rollupSwitch = page.getByLabel('Rollup', { exact: true }); + if ((await rollupSwitch.isChecked()) === rollup) return; -export interface DataLoader extends DataLoaderProps {} + // The switch's input is visually hidden, so click its label (which asks for confirmation) + await page.locator('label', { has: rollupSwitch }).click(); + await clickButton(page.locator('.bp6-alert'), `Yes - ${rollup ? 'enable' : 'disable'} rollup`); + await page.locator('.recipe-toaster').getByRole('button').click(); +} diff --git a/web-console/e2e-tests/component/load-data/partitions-spec.ts b/web-console/e2e-tests/component/load-data/partitions-spec.ts new file mode 100644 index 000000000000..dd91fae2c7e7 --- /dev/null +++ b/web-console/e2e-tests/component/load-data/partitions-spec.ts @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from '@playwright/test'; + +import { + formGroup, + getLabeledInput, + getLabeledTextarea, + selectSuggestibleInput, + setLabeledInput, + setLabeledTextarea, +} from '../../util/playwright'; + +/** + * A partitions spec, as set in the form of the data loader's Partition step or of the compaction config dialog. + */ +export type PartitionsSpec = + | { readonly type: 'hashed'; readonly numShards: number | null } + | { + readonly type: 'range'; + readonly partitionDimensions: string[]; + readonly targetRowsPerSegment: number | null; + readonly maxRowsPerSegment: number | null; + }; + +const PARTITIONING_TYPE = 'Partitioning type'; +const NUM_SHARDS = 'Num shards'; +const PARTITION_DIMENSIONS = 'Partition dimensions'; +const TARGET_ROWS_PER_SEGMENT = 'Target rows per segment'; +const MAX_ROWS_PER_SEGMENT = 'Max rows per segment'; + +export async function applyPartitionsSpec( + page: Page, + partitionsSpec: PartitionsSpec, +): Promise { + switch (partitionsSpec.type) { + case 'hashed': + await setLabeledInput(page, PARTITIONING_TYPE, partitionsSpec.type); + if (partitionsSpec.numShards != null) { + await setLabeledInput(page, NUM_SHARDS, String(partitionsSpec.numShards)); + } + break; + + case 'range': + await selectSuggestibleInput(page, PARTITIONING_TYPE, partitionsSpec.type); + await setLabeledTextarea( + page, + PARTITION_DIMENSIONS, + partitionsSpec.partitionDimensions.join(', '), + ); + if (partitionsSpec.targetRowsPerSegment != null) { + await setLabeledInput( + page, + TARGET_ROWS_PER_SEGMENT, + String(partitionsSpec.targetRowsPerSegment), + ); + } + if (partitionsSpec.maxRowsPerSegment != null) { + await setLabeledInput(page, MAX_ROWS_PER_SEGMENT, String(partitionsSpec.maxRowsPerSegment)); + } + break; + } +} + +export async function readPartitionsSpec(page: Page): Promise { + switch (await getLabeledInput(page, PARTITIONING_TYPE)) { + case 'hashed': + return { + type: 'hashed', + // The shards field is not always shown, then it is not set + numShards: (await formGroup(page, NUM_SHARDS).count()) + ? await getLabeledNumber(page, NUM_SHARDS) + : null, + }; + + case 'range': { + const partitionDimensions = await getLabeledTextarea(page, PARTITION_DIMENSIONS); + return { + type: 'range', + partitionDimensions: partitionDimensions + ? partitionDimensions.split(',').map(d => d.trim()) + : [], + targetRowsPerSegment: await getLabeledNumber(page, TARGET_ROWS_PER_SEGMENT), + maxRowsPerSegment: await getLabeledNumber(page, MAX_ROWS_PER_SEGMENT), + }; + } + + default: + return; + } +} + +async function getLabeledNumber(page: Page, label: string): Promise { + const value = await getLabeledInput(page, label); + return value === '' ? null : Number(value); +} diff --git a/web-console/e2e-tests/component/query/overview.ts b/web-console/e2e-tests/component/query/overview.ts deleted file mode 100644 index 31aa9e7be274..000000000000 --- a/web-console/e2e-tests/component/query/overview.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Page } from '@playwright/test'; - -import { clickButton, openView, setQueryInput } from '../../util/playwright'; -import { extractTable } from '../../util/table'; - -/** - * Represents query overview tab. - */ -export class QueryOverview { - private readonly page: Page; - - constructor(page: Page) { - this.page = page; - } - - async runQuery(query: string): Promise { - await openView(this.page, 'workbench'); - - await setQueryInput(this.page, query); - await clickButton(this.page, 'Run'); - - const results = this.page.locator('.result-table-pane'); - const error = this.page.locator('.execution-error-pane'); - await results.or(error).waitFor(); - if (await error.isVisible()) { - throw new Error(`Query failed: ${await error.innerText()}`); - } - return await extractTable(results); - } - - async cancelQuery(query: string): Promise { - await openView(this.page, 'workbench'); - - await setQueryInput(this.page, query); - - const queryRequest = this.page.waitForRequest( - request => request.url().includes('druid/v2') && request.method() === 'POST', - ); - await clickButton(this.page, 'Run'); - await queryRequest; - - const cancelResponse = this.page.waitForResponse( - response => response.url().includes('druid/v2') && response.request().method() === 'DELETE', - ); - await this.page.locator('.cancel-label', { hasText: 'Cancel query' }).click(); - - return (await cancelResponse).status(); - } -} diff --git a/web-console/e2e-tests/component/workbench/overview.ts b/web-console/e2e-tests/component/workbench/overview.ts index ce97920b1621..a010234430ab 100644 --- a/web-console/e2e-tests/component/workbench/overview.ts +++ b/web-console/e2e-tests/component/workbench/overview.ts @@ -22,7 +22,7 @@ import { clickButton, openView, setQueryInput } from '../../util/playwright'; import { extractTable } from '../../util/table'; /** - * Represents the workbench tab. + * Represents the Query view (the workbench). */ export class WorkbenchOverview { private readonly page: Page; @@ -31,6 +31,9 @@ export class WorkbenchOverview { this.page = page; } + /** + * Runs a query and returns its results (as text, one array of cells per row). Throws if the query fails. + */ async runQuery(query: string): Promise { await openView(this.page, 'workbench'); @@ -38,16 +41,43 @@ export class WorkbenchOverview { await clickButton(this.page, 'Run'); const results = this.page.locator('.result-table-pane'); + const error = this.page.locator('.execution-error-pane'); + // Shown before running a query with the MSQ task engine when the cluster lacks task slots const capacityAlert = this.page.locator('.alert-dialog').filter({ hasText: 'The cluster does not currently have enough available task slots', }); const timeout = 4 * 60 * 1000; - await results.or(capacityAlert).waitFor({ timeout }); + await results.or(error).or(capacityAlert).waitFor({ timeout }); if (await capacityAlert.isVisible()) { await clickButton(capacityAlert, 'Run it anyway'); - await results.waitFor({ timeout }); + await results.or(error).waitFor({ timeout }); } + if (await error.isVisible()) { + throw new Error(`Query failed: ${await error.innerText()}`); + } return await extractTable(results); } + + /** + * Runs a query, cancels it once it is running and returns the status of the cancel request. + */ + async cancelQuery(query: string): Promise { + await openView(this.page, 'workbench'); + + await setQueryInput(this.page, query); + + const queryRequest = this.page.waitForRequest( + request => request.url().includes('druid/v2') && request.method() === 'POST', + ); + await clickButton(this.page, 'Run'); + await queryRequest; + + const cancelResponse = this.page.waitForResponse( + response => response.url().includes('druid/v2') && response.request().method() === 'DELETE', + ); + await this.page.locator('.cancel-label', { hasText: 'Cancel query' }).click(); + + return (await cancelResponse).status(); + } } diff --git a/web-console/e2e-tests/reindexing.spec.ts b/web-console/e2e-tests/reindexing.spec.ts index 1b80266ac3af..842366b2a1a0 100644 --- a/web-console/e2e-tests/reindexing.spec.ts +++ b/web-console/e2e-tests/reindexing.spec.ts @@ -18,15 +18,7 @@ import type { APIRequestContext } from '@playwright/test'; -import { ConfigureSchemaConfig } from './component/load-data/config/configure-schema'; -import { - PartitionConfig, - RangePartitionsSpec, - SegmentGranularity, -} from './component/load-data/config/partition'; -import { PublishConfig } from './component/load-data/config/publish'; -import { ReindexDataConnector } from './component/load-data/data-connector/reindex'; -import { DataLoader } from './component/load-data/data-loader'; +import { loadData } from './component/load-data/data-loader'; import { readTutorialIngestionSpec, runTask } from './util/druid'; import { expect, test } from './util/fixtures'; import { CLUSTER_STATE_POLL, getDatasourceSegments, getTaskStatuses } from './util/sql'; @@ -38,39 +30,25 @@ test.describe('Reindexing from Druid', () => { newDatasourceName, }) => { const datasourceName = newDatasourceName('reindex-dynamic-to-range'); - const interval = '2015-09-12/2015-09-13'; - const dataConnector = new ReindexDataConnector(page, { - datasourceName, - interval, - }); - const configureSchemaConfig = new ConfigureSchemaConfig({ rollup: false }); - const partitionConfig = new PartitionConfig({ - segmentGranularity: SegmentGranularity.DAY, - timeIntervals: null, - partitionsSpec: new RangePartitionsSpec({ - partitionDimensions: ['channel'], - targetRowsPerSegment: 10_000, - maxRowsPerSegment: null, - }), - }); - const publishConfig = new PublishConfig({ datasourceName: datasourceName }); - - const dataLoader = new DataLoader({ - page: page, - connector: dataConnector, - connectValidator: validateConnectLocalData, - configureSchemaConfig: configureSchemaConfig, - partitionConfig: partitionConfig, - publishConfig: publishConfig, - }); - await loadInitialData(request, datasourceName); await expect .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) .toEqual({ numSegments: 1, numAvailableSegments: 1, numRows: 39244 }); - await dataLoader.load(); + await loadData(page, { + connector: { type: 'reindex', datasourceName, interval: '2015-09-12/2015-09-13' }, + validateConnect: validateConnectLocalData, + rollup: false, + segmentGranularity: 'day', + partitionsSpec: { + type: 'range', + partitionDimensions: ['channel'], + targetRowsPerSegment: 10_000, + maxRowsPerSegment: null, + }, + datasourceName, + }); // The initial load and the reindexing await expect .poll(() => getTaskStatuses(request, datasourceName), CLUSTER_STATE_POLL) diff --git a/web-console/e2e-tests/tutorial-batch.spec.ts b/web-console/e2e-tests/tutorial-batch.spec.ts index 5b9cfe3995f9..a1f18e9bf605 100644 --- a/web-console/e2e-tests/tutorial-batch.spec.ts +++ b/web-console/e2e-tests/tutorial-batch.spec.ts @@ -21,13 +21,8 @@ import { T } from 'druid-query-toolkit'; import { DatasourcesOverview } from './component/datasources/overview'; import { TasksOverview } from './component/ingestion/overview'; -import { ConfigureSchemaConfig } from './component/load-data/config/configure-schema'; -import { ConfigureTimestampConfig } from './component/load-data/config/configure-timestamp'; -import { PartitionConfig, SegmentGranularity } from './component/load-data/config/partition'; -import { PublishConfig } from './component/load-data/config/publish'; -import { LocalFileDataConnector } from './component/load-data/data-connector/local-file'; -import { DataLoader } from './component/load-data/data-loader'; -import { QueryOverview } from './component/query/overview'; +import { loadData } from './component/load-data/data-loader'; +import { WorkbenchOverview } from './component/workbench/overview'; import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR } from './util/druid'; import { expect, test } from './util/fixtures'; import { CLUSTER_STATE_POLL, getDatasourceSegments, getTaskStatuses } from './util/sql'; @@ -37,27 +32,19 @@ const ALL_SORTS_OF_CHARS = '<>|!@#$%^&`\'".,:;\\*()[]{}Україна 한국 中 test.describe('Tutorial: Loading a file', () => { test('Loads data from local disk', async ({ page, request, newDatasourceName }) => { const datasourceName = newDatasourceName('load-data-from-local-disk' + ALL_SORTS_OF_CHARS); - const dataLoader = new DataLoader({ - page: page, - connector: new LocalFileDataConnector(page, { + await loadData(page, { + connector: { + type: 'local', baseDirectory: DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, fileFilter: 'wikiticker-2015-09-12-sampled.json.gz', - }), - connectValidator: validateConnectLocalData, - configureTimestampConfig: new ConfigureTimestampConfig({ - timestampExpression: 'timestamp_parse("time") + 1', - }), - configureSchemaConfig: new ConfigureSchemaConfig({ rollup: false }), - partitionConfig: new PartitionConfig({ - segmentGranularity: SegmentGranularity.DAY, - timeIntervals: null, - partitionsSpec: null, - }), - publishConfig: new PublishConfig({ datasourceName: datasourceName }), + }, + validateConnect: validateConnectLocalData, + timestampExpression: 'timestamp_parse("time") + 1', + rollup: false, + segmentGranularity: 'day', + datasourceName, }); - await dataLoader.load(); - await expect .poll(() => getTaskStatuses(request, datasourceName), CLUSTER_STATE_POLL) .toEqual(['SUCCESS']); @@ -152,12 +139,12 @@ async function validateDatasourcesView(page: Page, datasourceName: string) { } async function validateQuery(page: Page, datasourceName: string) { - const queryOverview = new QueryOverview(page); + const workbench = new WorkbenchOverview(page); const query = `SELECT * FROM ${T(datasourceName)} ORDER BY __time`; let results!: string[][]; // The datasource can be available before the Broker's SQL schema knows about it await expect(async () => { - results = await queryOverview.runQuery(query); + results = await workbench.runQuery(query); expect(results.length).toBeGreaterThan(0); }).toPass(); expect(results[0]).toStrictEqual([ diff --git a/web-console/eslint.config.mjs b/web-console/eslint.config.mjs index b5739a2d82ae..88e60ba78d2b 100644 --- a/web-console/eslint.config.mjs +++ b/web-console/eslint.config.mjs @@ -84,10 +84,4 @@ export default [ '@typescript-eslint/no-require-imports': [0], }, }, - { - files: ['e2e-tests/**/*.ts'], - rules: { - '@typescript-eslint/no-unsafe-declaration-merging': [0], - }, - }, ]; From 2fa748d1db84a4d064fdf29d3eb7db06346a1be6 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 15:30:50 -0700 Subject: [PATCH 11/26] run the web console e2e specs on embedded clusters Adds WebConsoleTestBase and CoreWebConsoleTest to embedded-tests (tag web-console, profile web-console-tests), which start an embedded cluster with a Router and run the Playwright specs on it, with a keep-alive mode for working on a spec. Fixes two races in the specs that this surfaced: reading a view's table before it loaded, and canceling a query before it was sent. Co-Authored-By: Claude Opus 5.5 --- embedded-tests/pom.xml | 58 ++++- .../embedded/console/CoreWebConsoleTest.java | 44 ++++ .../embedded/console/WebConsoleTestBase.java | 204 ++++++++++++++++++ web-console/E2E_BETTERMENT_PLAN.md | 21 ++ web-console/e2e-tests/README.md | 27 +++ .../e2e-tests/component/workbench/overview.ts | 6 +- web-console/e2e-tests/tutorial-batch.spec.ts | 4 +- web-console/e2e-tests/util/table.ts | 11 +- 8 files changed, 368 insertions(+), 7 deletions(-) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/CoreWebConsoleTest.java create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java diff --git a/embedded-tests/pom.xml b/embedded-tests/pom.xml index 3928027576ae..974c64248a4a 100644 --- a/embedded-tests/pom.xml +++ b/embedded-tests/pom.xml @@ -890,12 +890,12 @@ true - + org.apache.maven.plugins maven-surefire-plugin - docker-test,perf + docker-test,web-console,perf + web-console-tests + + + true + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + org.apache.maven.plugins + maven-failsafe-plugin + + web-console + + *WebConsoleTest* + + + ${project.basedir}/../web-console + + + -XX:+UseCompactObjectHeaders + ${jdk.strong.encapsulation.argLine} + -Xmx3g + -XX:MaxDirectMemorySize=2500m + -XX:+ExitOnOutOfMemoryError + -XX:+HeapDumpOnOutOfMemoryError + -Duser.language=en + -Dfile.encoding=UTF-8 + -Duser.timezone=UTC + -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager + -Daws.region=us-east-1 + + + + + + integration-test + verify + + + + + + + diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/CoreWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/CoreWebConsoleTest.java new file mode 100644 index 000000000000..33aa00281de4 --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/CoreWebConsoleTest.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.console; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Map; + +/** + * Runs the web console specs that need only a plain cluster (no external resources). + */ +public class CoreWebConsoleTest extends WebConsoleTestBase +{ + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "auto-compaction.spec.ts", + "cancel-query.spec.ts", + "multi-stage-query.spec.ts", + "reindexing.spec.ts", + "tutorial-batch.spec.ts" + }) + public void testSpec(String spec) throws Exception + { + runSpec(spec, Map.of()); + } +} diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java new file mode 100644 index 000000000000..bca1c8ee3a90 --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java @@ -0,0 +1,204 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.console; + +import org.apache.druid.guice.SleepModule; +import org.apache.druid.java.util.common.StringUtils; +import org.apache.druid.java.util.common.logger.Logger; +import org.apache.druid.query.aggregation.datasketches.hll.HllSketchModule; +import org.apache.druid.query.aggregation.datasketches.quantiles.DoublesSketchModule; +import org.apache.druid.query.aggregation.datasketches.theta.SketchModule; +import org.apache.druid.testing.embedded.EmbeddedBroker; +import org.apache.druid.testing.embedded.EmbeddedCoordinator; +import org.apache.druid.testing.embedded.EmbeddedDruidCluster; +import org.apache.druid.testing.embedded.EmbeddedHistorical; +import org.apache.druid.testing.embedded.EmbeddedIndexer; +import org.apache.druid.testing.embedded.EmbeddedOverlord; +import org.apache.druid.testing.embedded.EmbeddedRouter; +import org.apache.druid.testing.embedded.junit5.EmbeddedClusterTestBase; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Base class for the web console end-to-end tests: a cluster with a Router (which serves the web console), on which + * the tests run the Playwright specs of {@code web-console/e2e-tests}. + *

+ * Steps to write a test: + *

    + *
  • Write the Playwright spec in {@code web-console/e2e-tests}.
  • + *
  • Write a {@code *WebConsoleTest} class that extends this class. Add the resources the spec needs (S3, Kafka, + * etc.) in {@link #addResources}, and the extensions and properties in {@link #configureCluster}.
  • + *
  • Write a {@code @Test} method that calls {@link #runSpec}, passing the settings of the resources to the spec + * through environment variables.
  • + *
+ * The tests run only with the {@code web-console-tests} profile: + *
+ * mvn -pl embedded-tests verify -Pweb-console-tests -Dit.test=CoreWebConsoleTest
+ * 
+ * With {@code -Dweb.console.keepAlive=true}, {@link #runSpec} doesn't run the spec. It prints the command to run it + * and waits, keeping the cluster up, so that the spec can be run (and debugged) from {@code web-console} against it. + * With {@code -Dweb.console.port}, the specs run against the console on that port (such as the dev server of + * {@code npm start}, which proxies to the Router on port 8888) rather than the console that the Router serves. + */ +@Tag("web-console") +public abstract class WebConsoleTestBase extends EmbeddedClusterTestBase +{ + private static final Logger log = new Logger(WebConsoleTestBase.class); + + private static final long SPEC_TIMEOUT_MINUTES = 15; + private static final Pattern NOT_FILE_NAME_CHARS = Pattern.compile("[^A-Za-z0-9.-]"); + + protected final EmbeddedCoordinator coordinator = new EmbeddedCoordinator(); + protected final EmbeddedOverlord overlord = new EmbeddedOverlord(); + protected final EmbeddedIndexer indexer = new EmbeddedIndexer(); + protected final EmbeddedHistorical historical = new EmbeddedHistorical(); + protected final EmbeddedBroker broker = new EmbeddedBroker(); + protected final EmbeddedRouter router = new EmbeddedRouter(); + + @Override + protected final EmbeddedDruidCluster createCluster() + { + final EmbeddedDruidCluster cluster = EmbeddedDruidCluster.withEmbeddedDerbyAndZookeeper(); + // Resources start in the order they are added, and must start before the Druid servers that use them + addResources(cluster); + + // SleepModule has the sleep() function, for queries that run long enough to be canceled + cluster.addExtensions(SketchModule.class, HllSketchModule.class, DoublesSketchModule.class, SleepModule.class) + .addCommonProperty("druid.msq.dart.enabled", "true") + .addCommonProperty("druid.sql.planner.enableSysQueriesTable", "true") + // The console waits for the cluster state to change, so make it change quickly + .addCommonProperty("druid.manager.segments.pollDuration", "PT1S") + .addServer(coordinator.addProperty("druid.coordinator.period", "PT1S")) + .addServer(overlord) + // Enough task slots for an MSQ query (a controller and a worker) next to an ingestion task + .addServer(indexer.addProperty("druid.worker.capacity", "4").setServerMemory(1_000_000_000L)) + .addServer(historical) + .addServer(broker) + .addServer(router); + + configureCluster(cluster); + return cluster; + } + + /** + * Adds the resources (such as an S3 container) that the specs of this test need. + */ + protected void addResources(EmbeddedDruidCluster cluster) + { + } + + /** + * Adds the extensions and properties that the specs of this test need. + */ + protected void configureCluster(EmbeddedDruidCluster cluster) + { + } + + /** + * Runs the Playwright spec (a file name or a pattern of {@code web-console/e2e-tests}) against this cluster and + * fails if it fails. + * + * @param env environment variables to pass to the spec, such as the settings of the resources + */ + protected void runSpec(String spec, Map env) throws Exception + { + final File webConsoleDir = new File(System.getProperty("web.console.dir", "../web-console")).getCanonicalFile(); + final Map specEnv = new HashMap<>(env); + specEnv.put( + "DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT", + System.getProperty("web.console.port", String.valueOf(router.bindings().selfNode().getPlaintextPort())) + ); + // Each spec has its own output directory, as Playwright empties it on every run + final String outputDir = "test-results/" + NOT_FILE_NAME_CHARS.matcher(spec).replaceAll("_"); + final List command = List.of("npx", "playwright", "test", spec, "--output", outputDir); + + if (Boolean.getBoolean("web.console.keepAlive")) { + keepAlive(webConsoleDir, specEnv, command); + return; + } + + log.info("Running spec[%s] in [%s] with env[%s].", spec, webConsoleDir, specEnv); + final ProcessBuilder processBuilder = new ProcessBuilder(command).directory(webConsoleDir) + .redirectErrorStream(true); + processBuilder.environment().putAll(specEnv); + final Process process = processBuilder.start(); + + // Log the output as it comes (so that a hung spec can be seen) and keep it for the failure message + final List output = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8) + )) { + String line; + while ((line = reader.readLine()) != null) { + log.info("[playwright] %s", line); + output.add(line); + } + } + + if (!process.waitFor(SPEC_TIMEOUT_MINUTES, TimeUnit.MINUTES)) { + process.destroyForcibly(); + Assertions.fail(StringUtils.format("Spec[%s] did not finish in %d minutes", spec, SPEC_TIMEOUT_MINUTES)); + } + Assertions.assertEquals( + 0, + process.exitValue(), + StringUtils.format( + "Spec[%s] failed (its screenshots and traces are in [%s]):\n%s", + spec, + new File(webConsoleDir, outputDir), + String.join("\n", output) + ) + ); + } + + private void keepAlive(File webConsoleDir, Map specEnv, List command) + throws InterruptedException + { + final String envString = specEnv.entrySet() + .stream() + .map(entry -> entry.getKey() + "=" + entry.getValue()) + .collect(Collectors.joining(" ")); + // Printed to stderr as well as logged, to stand out from the logs of the cluster + System.err.println( + StringUtils.format( + "%n%n==== The cluster is up, with the console at [%s]. Run the spec with:%n" + + "cd %s && %s %s%n" + + "==== Stop this process to stop the cluster.%n%n", + getServerUrl(router), + webConsoleDir, + envString, + String.join(" ", command) + ) + ); + Thread.sleep(Long.MAX_VALUE); + } +} diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index 919a61d8b7fd..650c67355512 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -91,3 +91,24 @@ modernize them, in order. Each step should leave the suite passing. - SQL-based ingestion (the SQL data loader, `INSERT` / `REPLACE` in the Query view), Explore, Supervisors, Segments, Services, Lookups. + +## 8. Run on embedded clusters (prototype done) + +Prototype (option B: JUnit drives, the specs stay in TypeScript): `WebConsoleTestBase` and `CoreWebConsoleTest` in +`embedded-tests` (`org.apache.druid.testing.embedded.console`), tag `web-console`, profile `web-console-tests`. The 5 +specs pass unchanged on it (50s for the whole Maven run), with a keep-alive mode for working on a spec. + +Found on the way (fixed in the specs, they were latent races that the quickstart's timing hid): +- `extractTableRecords` read a view's table right after opening it, before the view loaded its data. +- `cancelQuery` waited for any SQL `POST` (it could be the column tree's) and canceled before the query was sent. + +Left: +- CI: a job that installs Druid with the console built (not `-Dweb.console.skip=true`), runs `npm ci` and + `mvn -pl embedded-tests verify -Pweb-console-tests`, and uploads `web-console/test-results/` and + `embedded-tests/target/failsafe-reports/` on failure; then drop `script/druid` from `web-checks.sh`. +- A first feature test with a resource: `S3WebConsoleTest` with `S3StorageResource`, loading from `s3://` through + the data loader. +- Make the `newDatasourceName` cleanup optional (a new cluster per class doesn't need it), or drop it with the + quickstart way. +- A check that every spec is run by some `*WebConsoleTest` (they are listed by hand). +- The JUnit report names the cases `testSpec(String)[1]`..., not by spec. diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index 5da0a28169f1..6decb13f50b2 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -52,6 +52,33 @@ Before the tests start, a global setup (`util/global-setup.ts`) waits for the co (git ignored). Every HTTP response with a status of 400 or more is also logged with its body (`util/fixtures.ts`). +### On an embedded cluster (prototype) + +`CoreWebConsoleTest` in `embedded-tests` (package `org.apache.druid.testing.embedded.console`) starts an embedded +cluster (in one JVM, with an in-memory metadata store, its Router on :8888) and runs each spec on it with +`npx playwright test `. There's no distribution to build and nothing left behind, as every test class gets a new +cluster. It needs this checkout's Druid modules installed (`mvn install -DskipTests`), with the `web-console` one built +with the console (not with `-Dweb.console.skip=true`, which leaves the Router with no console to serve): + +```bash +# from the root of the checkout +mvn -pl web-console install -DskipTests # after changing the console (not the tests), for the Router to serve it +mvn -pl embedded-tests verify -Pweb-console-tests -Dit.test=CoreWebConsoleTest +``` + +The Druid logs and the Playwright output are in `embedded-tests/target/failsafe-reports/*-output.txt`, and the +artifacts of a failed spec in `test-results//`. The tests are tagged `web-console`, so they run only with the +`web-console-tests` profile. + +- **Keep the cluster up to work on a spec**: add `-Dweb.console.keepAlive=true -Dmaven.test.redirectTestOutputToFile=false`. + Rather than running the specs, it prints the command to run one against the cluster (with `--ui` or `--debug` added + as you like) and waits until it's stopped. +- **Test a dev server instead of the bundled console**: add `-Dweb.console.port=18081`, with `npm start` running (it + proxies to :8888). +- **A spec that needs more than Druid** (S3, Kafka...) gets its own `*WebConsoleTest` class that extends + `WebConsoleTestBase`, adds the resource in `addResources` and passes its settings (endpoint, bucket...) to the spec as + environment variables of `runSpec`. + A test deletes the datasources it created when it passes: it stops their tasks, removes their compaction config and permanently deletes their segments (with a `kill` task). When it fails, they are kept to look into (the log says which) and have to be deleted by hand, from the Datasources view. diff --git a/web-console/e2e-tests/component/workbench/overview.ts b/web-console/e2e-tests/component/workbench/overview.ts index a010234430ab..afd6f8949147 100644 --- a/web-console/e2e-tests/component/workbench/overview.ts +++ b/web-console/e2e-tests/component/workbench/overview.ts @@ -67,8 +67,12 @@ export class WorkbenchOverview { await setQueryInput(this.page, query); + // Matched on the query, as the view sends other SQL (like the one of its column tree) as it loads const queryRequest = this.page.waitForRequest( - request => request.url().includes('druid/v2') && request.method() === 'POST', + request => + request.url().includes('druid/v2') && + request.method() === 'POST' && + (request.postData() ?? '').includes(JSON.stringify(query).slice(1, -1)), ); await clickButton(this.page, 'Run'); await queryRequest; diff --git a/web-console/e2e-tests/tutorial-batch.spec.ts b/web-console/e2e-tests/tutorial-batch.spec.ts index a1f18e9bf605..24855f7ed9d1 100644 --- a/web-console/e2e-tests/tutorial-batch.spec.ts +++ b/web-console/e2e-tests/tutorial-batch.spec.ts @@ -116,7 +116,7 @@ function validateConnectLocalData(lines: string[]) { async function validateTasksView(page: Page, datasourceName: string) { const tasksOverview = new TasksOverview(page); - // Retried as the view loads its data after it opens + // Retried in case the view is a step behind the cluster state polled above await expect(async () => { const tasks = await tasksOverview.getTasks(datasourceName); const task = tasks.find(t => t.datasource === datasourceName); @@ -128,7 +128,7 @@ async function validateTasksView(page: Page, datasourceName: string) { async function validateDatasourcesView(page: Page, datasourceName: string) { const datasourcesOverview = new DatasourcesOverview(page); - // Retried as the view loads its data after it opens + // Retried in case the view is a step behind the cluster state polled above await expect(async () => { const datasources = await datasourcesOverview.getDatasources(datasourceName); const datasource = datasources.find(t => t.name === datasourceName); diff --git a/web-console/e2e-tests/util/table.ts b/web-console/e2e-tests/util/table.ts index 5697ba8d89fe..e4a59bd5119f 100644 --- a/web-console/e2e-tests/util/table.ts +++ b/web-console/e2e-tests/util/table.ts @@ -27,11 +27,18 @@ export async function extractTable(table: Locator): Promise { } /** - * Reads the data rows of a ConsoleTable as text, each row keyed by its column headers. Headers on two lines are read - * with a space for the line break (like "Datasource name"). + * Reads the data rows of a view's ConsoleTable as text, each row keyed by its column headers, once the view has + * loaded its data. Headers on two lines are read with a space for the line break (like "Datasource name"). * @param table locator of the table (its `.console-table` element, or one containing it) */ export async function extractTableRecords(table: Locator): Promise[]> { + // A view loads its data after it opens, and shows its no data text (like "No tasks", or the error) only after that + await table + .locator('.ct-tbody .ct-tr:not(.-padRow)') + .or(table.locator('.ct-no-data').filter({ hasText: /\S/ })) + .first() + .waitFor(); + const { headers, rows } = await readTable(table); return rows.map(row => Object.fromEntries(headers.map((header, i) => [header, row[i]]))); } From 3b016d12b5b0000aea94752f3fae5562c7f1cdc1 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 15:36:00 -0700 Subject: [PATCH 12/26] add a web console e2e test that loads data from S3 S3WebConsoleTest runs the new s3-ingestion spec on an embedded cluster with an S3 container (also its deep storage and task log storage), loading the tutorial file through the data loader's Amazon S3 connector. Co-Authored-By: Claude Opus 5.5 --- .../embedded/console/S3WebConsoleTest.java | 75 +++++++++++++++++++ .../embedded/console/WebConsoleTestBase.java | 11 ++- web-console/E2E_BETTERMENT_PLAN.md | 4 +- web-console/e2e-tests/README.md | 4 +- .../component/load-data/data-connector.ts | 9 ++- web-console/e2e-tests/s3-ingestion.spec.ts | 50 +++++++++++++ 6 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/S3WebConsoleTest.java create mode 100644 web-console/e2e-tests/s3-ingestion.spec.ts diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/S3WebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/S3WebConsoleTest.java new file mode 100644 index 000000000000..1e35fccabdee --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/S3WebConsoleTest.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.console; + +import org.apache.druid.data.input.s3.S3InputSourceDruidModule; +import org.apache.druid.java.util.common.StringUtils; +import org.apache.druid.testing.embedded.EmbeddedDruidCluster; +import org.apache.druid.testing.embedded.s3.S3StorageResource; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.File; +import java.util.Map; + +/** + * Runs the web console specs that load data from S3, on a cluster with an S3-compatible container (which is also its + * deep storage and where it keeps the task logs). + */ +public class S3WebConsoleTest extends WebConsoleTestBase +{ + private static final String DATA_FILE = "wikiticker-2015-09-12-sampled.json.gz"; + private static final String DATA_KEY = "web-console-data/" + DATA_FILE; + + private final S3StorageResource s3 = new S3StorageResource(); + + @Override + protected void addResources(EmbeddedDruidCluster cluster) + { + cluster.addResource(s3); + } + + @Override + protected void configureCluster(EmbeddedDruidCluster cluster) + { + cluster.addExtension(S3InputSourceDruidModule.class); + } + + @BeforeAll + public void uploadData() throws Exception + { + final File dataFile = new File(webConsoleDir(), "../examples/quickstart/tutorial/" + DATA_FILE); + s3.getS3Client().putObject( + PutObjectRequest.builder().bucket(s3.getBucket()).key(DATA_KEY).build(), + RequestBody.fromFile(dataFile) + ); + } + + @Test + public void testS3Ingestion() throws Exception + { + runSpec( + "s3-ingestion.spec.ts", + Map.of("DRUID_E2E_TEST_S3_URI", StringUtils.format("s3://%s/%s", s3.getBucket(), DATA_KEY)) + ); + } +} diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java index bca1c8ee3a90..c430a5f7a288 100644 --- a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java @@ -38,6 +38,7 @@ import java.io.BufferedReader; import java.io.File; +import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -131,7 +132,7 @@ protected void configureCluster(EmbeddedDruidCluster cluster) */ protected void runSpec(String spec, Map env) throws Exception { - final File webConsoleDir = new File(System.getProperty("web.console.dir", "../web-console")).getCanonicalFile(); + final File webConsoleDir = webConsoleDir(); final Map specEnv = new HashMap<>(env); specEnv.put( "DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT", @@ -180,6 +181,14 @@ protected void runSpec(String spec, Map env) throws Exception ); } + /** + * The {@code web-console} directory of this checkout. + */ + protected static File webConsoleDir() throws IOException + { + return new File(System.getProperty("web.console.dir", "../web-console")).getCanonicalFile(); + } + private void keepAlive(File webConsoleDir, Map specEnv, List command) throws InterruptedException { diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index 650c67355512..f5503b695731 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -97,6 +97,8 @@ modernize them, in order. Each step should leave the suite passing. Prototype (option B: JUnit drives, the specs stay in TypeScript): `WebConsoleTestBase` and `CoreWebConsoleTest` in `embedded-tests` (`org.apache.druid.testing.embedded.console`), tag `web-console`, profile `web-console-tests`. The 5 specs pass unchanged on it (50s for the whole Maven run), with a keep-alive mode for working on a spec. +`S3WebConsoleTest` (with `S3StorageResource`) runs `s3-ingestion.spec.ts`, which loads from `s3://` through the data +loader (24s for the test class). Found on the way (fixed in the specs, they were latent races that the quickstart's timing hid): - `extractTableRecords` read a view's table right after opening it, before the view loaded its data. @@ -106,8 +108,6 @@ Left: - CI: a job that installs Druid with the console built (not `-Dweb.console.skip=true`), runs `npm ci` and `mvn -pl embedded-tests verify -Pweb-console-tests`, and uploads `web-console/test-results/` and `embedded-tests/target/failsafe-reports/` on failure; then drop `script/druid` from `web-checks.sh`. -- A first feature test with a resource: `S3WebConsoleTest` with `S3StorageResource`, loading from `s3://` through - the data loader. - Make the `newDatasourceName` cleanup optional (a new cluster per class doesn't need it), or drop it with the quickstart way. - A check that every spec is run by some `*WebConsoleTest` (they are listed by hand). diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index 6decb13f50b2..803fd7a14e9a 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -77,7 +77,8 @@ artifacts of a failed spec in `test-results//`. The tests are tagged `web- proxies to :8888). - **A spec that needs more than Druid** (S3, Kafka...) gets its own `*WebConsoleTest` class that extends `WebConsoleTestBase`, adds the resource in `addResources` and passes its settings (endpoint, bucket...) to the spec as - environment variables of `runSpec`. + environment variables of `runSpec`. See `S3WebConsoleTest` and `s3-ingestion.spec.ts`. The resources run in Docker + containers (with Testcontainers), so Docker has to be running. A test deletes the datasources it created when it passes: it stops their tasks, removes their compaction config and permanently deletes their segments (with a `kill` task). When it fails, they are kept to look into (the log says which) @@ -95,6 +96,7 @@ for up to 2 minutes. | `auto-compaction.spec.ts` | Follows the [compaction tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-compaction): loads 2 hours of `compaction-init-index.json` through the API (3 segments, 1,412 rows), sets a compaction config (`skipOffsetFromLatest: PT0S`, hashed partitions) from the **Datasources** view, reopens the dialog until it reads back the same config, then forces compaction runs (from the Alt-click "more" menu) until there are 2 segments. | | `multi-stage-query.spec.ts` | Runs an MSQ `SELECT` over `EXTERN(...)` on the tutorial file in the **Query** view, clicking "Run it anyway" if the cluster warns it lacks task slots, and checks the top 2 of the 10 channels by count. | | `cancel-query.spec.ts` | Runs `SELECT sleep(40)` in the **Query** view, waits for its `POST` to `druid/v2`, clicks "Cancel query" and checks that the `DELETE` it sends is answered with `202 Accepted`. | +| `s3-ingestion.spec.ts` | Loads the tutorial file from S3 through the data loader's **Amazon S3** connector (its URI from `DRUID_E2E_TEST_S3_URI`) and waits for the task to succeed and for 1 segment with 39,244 rows. Skipped without `DRUID_E2E_TEST_S3_URI`: it runs on an embedded cluster with an S3 container, from `S3WebConsoleTest` (which also makes S3 the deep storage and task log storage). | ## Layout diff --git a/web-console/e2e-tests/component/load-data/data-connector.ts b/web-console/e2e-tests/component/load-data/data-connector.ts index 6c5d87974ab7..e85ec4e7fe32 100644 --- a/web-console/e2e-tests/component/load-data/data-connector.ts +++ b/web-console/e2e-tests/component/load-data/data-connector.ts @@ -18,13 +18,14 @@ import type { Page } from '@playwright/test'; -import { clickButton, setLabeledInput } from '../../util/playwright'; +import { clickButton, setLabeledInput, setLabeledTextarea } from '../../util/playwright'; /** * Where the data loader reads the data from. */ export type DataConnector = | { readonly type: 'local'; readonly baseDirectory: string; readonly fileFilter: string } + | { readonly type: 's3'; readonly uris: string[] } | { readonly type: 'reindex'; readonly datasourceName: string; readonly interval: string }; /** @@ -34,6 +35,8 @@ export function connectorCardTitle(connector: DataConnector): string { switch (connector.type) { case 'local': return 'Local disk'; + case 's3': + return 'Amazon S3'; case 'reindex': return 'Reindex from Druid'; } @@ -56,6 +59,10 @@ export async function connect(page: Page, connector: DataConnector): Promise { + test.skip(!S3_URI, 'needs DRUID_E2E_TEST_S3_URI (run by S3WebConsoleTest in embedded-tests)'); + + test('Loads data from S3', async ({ page, request, newDatasourceName }) => { + const datasourceName = newDatasourceName('load-data-from-s3'); + await loadData(page, { + connector: { type: 's3', uris: [S3_URI!] }, + validateConnect: lines => { + expect(lines.length).toBe(500); + expect(lines[0]).toMatch(/^\{"time":"2015-09-12T00:46:58.771Z","channel":"#en.wikipedia",/); + }, + rollup: false, + segmentGranularity: 'day', + datasourceName, + }); + + await expect + .poll(() => getTaskStatuses(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual(['SUCCESS']); + await expect + .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual({ numSegments: 1, numAvailableSegments: 1, numRows: 39244 }); + }); +}); From 346d74b6eb6bd5dbbc06f5b961c250b966bc9c1b Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 15:43:26 -0700 Subject: [PATCH 13/26] run the web console e2e tests on embedded clusters in CI web-checks.sh installs web-console with the console built, then runs the web-console-tests profile of embedded-tests (building only the modules it needs) instead of building a distribution and starting it with script/druid. worker.yml uploads the failsafe reports and the Playwright results in place of the distribution's logs. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/web-checks.sh | 16 +++++++--------- .github/workflows/worker.yml | 3 ++- web-console/AGENTS.md | 5 +++-- web-console/E2E_BETTERMENT_PLAN.md | 10 +++++++--- web-console/README.md | 2 +- web-console/e2e-tests/README.md | 4 ++-- 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/.github/scripts/web-checks.sh b/.github/scripts/web-checks.sh index 8f39c16a47f8..a1588c695a21 100755 --- a/.github/scripts/web-checks.sh +++ b/.github/scripts/web-checks.sh @@ -20,7 +20,8 @@ set -x # Install node.js via maven frontend plugin (same approach as jacoco script) ./mvnw -B com.github.eirslett:frontend-maven-plugin:install-node-and-npm@install-node-and-npm -pl web-console/ -PATH+=:web-console/target/node/ +# Absolute, as the web console end-to-end tests run npx from another directory +PATH+=:$(pwd)/web-console/target/node/ # docs (cd website && npm ci) @@ -30,12 +31,9 @@ npm run link-lint npm run spellcheck cd .. -# web console -./mvnw -B test -pl 'web-console' +# web console (installed with the console built, for the Router of the end-to-end tests to serve) +./mvnw -B install -pl 'web-console' -# web console end-to-end test -./.github/scripts/setup_generate_license.sh -web-console/script/druid build -web-console/script/druid start -(cd web-console && npm run test-e2e) -web-console/script/druid stop +# web console end-to-end tests, on embedded clusters (builds the modules embedded-tests needs, not web-console again) +./mvnw -B verify -pl 'embedded-tests,!web-console' -am -Pweb-console-tests,skip-static-checks -DskipUTs \ + -Dmaven.javadoc.skip=true -Danimal.sniffer.skip=true -Denforcer.skip=true -Dcyclonedx.skip=true diff --git a/.github/workflows/worker.yml b/.github/workflows/worker.yml index fa0474460a87..4cd829fbfc34 100644 --- a/.github/workflows/worker.yml +++ b/.github/workflows/worker.yml @@ -161,4 +161,5 @@ jobs: **/TEST-*.xml **/target/jacoco*.exec **/target/ci-debug/** - distribution/target/apache-druid-*/log/** + embedded-tests/target/failsafe-reports/** + web-console/test-results/** diff --git a/web-console/AGENTS.md b/web-console/AGENTS.md index 454bd580fc75..99e5b26bf810 100644 --- a/web-console/AGENTS.md +++ b/web-console/AGENTS.md @@ -40,8 +40,9 @@ The Apache Druid web console: a React + TypeScript single page app built with Bl bundle first. - `npm run autofix` fixes ESLint, stylelint and Prettier issues. - **End-to-end tests** (`e2e-tests/`, Playwright) need a running Druid: `script/druid build && script/druid start`, - then `npm run test-e2e`. See `e2e-tests/README.md` for running one test or with a visible browser, and below for - running them against your own changes. + then `npm run test-e2e`. CI runs them on embedded clusters instead (`*WebConsoleTest` in `embedded-tests`, profile + `web-console-tests`). See `e2e-tests/README.md` for both, for running one test or with a visible browser, and below + for running them against your own changes. ## Developing against a running Druid diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index f5503b695731..ab53adb68b3b 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -100,14 +100,18 @@ specs pass unchanged on it (50s for the whole Maven run), with a keep-alive mode `S3WebConsoleTest` (with `S3StorageResource`) runs `s3-ingestion.spec.ts`, which loads from `s3://` through the data loader (24s for the test class). +CI: `web-checks.sh` installs `web-console` (with the console built), then runs +`mvn verify -pl 'embedded-tests,!web-console' -am -Pweb-console-tests -DskipUTs`, which builds the 38 modules +embedded-tests needs (not the whole of Druid, nor a distribution). `worker.yml` uploads +`embedded-tests/target/failsafe-reports/` (the Druid and Playwright logs) and `web-console/test-results/` (instead of +the distribution's logs), and the JUnit report shows the specs' results. `script/druid` stays for running the tests +against a quickstart locally. + Found on the way (fixed in the specs, they were latent races that the quickstart's timing hid): - `extractTableRecords` read a view's table right after opening it, before the view loaded its data. - `cancelQuery` waited for any SQL `POST` (it could be the column tree's) and canceled before the query was sent. Left: -- CI: a job that installs Druid with the console built (not `-Dweb.console.skip=true`), runs `npm ci` and - `mvn -pl embedded-tests verify -Pweb-console-tests`, and uploads `web-console/test-results/` and - `embedded-tests/target/failsafe-reports/` on failure; then drop `script/druid` from `web-checks.sh`. - Make the `newDatasourceName` cleanup optional (a new cluster per class doesn't need it), or drop it with the quickstart way. - A check that every spec is run by some `*WebConsoleTest` (they are listed by hand). diff --git a/web-console/README.md b/web-console/README.md index 92a28f925bd6..2a8f64363e42 100644 --- a/web-console/README.md +++ b/web-console/README.md @@ -109,7 +109,7 @@ From the web-console directory: 4. Stop druid cluster: `script/druid stop` If you already have a druid cluster running on the standard ports, the steps to build/start/stop a druid cluster can -be skipped. +be skipped. CI runs the tests on embedded clusters instead, see [e2e-tests/README.md](e2e-tests/README.md). #### Running one test, watching or debugging diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index 803fd7a14e9a..78cf1054d78d 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -25,7 +25,7 @@ fails against a live cluster. They use the Playwright test runner (`@playwright/test`, configured in `../playwright.config.ts`). The Chromium it drives is downloaded by the `playwright-chromium` package's install script, so keep the two packages on the same -version. They run in CI as part of `.github/scripts/web-checks.sh`. +version. They run in CI on embedded clusters (see below), as part of `.github/scripts/web-checks.sh`. ## Running them @@ -52,7 +52,7 @@ Before the tests start, a global setup (`util/global-setup.ts`) waits for the co (git ignored). Every HTTP response with a status of 400 or more is also logged with its body (`util/fixtures.ts`). -### On an embedded cluster (prototype) +### On an embedded cluster (as in CI) `CoreWebConsoleTest` in `embedded-tests` (package `org.apache.druid.testing.embedded.console`) starts an embedded cluster (in one JVM, with an in-memory metadata store, its Router on :8888) and runs each spec on it with From 3b68a400360270f9c80e8f199328a05cfd5d0ce8 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 16:11:42 -0700 Subject: [PATCH 14/26] move the web console e2e tests next to the embedded tests of their functionality Splits CoreWebConsoleTest into a *WebConsoleTest per functionality (indexing, compact, msq, query) and moves S3WebConsoleTest to the s3 package. WebConsoleTestBase stays in console, as shared infrastructure. Co-Authored-By: Claude Opus 5.5 --- .../compact/AutoCompactionWebConsoleTest.java | 35 +++++++++++++++++++ .../embedded/console/WebConsoleTestBase.java | 19 +++++++--- .../BatchIndexingWebConsoleTest.java} | 31 ++++++++-------- .../msq/MultiStageQueryWebConsoleTest.java | 35 +++++++++++++++++++ .../query/SqlQueryCancelWebConsoleTest.java | 35 +++++++++++++++++++ .../{console => s3}/S3WebConsoleTest.java | 4 +-- web-console/E2E_BETTERMENT_PLAN.md | 5 +-- web-console/e2e-tests/README.md | 33 +++++++++++------ 8 files changed, 162 insertions(+), 35 deletions(-) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/compact/AutoCompactionWebConsoleTest.java rename embedded-tests/src/test/java/org/apache/druid/testing/embedded/{console/CoreWebConsoleTest.java => indexing/BatchIndexingWebConsoleTest.java} (56%) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/MultiStageQueryWebConsoleTest.java create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/SqlQueryCancelWebConsoleTest.java rename embedded-tests/src/test/java/org/apache/druid/testing/embedded/{console => s3}/S3WebConsoleTest.java (95%) diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/compact/AutoCompactionWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/compact/AutoCompactionWebConsoleTest.java new file mode 100644 index 000000000000..8db123a0fc70 --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/compact/AutoCompactionWebConsoleTest.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.compact; + +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; +import org.junit.jupiter.api.Test; + +/** + * Sets up auto-compaction from the Datasources view of the web console, and forces compaction runs from there. + */ +public class AutoCompactionWebConsoleTest extends WebConsoleTestBase +{ + @Test + public void testCompactionConfigAndForcedRun() throws Exception + { + runSpec("auto-compaction.spec.ts"); + } +} diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java index c430a5f7a288..a18e48f6c58a 100644 --- a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java @@ -56,14 +56,16 @@ * Steps to write a test: *
    *
  • Write the Playwright spec in {@code web-console/e2e-tests}.
  • - *
  • Write a {@code *WebConsoleTest} class that extends this class. Add the resources the spec needs (S3, Kafka, - * etc.) in {@link #addResources}, and the extensions and properties in {@link #configureCluster}.
  • + *
  • Write a {@code *WebConsoleTest} class that extends this class, in the package of the functionality the spec + * covers (like {@code s3} for loading data from S3). Add the resources the spec needs (S3, Kafka, etc.) in + * {@link #addResources}, and the extensions and properties in {@link #configureCluster}.
  • *
  • Write a {@code @Test} method that calls {@link #runSpec}, passing the settings of the resources to the spec * through environment variables.
  • *
- * The tests run only with the {@code web-console-tests} profile: + * The tests run only with the {@code web-console-tests} profile, all of them or one class: *
- * mvn -pl embedded-tests verify -Pweb-console-tests -Dit.test=CoreWebConsoleTest
+ * mvn -pl embedded-tests verify -Pweb-console-tests
+ * mvn -pl embedded-tests verify -Pweb-console-tests -Dit.test=BatchIndexingWebConsoleTest
  * 
* With {@code -Dweb.console.keepAlive=true}, {@link #runSpec} doesn't run the spec. It prints the command to run it * and waits, keeping the cluster up, so that the spec can be run (and debugged) from {@code web-console} against it. @@ -124,6 +126,15 @@ protected void configureCluster(EmbeddedDruidCluster cluster) { } + /** + * Runs the Playwright spec (a file name or a pattern of {@code web-console/e2e-tests}) against this cluster and + * fails if it fails. + */ + protected void runSpec(String spec) throws Exception + { + runSpec(spec, Map.of()); + } + /** * Runs the Playwright spec (a file name or a pattern of {@code web-console/e2e-tests}) against this cluster and * fails if it fails. diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/CoreWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/BatchIndexingWebConsoleTest.java similarity index 56% rename from embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/CoreWebConsoleTest.java rename to embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/BatchIndexingWebConsoleTest.java index 33aa00281de4..9fe3169fbf5e 100644 --- a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/CoreWebConsoleTest.java +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/BatchIndexingWebConsoleTest.java @@ -17,28 +17,25 @@ * under the License. */ -package org.apache.druid.testing.embedded.console; +package org.apache.druid.testing.embedded.indexing; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import java.util.Map; +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; +import org.junit.jupiter.api.Test; /** - * Runs the web console specs that need only a plain cluster (no external resources). + * Runs batch ingestion through the data loader of the web console: from local files and reindexing from Druid. */ -public class CoreWebConsoleTest extends WebConsoleTestBase +public class BatchIndexingWebConsoleTest extends WebConsoleTestBase { - @ParameterizedTest(name = "{0}") - @ValueSource(strings = { - "auto-compaction.spec.ts", - "cancel-query.spec.ts", - "multi-stage-query.spec.ts", - "reindexing.spec.ts", - "tutorial-batch.spec.ts" - }) - public void testSpec(String spec) throws Exception + @Test + public void testLoadFromLocalDisk() throws Exception + { + runSpec("tutorial-batch.spec.ts"); + } + + @Test + public void testReindex() throws Exception { - runSpec(spec, Map.of()); + runSpec("reindexing.spec.ts"); } } diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/MultiStageQueryWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/MultiStageQueryWebConsoleTest.java new file mode 100644 index 000000000000..dd23cc70b58e --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/MultiStageQueryWebConsoleTest.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.msq; + +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; +import org.junit.jupiter.api.Test; + +/** + * Runs an MSQ query on external data from the Query view of the web console. + */ +public class MultiStageQueryWebConsoleTest extends WebConsoleTestBase +{ + @Test + public void testExternQuery() throws Exception + { + runSpec("multi-stage-query.spec.ts"); + } +} diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/SqlQueryCancelWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/SqlQueryCancelWebConsoleTest.java new file mode 100644 index 000000000000..220815a6035a --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/SqlQueryCancelWebConsoleTest.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.query; + +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; +import org.junit.jupiter.api.Test; + +/** + * Cancels a running SQL query from the Query view of the web console. + */ +public class SqlQueryCancelWebConsoleTest extends WebConsoleTestBase +{ + @Test + public void testCancelQuery() throws Exception + { + runSpec("cancel-query.spec.ts"); + } +} diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/S3WebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/s3/S3WebConsoleTest.java similarity index 95% rename from embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/S3WebConsoleTest.java rename to embedded-tests/src/test/java/org/apache/druid/testing/embedded/s3/S3WebConsoleTest.java index 1e35fccabdee..443e795b6628 100644 --- a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/S3WebConsoleTest.java +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/s3/S3WebConsoleTest.java @@ -17,12 +17,12 @@ * under the License. */ -package org.apache.druid.testing.embedded.console; +package org.apache.druid.testing.embedded.s3; import org.apache.druid.data.input.s3.S3InputSourceDruidModule; import org.apache.druid.java.util.common.StringUtils; import org.apache.druid.testing.embedded.EmbeddedDruidCluster; -import org.apache.druid.testing.embedded.s3.S3StorageResource; +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import software.amazon.awssdk.core.sync.RequestBody; diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index ab53adb68b3b..608349e532e9 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -94,8 +94,9 @@ modernize them, in order. Each step should leave the suite passing. ## 8. Run on embedded clusters (prototype done) -Prototype (option B: JUnit drives, the specs stay in TypeScript): `WebConsoleTestBase` and `CoreWebConsoleTest` in -`embedded-tests` (`org.apache.druid.testing.embedded.console`), tag `web-console`, profile `web-console-tests`. The 5 +Prototype (option B: JUnit drives, the specs stay in TypeScript): `WebConsoleTestBase` in `embedded-tests` +(`org.apache.druid.testing.embedded.console`), tag `web-console`, profile `web-console-tests`, and a +`*WebConsoleTest` per functionality, in its package (`indexing`, `compact`, `msq`, `query`, `s3`). The 5 specs pass unchanged on it (50s for the whole Maven run), with a keep-alive mode for working on a spec. `S3WebConsoleTest` (with `S3StorageResource`) runs `s3-ingestion.spec.ts`, which loads from `s3://` through the data loader (24s for the test class). diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index 78cf1054d78d..3a05bec063f6 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -54,16 +54,28 @@ Before the tests start, a global setup (`util/global-setup.ts`) waits for the co ### On an embedded cluster (as in CI) -`CoreWebConsoleTest` in `embedded-tests` (package `org.apache.druid.testing.embedded.console`) starts an embedded -cluster (in one JVM, with an in-memory metadata store, its Router on :8888) and runs each spec on it with -`npx playwright test `. There's no distribution to build and nothing left behind, as every test class gets a new -cluster. It needs this checkout's Druid modules installed (`mvn install -DskipTests`), with the `web-console` one built -with the console (not with `-Dweb.console.skip=true`, which leaves the Router with no console to serve): +Each spec is run by a `*WebConsoleTest` class in `embedded-tests`, in the package of the functionality it covers. The +class starts an embedded cluster (in one JVM, with an in-memory metadata store, its Router on :8888) with the resources +the spec needs, and runs the spec on it with `npx playwright test `: + +| Spec | Run by (in `org.apache.druid.testing.embedded`) | +|-----------------------------------------------|-------------------------------------------------| +| `tutorial-batch.spec.ts`, `reindexing.spec.ts` | `indexing.BatchIndexingWebConsoleTest` | +| `auto-compaction.spec.ts` | `compact.AutoCompactionWebConsoleTest` | +| `multi-stage-query.spec.ts` | `msq.MultiStageQueryWebConsoleTest` | +| `cancel-query.spec.ts` | `query.SqlQueryCancelWebConsoleTest` | +| `s3-ingestion.spec.ts` | `s3.S3WebConsoleTest` (with an S3 container) | + +They extend `console.WebConsoleTestBase`. There's no distribution to build and nothing left behind, as every test class +gets a new cluster. They need this checkout's Druid modules installed (`mvn install -DskipTests`), with the +`web-console` one built with the console (not with `-Dweb.console.skip=true`, which leaves the Router with no console +to serve): ```bash # from the root of the checkout mvn -pl web-console install -DskipTests # after changing the console (not the tests), for the Router to serve it -mvn -pl embedded-tests verify -Pweb-console-tests -Dit.test=CoreWebConsoleTest +mvn -pl embedded-tests verify -Pweb-console-tests # all of them +mvn -pl embedded-tests verify -Pweb-console-tests -Dit.test=BatchIndexingWebConsoleTest # one class ``` The Druid logs and the Playwright output are in `embedded-tests/target/failsafe-reports/*-output.txt`, and the @@ -75,10 +87,11 @@ artifacts of a failed spec in `test-results//`. The tests are tagged `web- as you like) and waits until it's stopped. - **Test a dev server instead of the bundled console**: add `-Dweb.console.port=18081`, with `npm start` running (it proxies to :8888). -- **A spec that needs more than Druid** (S3, Kafka...) gets its own `*WebConsoleTest` class that extends - `WebConsoleTestBase`, adds the resource in `addResources` and passes its settings (endpoint, bucket...) to the spec as - environment variables of `runSpec`. See `S3WebConsoleTest` and `s3-ingestion.spec.ts`. The resources run in Docker - containers (with Testcontainers), so Docker has to be running. +- **A new spec** gets a `@Test` in the `*WebConsoleTest` class of its functionality (or a new class, next to the + embedded tests of that functionality). One that needs more than Druid (S3, Kafka...) adds the resource in + `addResources` and passes its settings (endpoint, bucket...) to the spec as environment variables of `runSpec`. See + `S3WebConsoleTest` and `s3-ingestion.spec.ts`. The resources run in Docker containers (with Testcontainers), so + Docker has to be running. A test deletes the datasources it created when it passes: it stops their tasks, removes their compaction config and permanently deletes their segments (with a `kill` task). When it fails, they are kept to look into (the log says which) From b5875181b4198ad039eba9b5bc1908edcb924403 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 16:26:28 -0700 Subject: [PATCH 15/26] skip the e2e datasource cleanup on embedded clusters, check that every spec is run The specs no longer delete their datasources on an embedded cluster, as it's thrown away after the test class. WebConsoleSpecsTest checks that every spec of web-console/e2e-tests is run by a *WebConsoleTest. Co-Authored-By: Claude Opus 5.5 --- .../embedded/console/WebConsoleSpecsTest.java | 79 +++++++++++++++++++ .../embedded/console/WebConsoleTestBase.java | 4 + web-console/E2E_BETTERMENT_PLAN.md | 11 +-- web-console/e2e-tests/README.md | 5 +- web-console/e2e-tests/util/fixtures.ts | 2 + 5 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleSpecsTest.java diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleSpecsTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleSpecsTest.java new file mode 100644 index 000000000000..6b147a939216 --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleSpecsTest.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.console; + +import com.google.common.collect.Sets; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Checks that every Playwright spec of {@code web-console/e2e-tests} is run by a {@link WebConsoleTestBase} test (the + * specs are passed to {@link WebConsoleTestBase#runSpec} by name), and that those tests run only specs that exist. + * It needs no cluster, so it runs with the unit tests rather than in the {@code web-console-tests} profile. + */ +public class WebConsoleSpecsTest +{ + private static final Pattern RUN_SPEC = Pattern.compile("runSpec\\(\\s*\"([^\"]+)\""); + + @Test + public void testEverySpecIsRun() throws IOException + { + final Set specs; + try (Stream files = Files.list(new File(WebConsoleTestBase.webConsoleDir(), "e2e-tests").toPath())) { + specs = files.map(file -> file.getFileName().toString()) + .filter(name -> name.endsWith(".spec.ts")) + .collect(Collectors.toCollection(TreeSet::new)); + } + + final Set runSpecs = new TreeSet<>(); + try (Stream files = Files.walk(Path.of("src", "test", "java"))) { + for (Path file : files.filter(f -> f.getFileName().toString().endsWith("WebConsoleTest.java")).toList()) { + final Matcher matcher = RUN_SPEC.matcher(Files.readString(file, StandardCharsets.UTF_8)); + while (matcher.find()) { + runSpecs.add(matcher.group(1)); + } + } + } + + Assertions.assertFalse(specs.isEmpty(), "Found no specs in web-console/e2e-tests"); + Assertions.assertEquals( + Set.of(), + Sets.difference(specs, runSpecs), + "Specs of web-console/e2e-tests that no *WebConsoleTest runs (with runSpec)" + ); + Assertions.assertEquals( + Set.of(), + Sets.difference(runSpecs, specs), + "Specs that a *WebConsoleTest runs, which are not in web-console/e2e-tests" + ); + } +} diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java index a18e48f6c58a..e765bfabdc7d 100644 --- a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java @@ -158,6 +158,10 @@ protected void runSpec(String spec, Map env) throws Exception return; } + // The cluster is thrown away after this class, so the specs don't need to delete what they create. (A kept-alive + // cluster is reused for runs of the specs by hand, so they do.) + specEnv.put("DRUID_E2E_TEST_CLUSTER_IS_DISPOSABLE", "true"); + log.info("Running spec[%s] in [%s] with env[%s].", spec, webConsoleDir, specEnv); final ProcessBuilder processBuilder = new ProcessBuilder(command).directory(webConsoleDir) .redirectErrorStream(true); diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index 608349e532e9..229059f94944 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -112,8 +112,9 @@ Found on the way (fixed in the specs, they were latent races that the quickstart - `extractTableRecords` read a view's table right after opening it, before the view loaded its data. - `cancelQuery` waited for any SQL `POST` (it could be the column tree's) and canceled before the query was sent. -Left: -- Make the `newDatasourceName` cleanup optional (a new cluster per class doesn't need it), or drop it with the - quickstart way. -- A check that every spec is run by some `*WebConsoleTest` (they are listed by hand). -- The JUnit report names the cases `testSpec(String)[1]`..., not by spec. +Also done: +- The specs don't delete their datasources on an embedded cluster (`runSpec` sets + `DRUID_E2E_TEST_CLUSTER_IS_DISPOSABLE=true`, except in keep-alive mode), as it's thrown away after the class. +- `WebConsoleSpecsTest` (a unit test, no cluster) checks that every spec is run by a `*WebConsoleTest`, and that they + run only specs that exist. +- Each spec has its own `@Test`, so the JUnit report names them. diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index 3a05bec063f6..25fed3c8cc24 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -88,14 +88,15 @@ artifacts of a failed spec in `test-results//`. The tests are tagged `web- - **Test a dev server instead of the bundled console**: add `-Dweb.console.port=18081`, with `npm start` running (it proxies to :8888). - **A new spec** gets a `@Test` in the `*WebConsoleTest` class of its functionality (or a new class, next to the - embedded tests of that functionality). One that needs more than Druid (S3, Kafka...) adds the resource in + embedded tests of that functionality). `WebConsoleSpecsTest`, a unit test, fails if a spec isn't run by any. One that needs more than Druid (S3, Kafka...) adds the resource in `addResources` and passes its settings (endpoint, bucket...) to the spec as environment variables of `runSpec`. See `S3WebConsoleTest` and `s3-ingestion.spec.ts`. The resources run in Docker containers (with Testcontainers), so Docker has to be running. A test deletes the datasources it created when it passes: it stops their tasks, removes their compaction config and permanently deletes their segments (with a `kill` task). When it fails, they are kept to look into (the log says which) -and have to be deleted by hand, from the Datasources view. +and have to be deleted by hand, from the Datasources view. On an embedded cluster nothing is deleted, as the cluster is +thrown away after the test class (`DRUID_E2E_TEST_CLUSTER_IS_DISPOSABLE=true`). ## The tests diff --git a/web-console/e2e-tests/util/fixtures.ts b/web-console/e2e-tests/util/fixtures.ts index 0394602561d7..8d3d7b9493eb 100644 --- a/web-console/e2e-tests/util/fixtures.ts +++ b/web-console/e2e-tests/util/fixtures.ts @@ -52,6 +52,7 @@ export const test = base.extend<{ // Makes a unique datasource name (the prefix and the time), for the test to create. After the test passes, the // datasource is deleted (with its tasks, compaction config and segments). After it fails, it is kept to look into. + // Nothing is deleted on a cluster that is thrown away after the tests (an embedded one, see WebConsoleTestBase). newDatasourceName: [ async ({ request }, use, testInfo) => { const datasourceNames: string[] = []; @@ -61,6 +62,7 @@ export const test = base.extend<{ return datasourceName; }); + if (process.env['DRUID_E2E_TEST_CLUSTER_IS_DISPOSABLE'] === 'true') return; if (testInfo.status !== testInfo.expectedStatus) { for (const datasourceName of datasourceNames) { console.log(`Keeping datasource ${datasourceName} of the failed test`); From 152ef0c6268534880a1df6806a9c350c36898389 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 16:50:20 -0700 Subject: [PATCH 16/26] show the steps of the e2e tests as screenshots with E2E_SHOW_STEPS=true showStep(page, title) saves a screenshot to e2e-tests/steps/-NNN.png (git ignored) when E2E_SHOW_STEPS=true, and is called by the page objects at each step of the data loader, each view read, query results and dialogs, and by a fixture as the test ends. setQueryInput closes the autocomplete that typing opens, as it covered the query. Co-Authored-By: Claude Opus 5.5 --- web-console/.gitignore | 3 +- web-console/e2e-tests/README.md | 16 ++++- .../component/datasources/overview.ts | 9 ++- .../e2e-tests/component/ingestion/overview.ts | 2 + .../component/load-data/data-loader.ts | 12 ++++ .../e2e-tests/component/workbench/overview.ts | 9 ++- web-console/e2e-tests/util/fixtures.ts | 14 +++++ web-console/e2e-tests/util/playwright.ts | 5 +- web-console/e2e-tests/util/steps.ts | 59 +++++++++++++++++++ 9 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 web-console/e2e-tests/util/steps.ts diff --git a/web-console/.gitignore b/web-console/.gitignore index 19e698794299..d3a59a502544 100644 --- a/web-console/.gitignore +++ b/web-console/.gitignore @@ -19,9 +19,10 @@ tscommand-*.tmp.txt licenses.json .druid.pid -# What the e2e tests write (screenshots and traces of failed tests, the HTML report) +# What the e2e tests write (screenshots and traces of failed tests, the HTML report, the steps of E2E_SHOW_STEPS=true) /test-results/ /playwright-report/ +/e2e-tests/steps/ # Generated by script/build-grammars.mjs src/editor-languages/*.parser.ts diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index 25fed3c8cc24..65bca0d63025 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -51,6 +51,11 @@ Before the tests start, a global setup (`util/global-setup.ts`) waits for the co console and network at that point) and an `error-context.md` (the page as an accessibility tree) in `test-results/` (git ignored). Every HTTP response with a status of 400 or more is also logged with its body (`util/fixtures.ts`). +- **See the steps a test takes**: `E2E_SHOW_STEPS=true npm run test-e2e`. Each test saves a screenshot at each of + its steps (each step of the data loader, each view it reads, a query's results, a dialog it fills in, and the + page as it ends) to `steps/-001.png`, `-002.png`, ... (git ignored, replaced by the next run of the spec). + The quickest way to see what a test does, especially one someone else wrote. It works on the embedded clusters + too: `E2E_SHOW_STEPS=true mvn -pl embedded-tests verify -Pweb-console-tests ...`. ### On an embedded cluster (as in CI) @@ -124,14 +129,16 @@ e2e-tests/ (data-connector.ts: local disk, reindex from Druid; partitions-spec.ts: hashed, range) workbench/ Query view: run a query (accepting the task slot warning), cancel a query util/ - fixtures.ts the `test` and `expect` to import in specs (`test` logs failed responses, and has the - newDatasourceName fixture) + fixtures.ts the `test` and `expect` to import in specs (`test` logs failed responses, shows the last + step, and has the newDatasourceName fixture) global-setup.ts waits for the console's SQL endpoint before the tests start druid.ts the tutorial data dir and ingestion specs, runTask (submits a task and waits for it), deleteDatasource sql.ts querySql, and the cluster state the tests wait for: task statuses, a datasource's segments playwright.ts openView, and helpers that find inputs and buttons by their label or text table.ts extractTable / extractTableRecords: read a table's rows as text (by position / by header) + steps.ts showStep: a screenshot of a step of the test, with E2E_SHOW_STEPS=true + steps/ the screenshots of showStep (git ignored) ``` ### Writing a test @@ -155,8 +162,11 @@ e2e-tests/ - Read the console's tables with `extractTableRecords`, which keys each row by its column headers, so a column being added, hidden or moved doesn't break the test. - Retry UI checks that depend on data loading with `await expect(async () => { ... }).toPass()`. Make each attempt - fail fast (like `QueryOverview.runQuery` throwing on a query error) rather than wait out a timeout. + fail fast (like `WorkbenchOverview.runQuery` throwing on a query error) rather than wait out a timeout. - Set up data that the test is not about through the API, not the UI: `readTutorialIngestionSpec`, change what you need (the datasource name at least) and `runTask(request, ingestionSpec)`. +- Call `showStep(page, 'what the page shows')` at the points worth seeing, once the page shows them (in a page + object, so every test using it gets the step). With `E2E_SHOW_STEPS=true` it saves a screenshot, otherwise it does + nothing. - Name a datasource you create with the `newDatasourceName` fixture: `newDatasourceName('my-test')` makes a unique name (the prefix and the time) and deletes the datasource after the test passes. diff --git a/web-console/e2e-tests/component/datasources/overview.ts b/web-console/e2e-tests/component/datasources/overview.ts index ac778ad32bee..3b81db6c4ae9 100644 --- a/web-console/e2e-tests/component/datasources/overview.ts +++ b/web-console/e2e-tests/component/datasources/overview.ts @@ -25,6 +25,7 @@ import { openView, setLabeledInput, } from '../../util/playwright'; +import { showStep } from '../../util/steps'; import { extractTableRecords } from '../../util/table'; import type { PartitionsSpec } from '../load-data/partitions-spec'; import { applyPartitionsSpec, readPartitionsSpec } from '../load-data/partitions-spec'; @@ -67,6 +68,7 @@ export class DatasourcesOverview { await openView(this.page, 'datasources', { datasource: datasourceName }); const records = await extractTableRecords(this.table()); + await showStep(this.page, `Datasources view, filtered on ${datasourceName}`); return records.map(record => ({ name: record['Datasource name'], @@ -83,6 +85,7 @@ export class DatasourcesOverview { await setLabeledInput(dialog, SKIP_OFFSET_FROM_LATEST, compactionConfig.skipOffsetFromLatest); await applyPartitionsSpec(this.page, compactionConfig.partitionsSpec); + await showStep(this.page, 'Compaction config dialog, filled in'); await clickButton(dialog, 'Submit'); } @@ -92,6 +95,7 @@ export class DatasourcesOverview { const skipOffsetFromLatest = await getLabeledInput(dialog, SKIP_OFFSET_FROM_LATEST); const partitionsSpec = await readPartitionsSpec(this.page); + await showStep(this.page, 'Compaction config dialog, as saved'); await clickButton(dialog.locator('.bp6-dialog-footer'), 'Close'); return { skipOffsetFromLatest, partitionsSpec: partitionsSpec! }; @@ -111,6 +115,9 @@ export class DatasourcesOverview { await openView(this.page, 'datasources'); await this.page.locator('.more-button button').click({ modifiers: ['Alt'] }); await clickMenuItem(this.page, 'Force compaction run'); - await clickButton(this.page.locator('.bp6-alert'), 'Force compaction run'); + const confirmation = this.page.locator('.bp6-alert'); + await confirmation.waitFor(); + await showStep(this.page, 'Force compaction run, to confirm'); + await clickButton(confirmation, 'Force compaction run'); } } diff --git a/web-console/e2e-tests/component/ingestion/overview.ts b/web-console/e2e-tests/component/ingestion/overview.ts index 79d90bf78a5c..3cb60517c6c1 100644 --- a/web-console/e2e-tests/component/ingestion/overview.ts +++ b/web-console/e2e-tests/component/ingestion/overview.ts @@ -19,6 +19,7 @@ import type { Page } from '@playwright/test'; import { openView } from '../../util/playwright'; +import { showStep } from '../../util/steps'; import { extractTableRecords } from '../../util/table'; /** @@ -46,6 +47,7 @@ export class TasksOverview { await openView(this.page, 'tasks', { datasource: datasourceName }); const records = await extractTableRecords(this.page.locator('.tasks-view .console-table')); + await showStep(this.page, `Tasks view, filtered on ${datasourceName}`); return records.map(record => ({ datasource: record['Datasource'], diff --git a/web-console/e2e-tests/component/load-data/data-loader.ts b/web-console/e2e-tests/component/load-data/data-loader.ts index dc8eb28e7a7d..64f3c1c3afab 100644 --- a/web-console/e2e-tests/component/load-data/data-loader.ts +++ b/web-console/e2e-tests/component/load-data/data-loader.ts @@ -19,6 +19,7 @@ import type { Page } from '@playwright/test'; import { clickButton, openView, setLabeledInput, setLabeledTextarea } from '../../util/playwright'; +import { showStep } from '../../util/steps'; import type { DataConnector } from './data-connector'; import { connect, connectorCardTitle, connectorNeedsParse } from './data-connector'; @@ -57,6 +58,7 @@ export async function loadData(page: Page, config: DataLoaderConfig): Promise response.url().includes('druid/v2') && response.request().method() === 'DELETE', ); await this.page.locator('.cancel-label', { hasText: 'Cancel query' }).click(); - return (await cancelResponse).status(); + const status = (await cancelResponse).status(); + await showStep(this.page, 'Query view: the query canceled'); + return status; } } diff --git a/web-console/e2e-tests/util/fixtures.ts b/web-console/e2e-tests/util/fixtures.ts index 8d3d7b9493eb..5e57e9c2db24 100644 --- a/web-console/e2e-tests/util/fixtures.ts +++ b/web-console/e2e-tests/util/fixtures.ts @@ -19,11 +19,13 @@ import { test as base } from '@playwright/test'; import { deleteDatasource } from './druid'; +import { showStep } from './steps'; export { expect } from '@playwright/test'; export const test = base.extend<{ logFailedResponses: void; + showLastStep: void; newDatasourceName: (prefix: string) => string; }>({ // Logs every response with an error status (and its body) so that a failure can be understood from the log alone @@ -50,6 +52,18 @@ export const test = base.extend<{ { auto: true }, ], + // Shows the page as the test ends (see showStep), as the last step + showLastStep: [ + async ({ page }, use, testInfo) => { + await use(); + await showStep( + page, + testInfo.status === testInfo.expectedStatus ? 'The end' : 'The end (failed)', + ); + }, + { auto: true }, + ], + // Makes a unique datasource name (the prefix and the time), for the test to create. After the test passes, the // datasource is deleted (with its tasks, compaction config and segments). After it fails, it is kept to look into. // Nothing is deleted on a cluster that is thrown away after the tests (an embedded one, see WebConsoleTestBase). diff --git a/web-console/e2e-tests/util/playwright.ts b/web-console/e2e-tests/util/playwright.ts index 4f66c8aea605..4f358dd7f9a6 100644 --- a/web-console/e2e-tests/util/playwright.ts +++ b/web-console/e2e-tests/util/playwright.ts @@ -107,7 +107,10 @@ export async function selectSuggestibleInput( export async function setQueryInput(page: Page, value: string): Promise { // The query input is a CodeMirror editor, its editable surface is a contenteditable div - await page.locator('.flexible-query-input .cm-content').fill(value); + const input = page.locator('.flexible-query-input .cm-content'); + await input.fill(value); + // Closes the autocomplete that typing opens, which would cover the query (and its results) + await input.press('Escape'); } export function button(scope: Page | Locator, text: string): Locator { diff --git a/web-console/e2e-tests/util/steps.ts b/web-console/e2e-tests/util/steps.ts new file mode 100644 index 000000000000..b074e46f7356 --- /dev/null +++ b/web-console/e2e-tests/util/steps.ts @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from '@playwright/test'; +import { test } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const SHOW_STEPS = process.env['E2E_SHOW_STEPS'] === 'true'; +const STEPS_DIR = path.join(__dirname, '..', 'steps'); + +// The number of the last step shown, by spec (the counting starts over in each run) +const lastStepBySpec = new Map(); + +/** + * Marks a step of the test worth seeing: with E2E_SHOW_STEPS=true, it saves a screenshot of the page to + * e2e-tests/steps/-.png (like s3-ingestion-001.png), so that the steps a test takes can be seen at a + * glance. Otherwise it does nothing. + * @param title what the page shows at this step, which is how the step is named in the trace and the report + */ +export async function showStep(page: Page, title: string): Promise { + if (!SHOW_STEPS) return; + + const spec = path.basename(test.info().file).replace(/\.spec\.ts$/, ''); + const step = (lastStepBySpec.get(spec) ?? 0) + 1; + if (step === 1) deleteSteps(spec); // of an earlier run, which could have had more steps + lastStepBySpec.set(spec, step); + + await test.step(`Step ${step}: ${title}`, async () => { + await page.screenshot({ + path: path.join(STEPS_DIR, `${spec}-${String(step).padStart(3, '0')}.png`), + animations: 'disabled', // finishes the transitions (like a dialog fading in) rather than catch them midway + }); + }); +} + +function deleteSteps(spec: string): void { + if (!fs.existsSync(STEPS_DIR)) return; + for (const file of fs.readdirSync(STEPS_DIR)) { + if (file.startsWith(`${spec}-`) && /^-\d+\.png$/.test(file.slice(spec.length))) { + fs.unlinkSync(path.join(STEPS_DIR, file)); + } + } +} From 545ec78cd8c00a1ce4f87eff4f7dcb63d73deace Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 18:14:58 -0700 Subject: [PATCH 17/26] list the console e2e coverage to add in the plan Co-Authored-By: Claude Opus 5.5 --- web-console/E2E_BETTERMENT_PLAN.md | 67 ++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index 229059f94944..972cdb921f49 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -87,10 +87,69 @@ modernize them, in order. Each step should leave the suite passing. - Done earlier, in steps 1-2: no `goto` + `reload({ waitUntil: 'networkidle' })`, one `expect` in `cancel-query`, no `--disable-local-storage`. -## 7. More coverage (later, separately) - -- SQL-based ingestion (the SQL data loader, `INSERT` / `REPLACE` in the Query view), Explore, Supervisors, - Segments, Services, Lookups. +## 7. More coverage: what the embedded tests cover that the console can do, without a console e2e test + +In the order to do them: (1) input formats, (2) Kafka and the Supervisors view, (3) SQL-based ingestion, (4) the +Datasources view's destructive actions, (5) retention rules and lookups; then the rest. Each goes in a +`*WebConsoleTest` next to the embedded tests of its functionality. + +Classic data loader, connectors: +- [ ] HTTP (`indexer/ITHttpInputSourceTest`): "HTTP(s)" card +- [ ] Inline (`indexing/IndexTaskTest`): "Paste data" card +- [ ] Azure (`azure/ITAzureToAzureParallelIndexTest`, `ITAzureV2ParallelIndexTest`): "Azure" card +- [ ] Google Cloud Storage (`gcs/ITGcsToGcsParallelIndexTest`): "Google Cloud Storage" card +- [ ] HDFS (`hdfs/HdfsToHdfsParallelIndexTest`): "HDFS" card +- [ ] Delta Lake (`deltalake/DeltaLakeInputSourceIngestionTest`): "Delta Lake" card + +Classic data loader, formats and steps: +- [ ] (1) CSV, TSV, Parquet, ORC, Avro OCF (`indexer/ITLocalInputSourceAllInputFormatTest`, + `ITLocalInputSourceAllFormatSchemalessTest`): Parse data step (only JSON is covered) +- [ ] Transforms (`indexer/ITTransformTest`): Transform step +- [ ] Nested columns (`indexing/NestedDataFormatsTest`): Configure schema step +- [ ] Overwrite, with and without dropping existing data (`indexer/ITOverwriteBatchIndexTest`): Publish step + +Streaming and the Supervisors view: +- [ ] (2) Kafka supervisor (`IngestionSmokeTest.test_runKafkaSupervisor`, `kafka/simulate/EmbeddedKafkaSupervisorTest`): + "Apache Kafka" card +- [ ] Kafka formats: Avro and Protobuf with or without a schema registry, CSV... (`indexing/KafkaIndexDataFormatsTest`) +- [ ] Kinesis supervisor and formats (`kinesis/KinesisDataFormatsTest`): "Amazon Kinesis" card +- [ ] (2) Suspend / resume (`KafkaIndexFaultToleranceTest`, `KinesisFaultToleranceTest`), handoff early + (`StreamIndexFaultToleranceTest`), reset to latest and backfill (`KafkaBoundedSupervisorTest`), terminate + (`server/KillSupervisorsCustomDutyTest`): Supervisors view actions +- [ ] Editing a running supervisor (`IngestionSmokeTest.test_kafkaSupervisor_modifiedAndRestartedCombinations`) + +SQL-based ingestion (MSQ) and the Query view: +- [ ] (3) `INSERT` / `REPLACE` from external data (`msq/ITSQLBasedBatchIngestionTest`, `MultiStageQueryTest`, + `IngestionSmokeTest.test_ingestWikipedia1DayWithMSQ`): Query view with MSQ, "Batch - SQL" data loader +- [ ] (3) Reindex with `REPLACE ... SELECT FROM` a datasource (`msq/ITMSQReindexTest`) +- [ ] Export, `INSERT INTO EXTERN` (`MultiStageQueryTest.testExport`) +- [ ] SQL ingestion / MSQ `SELECT` from S3 (`s3/ITS3SQLBasedIngestionTest`, `msq/S3ExternQueryTest`) +- [ ] Dart: running and recent queries, reports, cancel (`msq/EmbeddedDartReportApiTest`): Dart engine, current + Dart queries panel +- [ ] Query errors: parse, validation, timeout, capacity, resource limits (`query/QueryErrorTest`): error pane +- [ ] Query blocklist, default query context (`server/EmbeddedBrokerDynamicConfigTest`): Broker dynamic config dialog + +Datasources, Segments and Tasks views: +- [ ] (4) Mark segments unused / used (`IngestionSmokeTest`, `OverlordClientTest`, `ConcurrentAppendReplaceTest`) +- [ ] (4) Delete data with a kill task (`IngestionSmokeTest.test_runIndexTask_andKillData`, + `OverlordClientTest.test_runKillTask`) +- [ ] (5) Retention rules (`query/BroadcastJoinQueryTest`, `server/CoordinatorClientTest`) +- [ ] Compaction supervisors and cluster compaction config (`compact/CompactionSupervisorTest`) +- [ ] Compaction to another segment granularity (`CompactionSupervisorTest`, `CompactionTaskTest`) +- [ ] Cancel a running task (`OverlordClientTest.test_cancelTask_*`): Tasks view "Kill" +- [ ] Stream a task's log (`IngestionSmokeTest.test_streamLogs_ofCancelledTask`) +- [ ] Submit a JSON task (`OverlordClientTest.test_runTask_ofTypeNoop`) +- [ ] Segment counts (`query/SystemTableQueryTest`): Segments view + +Services, Lookups, dynamic configs: +- [ ] Server types, workers and capacity (`SystemTableQueryTest`, `OverlordClientTest.test_getWorkers`): Services view +- [ ] (5) JDBC lookup (`lookup/JdbcLookupTest`): Lookups view +- [ ] Pause coordination (`server/CoordinatorPauseTest`), turbo loading (`server/HistoricalCloningTest`): Coordinator + dynamic config dialog + +Left out: no console UI (SQL, combining and Iceberg input sources, catalog DDL, JDBC queries, Kafka topic +patterns), not a console user's concern (TLS, HA, Consul, Kubernetes, metadata stores, emitters, autoscaling, +partial loading, faults, performance). Borderline: basic auth, what a restricted user sees (with `auth/`). ## 8. Run on embedded clusters (prototype done) From 641c6ce32efdc5587c1d6554c973659bfed68f70 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 18:17:51 -0700 Subject: [PATCH 18/26] add a web console e2e test of the data loader's input formats input-formats.spec.ts loads CSV, TSV, Parquet, ORC and Avro OCF files through the data loader, checking the input format it picks and the columns it parses, run by InputFormatsWebConsoleTest (with the Avro, Parquet and ORC extensions) next to ITLocalInputSourceAllInputFormatTest. Co-Authored-By: Claude Opus 5.5 --- .../indexer/InputFormatsWebConsoleTest.java | 52 ++++++++++++ web-console/E2E_BETTERMENT_PLAN.md | 5 +- web-console/e2e-tests/README.md | 2 + .../component/load-data/data-loader.ts | 21 ++++- web-console/e2e-tests/input-formats.spec.ts | 82 +++++++++++++++++++ 5 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexer/InputFormatsWebConsoleTest.java create mode 100644 web-console/e2e-tests/input-formats.spec.ts diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexer/InputFormatsWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexer/InputFormatsWebConsoleTest.java new file mode 100644 index 000000000000..1fe4c5f4a9b8 --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexer/InputFormatsWebConsoleTest.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.indexer; + +import org.apache.druid.data.input.avro.AvroExtensionsModule; +import org.apache.druid.data.input.orc.OrcExtensionsModule; +import org.apache.druid.data.input.parquet.ParquetExtensionsModule; +import org.apache.druid.testing.embedded.EmbeddedDruidCluster; +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Map; + +/** + * Loads the data files of {@link ITLocalInputSourceAllInputFormatTest} (CSV, TSV, Parquet, ORC and Avro OCF) through + * the data loader of the web console, checking the input format it picks for each. + */ +public class InputFormatsWebConsoleTest extends WebConsoleTestBase +{ + @Override + protected void configureCluster(EmbeddedDruidCluster cluster) + { + cluster.addExtensions(AvroExtensionsModule.class, ParquetExtensionsModule.class, OrcExtensionsModule.class); + } + + @Test + public void testInputFormats() throws Exception + { + runSpec( + "input-formats.spec.ts", + Map.of("DRUID_E2E_TEST_DATA_DIR", new File("src/test/resources/data").getCanonicalPath()) + ); + } +} diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index 972cdb921f49..7e55a6972ae3 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -102,8 +102,9 @@ Classic data loader, connectors: - [ ] Delta Lake (`deltalake/DeltaLakeInputSourceIngestionTest`): "Delta Lake" card Classic data loader, formats and steps: -- [ ] (1) CSV, TSV, Parquet, ORC, Avro OCF (`indexer/ITLocalInputSourceAllInputFormatTest`, - `ITLocalInputSourceAllFormatSchemalessTest`): Parse data step (only JSON is covered) +- [x] (1) CSV, TSV, Parquet, ORC, Avro OCF (`indexer/ITLocalInputSourceAllInputFormatTest`, + `ITLocalInputSourceAllFormatSchemalessTest`): Parse data step. Done: `input-formats.spec.ts`, run by + `indexer/InputFormatsWebConsoleTest` - [ ] Transforms (`indexer/ITTransformTest`): Transform step - [ ] Nested columns (`indexing/NestedDataFormatsTest`): Configure schema step - [ ] Overwrite, with and without dropping existing data (`indexer/ITOverwriteBatchIndexTest`): Publish step diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index 65bca0d63025..958597855c7f 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -70,6 +70,8 @@ the spec needs, and runs the spec on it with `npx playwright test `: | `multi-stage-query.spec.ts` | `msq.MultiStageQueryWebConsoleTest` | | `cancel-query.spec.ts` | `query.SqlQueryCancelWebConsoleTest` | | `s3-ingestion.spec.ts` | `s3.S3WebConsoleTest` (with an S3 container) | +| `input-formats.spec.ts` | `indexer.InputFormatsWebConsoleTest` | +| `input-formats.spec.ts` | Loads the same 10 rows (over 2 days) of Wikipedia edits from local CSV, TSV, Parquet, ORC and Avro OCF files through the data loader, one test per format. Checks that the **Parse data** step picks the right input format by itself and parses the columns, and that the datasource ends up as 2 segments with 10 rows. Skipped without `DRUID_E2E_TEST_DATA_DIR` (the data files of embedded-tests): it runs on an embedded cluster with the Avro, Parquet and ORC extensions, from `InputFormatsWebConsoleTest`. | They extend `console.WebConsoleTestBase`. There's no distribution to build and nothing left behind, as every test class gets a new cluster. They need this checkout's Druid modules installed (`mvn install -DskipTests`), with the diff --git a/web-console/e2e-tests/component/load-data/data-loader.ts b/web-console/e2e-tests/component/load-data/data-loader.ts index 64f3c1c3afab..b2646132bf7e 100644 --- a/web-console/e2e-tests/component/load-data/data-loader.ts +++ b/web-console/e2e-tests/component/load-data/data-loader.ts @@ -18,7 +18,13 @@ import type { Page } from '@playwright/test'; -import { clickButton, openView, setLabeledInput, setLabeledTextarea } from '../../util/playwright'; +import { + clickButton, + getLabeledInput, + openView, + setLabeledInput, + setLabeledTextarea, +} from '../../util/playwright'; import { showStep } from '../../util/steps'; import type { DataConnector } from './data-connector'; @@ -34,6 +40,9 @@ export interface DataLoaderConfig { readonly connector: DataConnector; /** Checks the raw lines of the preview */ readonly validateConnect: (previewLines: string[]) => void; + // Parse data (when the connector's data needs parsing) + /** Checks the input format that the data loader picked and the columns it parsed */ + readonly validateParseData?: (parsed: { inputFormat: string; columns: string[] }) => void; // Parse time (when the connector's data needs parsing) readonly timestampExpression?: string; // Configure schema @@ -72,7 +81,15 @@ export async function loadData(page: Page, config: DataLoaderConfig): Promise { + test.skip( + !DATA_DIR, + 'needs DRUID_E2E_TEST_DATA_DIR (run by InputFormatsWebConsoleTest in embedded-tests)', + ); + + for (const format of FORMATS) { + test(`Loads ${format.inputFormat} data`, async ({ page, request, newDatasourceName }) => { + const datasourceName = newDatasourceName(`input-format-${format.inputFormat}`); + await loadData(page, { + connector: { + type: 'local', + baseDirectory: path.join(DATA_DIR!, format.dir), + fileFilter: format.file, + }, + validateConnect: lines => { + // The text formats show their lines (a header and the rows of each file), the others their bytes + if (format.text) { + expect(lines).toHaveLength(13); + expect(lines[0]).toMatch(/^timestamp.page.language.user/); + } else { + expect(lines.length).toBeGreaterThan(0); + } + }, + validateParseData: ({ inputFormat, columns }) => { + // Picked by the data loader, from the sampled data + expect(inputFormat).toBe(format.inputFormat); + expect(columns).toEqual( + expect.arrayContaining(['timestamp', 'page', 'user', 'added', 'deleted', 'delta']), + ); + }, + rollup: false, + segmentGranularity: 'day', + datasourceName, + }); + + await expect + .poll(() => getTaskStatuses(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual(['SUCCESS']); + await expect + .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual({ numSegments: 2, numAvailableSegments: 2, numRows: 10 }); + }); + } +}); From 3e55e767c80d2728f346fb7b4139985e1678010e Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 18:32:00 -0700 Subject: [PATCH 19/26] add a web console e2e test of Kafka ingestion and the Supervisors view kafka-ingestion.spec.ts sets up a Kafka supervisor through the data loader (as in the Kafka tutorial), then suspends, resumes and terminates it from the Supervisors view, run by KafkaWebConsoleTest with a Kafka container next to the other Kafka embedded tests. Co-Authored-By: Claude Opus 5.5 --- .../indexing/KafkaWebConsoleTest.java | 88 +++++++++++++ web-console/E2E_BETTERMENT_PLAN.md | 15 +-- web-console/e2e-tests/README.md | 7 +- .../component/load-data/data-connector.ts | 33 ++++- .../component/load-data/data-loader.ts | 16 ++- .../component/supervisors/overview.ts | 88 +++++++++++++ web-console/e2e-tests/kafka-ingestion.spec.ts | 118 ++++++++++++++++++ web-console/e2e-tests/util/playwright.ts | 13 ++ web-console/e2e-tests/util/sql.ts | 37 ++++++ 9 files changed, 403 insertions(+), 12 deletions(-) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/KafkaWebConsoleTest.java create mode 100644 web-console/e2e-tests/component/supervisors/overview.ts create mode 100644 web-console/e2e-tests/kafka-ingestion.spec.ts diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/KafkaWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/KafkaWebConsoleTest.java new file mode 100644 index 000000000000..49e6e2c2addf --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/KafkaWebConsoleTest.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.indexing; + +import org.apache.druid.indexing.kafka.simulate.KafkaResource; +import org.apache.druid.java.util.common.StringUtils; +import org.apache.druid.testing.embedded.EmbeddedDruidCluster; +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +/** + * Sets up a Kafka supervisor through the data loader of the web console, then suspends, resumes and terminates it + * from the Supervisors view. + */ +public class KafkaWebConsoleTest extends WebConsoleTestBase +{ + private static final String TOPIC = "wikipedia"; + + private final KafkaResource kafka = new KafkaResource(); + + @Override + protected void addResources(EmbeddedDruidCluster cluster) + { + cluster.addResource(kafka); + } + + /** + * Publishes the edits of the tutorial file to the topic, one per message, in one partition (so in the order of the + * file), as in the Kafka tutorial. + */ + @BeforeAll + public void publishData() throws Exception + { + final File dataFile = new File(webConsoleDir(), "../examples/quickstart/tutorial/wikiticker-2015-09-12-sampled.json.gz"); + final List records = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(new GZIPInputStream(new FileInputStream(dataFile)), StandardCharsets.UTF_8) + )) { + String line; + while ((line = reader.readLine()) != null) { + records.add(StringUtils.toUtf8(line)); + } + } + + kafka.createTopicWithPartitions(TOPIC, 1); + kafka.publishRecordsToTopic(TOPIC, records); + } + + @Test + public void testKafkaIngestion() throws Exception + { + runSpec( + "kafka-ingestion.spec.ts", + Map.of( + "DRUID_E2E_TEST_KAFKA_BOOTSTRAP_SERVERS", kafka.getBootstrapServerUrl(), + "DRUID_E2E_TEST_KAFKA_TOPIC", TOPIC + ) + ); + } +} diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index 7e55a6972ae3..caefd8de4520 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -110,13 +110,15 @@ Classic data loader, formats and steps: - [ ] Overwrite, with and without dropping existing data (`indexer/ITOverwriteBatchIndexTest`): Publish step Streaming and the Supervisors view: -- [ ] (2) Kafka supervisor (`IngestionSmokeTest.test_runKafkaSupervisor`, `kafka/simulate/EmbeddedKafkaSupervisorTest`): - "Apache Kafka" card +- [x] (2) Kafka supervisor (`IngestionSmokeTest.test_runKafkaSupervisor`, `kafka/simulate/EmbeddedKafkaSupervisorTest`): + "Apache Kafka" card. Done: `kafka-ingestion.spec.ts`, run by `indexing/KafkaWebConsoleTest` - [ ] Kafka formats: Avro and Protobuf with or without a schema registry, CSV... (`indexing/KafkaIndexDataFormatsTest`) - [ ] Kinesis supervisor and formats (`kinesis/KinesisDataFormatsTest`): "Amazon Kinesis" card -- [ ] (2) Suspend / resume (`KafkaIndexFaultToleranceTest`, `KinesisFaultToleranceTest`), handoff early - (`StreamIndexFaultToleranceTest`), reset to latest and backfill (`KafkaBoundedSupervisorTest`), terminate - (`server/KillSupervisorsCustomDutyTest`): Supervisors view actions +- [x] (2) Suspend / resume (`KafkaIndexFaultToleranceTest`, `KinesisFaultToleranceTest`), terminate + (`server/KillSupervisorsCustomDutyTest`): Supervisors view actions. Done: in `kafka-ingestion.spec.ts` +- [ ] Handoff early (`StreamIndexFaultToleranceTest`), reset to latest and backfill (`KafkaBoundedSupervisorTest`): + Supervisors view actions (`SupervisorsOverview.runAction` has them, or is a line away) +- [ ] Several topics through a topic pattern (`server/KafkaMultiSupervisorTest`): Kafka connector's "Topic pattern" - [ ] Editing a running supervisor (`IngestionSmokeTest.test_kafkaSupervisor_modifiedAndRestartedCombinations`) SQL-based ingestion (MSQ) and the Query view: @@ -148,8 +150,7 @@ Services, Lookups, dynamic configs: - [ ] Pause coordination (`server/CoordinatorPauseTest`), turbo loading (`server/HistoricalCloningTest`): Coordinator dynamic config dialog -Left out: no console UI (SQL, combining and Iceberg input sources, catalog DDL, JDBC queries, Kafka topic -patterns), not a console user's concern (TLS, HA, Consul, Kubernetes, metadata stores, emitters, autoscaling, +Left out: no console UI (SQL, combining and Iceberg input sources, catalog DDL, JDBC queries), not a console user's concern (TLS, HA, Consul, Kubernetes, metadata stores, emitters, autoscaling, partial loading, faults, performance). Borderline: basic auth, what a restricted user sees (with `auth/`). ## 8. Run on embedded clusters (prototype done) diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index 958597855c7f..d73130cab516 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -71,7 +71,9 @@ the spec needs, and runs the spec on it with `npx playwright test `: | `cancel-query.spec.ts` | `query.SqlQueryCancelWebConsoleTest` | | `s3-ingestion.spec.ts` | `s3.S3WebConsoleTest` (with an S3 container) | | `input-formats.spec.ts` | `indexer.InputFormatsWebConsoleTest` | +| `kafka-ingestion.spec.ts` | `indexing.KafkaWebConsoleTest` (with Kafka) | | `input-formats.spec.ts` | Loads the same 10 rows (over 2 days) of Wikipedia edits from local CSV, TSV, Parquet, ORC and Avro OCF files through the data loader, one test per format. Checks that the **Parse data** step picks the right input format by itself and parses the columns, and that the datasource ends up as 2 segments with 10 rows. Skipped without `DRUID_E2E_TEST_DATA_DIR` (the data files of embedded-tests): it runs on an embedded cluster with the Avro, Parquet and ORC extensions, from `InputFormatsWebConsoleTest`. | +| `kafka-ingestion.spec.ts` | Follows the [Kafka tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-kafka): sets up a supervisor through the data loader's **Apache Kafka** connector on a topic with the 39,244 edits of the tutorial file (reading it from the earliest offset), and waits for the rows to be queryable. Then, from the **Supervisors** view, suspends the supervisor, resumes it and terminates it, checking its state each time (through SQL on `sys.supervisors` and in the view). Skipped without `DRUID_E2E_TEST_KAFKA_BOOTSTRAP_SERVERS` and `DRUID_E2E_TEST_KAFKA_TOPIC`: it runs on an embedded cluster with a Kafka container, from `KafkaWebConsoleTest`. | They extend `console.WebConsoleTestBase`. There's no distribution to build and nothing left behind, as every test class gets a new cluster. They need this checkout's Druid modules installed (`mvn install -DskipTests`), with the @@ -128,7 +130,9 @@ e2e-tests/ datasources/ Datasources view (reads the table, edits and triggers compaction) ingestion/ Tasks view (reads the table) load-data/ the classic data loader: loadData goes through each step with a DataLoaderConfig - (data-connector.ts: local disk, reindex from Druid; partitions-spec.ts: hashed, range) + (data-connector.ts: local disk, S3, Kafka, reindex from Druid; partitions-spec.ts: hashed, + range) + supervisors/ Supervisors view (reads the table, runs and confirms a supervisor's actions) workbench/ Query view: run a query (accepting the task slot warning), cancel a query util/ fixtures.ts the `test` and `expect` to import in specs (`test` logs failed responses, shows the last @@ -137,6 +141,7 @@ e2e-tests/ druid.ts the tutorial data dir and ingestion specs, runTask (submits a task and waits for it), deleteDatasource sql.ts querySql, and the cluster state the tests wait for: task statuses, a datasource's segments + and queryable rows, a supervisor's state playwright.ts openView, and helpers that find inputs and buttons by their label or text table.ts extractTable / extractTableRecords: read a table's rows as text (by position / by header) steps.ts showStep: a screenshot of a step of the test, with E2E_SHOW_STEPS=true diff --git a/web-console/e2e-tests/component/load-data/data-connector.ts b/web-console/e2e-tests/component/load-data/data-connector.ts index e85ec4e7fe32..0e862378f4ce 100644 --- a/web-console/e2e-tests/component/load-data/data-connector.ts +++ b/web-console/e2e-tests/component/load-data/data-connector.ts @@ -18,7 +18,12 @@ import type { Page } from '@playwright/test'; -import { clickButton, setLabeledInput, setLabeledTextarea } from '../../util/playwright'; +import { + clickButton, + setLabeledBoolean, + setLabeledInput, + setLabeledTextarea, +} from '../../util/playwright'; /** * Where the data loader reads the data from. @@ -26,6 +31,7 @@ import { clickButton, setLabeledInput, setLabeledTextarea } from '../../util/pla export type DataConnector = | { readonly type: 'local'; readonly baseDirectory: string; readonly fileFilter: string } | { readonly type: 's3'; readonly uris: string[] } + | { readonly type: 'kafka'; readonly bootstrapServers: string; readonly topic: string } | { readonly type: 'reindex'; readonly datasourceName: string; readonly interval: string }; /** @@ -37,6 +43,8 @@ export function connectorCardTitle(connector: DataConnector): string { return 'Local disk'; case 's3': return 'Amazon S3'; + case 'kafka': + return 'Apache Kafka'; case 'reindex': return 'Reindex from Druid'; } @@ -49,6 +57,13 @@ export function connectorNeedsParse(connector: DataConnector): boolean { return connector.type !== 'reindex'; } +/** + * Whether the connector reads a stream, so the data loader sets up a supervisor (rather than run a task). + */ +export function connectorIsStreaming(connector: DataConnector): boolean { + return connector.type === 'kafka'; +} + /** * Fills in the Connect step and applies it. */ @@ -63,6 +78,12 @@ export async function connect(page: Page, connector: DataConnector): Promise { + if (connector.type === 'kafka') { + // Required for streaming, as there's no default: read the topic from its start (the data the test put in it) + await setLabeledBoolean(page, 'Use earliest offset', true); + } +} diff --git a/web-console/e2e-tests/component/load-data/data-loader.ts b/web-console/e2e-tests/component/load-data/data-loader.ts index b2646132bf7e..5dc1ca452f03 100644 --- a/web-console/e2e-tests/component/load-data/data-loader.ts +++ b/web-console/e2e-tests/component/load-data/data-loader.ts @@ -28,7 +28,13 @@ import { import { showStep } from '../../util/steps'; import type { DataConnector } from './data-connector'; -import { connect, connectorCardTitle, connectorNeedsParse } from './data-connector'; +import { + connect, + connectorCardTitle, + connectorIsStreaming, + connectorNeedsParse, + tune, +} from './data-connector'; import type { PartitionsSpec } from './partitions-spec'; import { applyPartitionsSpec } from './partitions-spec'; @@ -56,7 +62,7 @@ export interface DataLoaderConfig { } /** - * Goes through each step of the data loader and submits the task. + * Goes through each step of the data loader and submits the task (or the supervisor, for a stream). */ export async function loadData(page: Page, config: DataLoaderConfig): Promise { const nextBar = page.locator('.next-bar'); @@ -134,6 +140,7 @@ export async function loadData(page: Page, config: DataLoaderConfig): Promise { diff --git a/web-console/e2e-tests/component/supervisors/overview.ts b/web-console/e2e-tests/component/supervisors/overview.ts new file mode 100644 index 000000000000..58deb7cedda4 --- /dev/null +++ b/web-console/e2e-tests/component/supervisors/overview.ts @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from '@playwright/test'; + +import { clickButton, clickMenuItem, openView } from '../../util/playwright'; +import { showStep } from '../../util/steps'; +import { extractTableRecords } from '../../util/table'; + +/** + * A row of the Supervisors view. + */ +export interface Supervisor { + readonly id: string; + readonly datasource: string; + readonly status: string; +} + +/** + * An action of a supervisor's menu that asks for confirmation, with the text of its confirm button. + */ +const CONFIRMED_ACTIONS = { + 'Suspend': 'Suspend supervisor', + 'Resume': 'Resume supervisor', + 'Hard reset': 'Hard reset supervisor', + 'Terminate': 'Terminate supervisor', +} as const; + +export type SupervisorAction = keyof typeof CONFIRMED_ACTIONS; + +/** + * Represents the Supervisors view. + */ +export class SupervisorsOverview { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + private readonly table = () => this.page.locator('.supervisors-view .console-table'); + + /** + * The supervisor `supervisorId` (none, if it doesn't exist), as the view shows it. + */ + async getSupervisors(supervisorId: string): Promise { + await openView(this.page, 'supervisors', { supervisor_id: supervisorId }); + + const records = await extractTableRecords(this.table()); + await showStep(this.page, `Supervisors view, filtered on ${supervisorId}`); + + return records.map(record => ({ + id: record['Supervisor ID'], + datasource: record['Datasource'], + status: record['Status'], + })); + } + + /** + * Runs an action from the supervisor's menu, and confirms it. + */ + async runAction(supervisorId: string, action: SupervisorAction): Promise { + await openView(this.page, 'supervisors', { supervisor_id: supervisorId }); + await this.table().locator('.ct-tbody .action-cell .bp6-icon-more').click(); + await clickMenuItem(this.page, action); + + const confirmation = this.page.locator('.bp6-alert'); + await confirmation.waitFor(); + await showStep(this.page, `Supervisors view: ${action}, to confirm`); + await clickButton(confirmation, CONFIRMED_ACTIONS[action]); + await confirmation.waitFor({ state: 'detached' }); + } +} diff --git a/web-console/e2e-tests/kafka-ingestion.spec.ts b/web-console/e2e-tests/kafka-ingestion.spec.ts new file mode 100644 index 000000000000..d1ef502d3e77 --- /dev/null +++ b/web-console/e2e-tests/kafka-ingestion.spec.ts @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { APIRequestContext, Page } from '@playwright/test'; + +import { loadData } from './component/load-data/data-loader'; +import type { SupervisorAction } from './component/supervisors/overview'; +import { SupervisorsOverview } from './component/supervisors/overview'; +import { expect, test } from './util/fixtures'; +import { CLUSTER_STATE_POLL, getQueryableRowCount, getSupervisorState } from './util/sql'; + +// A Kafka topic with the 39,244 Wikipedia edits of wikiticker-2015-09-12-sampled.json.gz (of +// examples/quickstart/tutorial), one per message, in one partition. KafkaWebConsoleTest (in embedded-tests) sets it +// up, with a Kafka container. +const BOOTSTRAP_SERVERS = process.env['DRUID_E2E_TEST_KAFKA_BOOTSTRAP_SERVERS']; +const TOPIC = process.env['DRUID_E2E_TEST_KAFKA_TOPIC']; + +const NUM_ROWS = 39244; + +// The workflow in this test is based on the Kafka tutorial: +// https://druid.apache.org/docs/latest/tutorials/tutorial-kafka +test.describe('Kafka ingestion', () => { + test.skip( + !BOOTSTRAP_SERVERS || !TOPIC, + 'needs DRUID_E2E_TEST_KAFKA_BOOTSTRAP_SERVERS and DRUID_E2E_TEST_KAFKA_TOPIC (run by KafkaWebConsoleTest in embedded-tests)', + ); + + test('Loads data from Kafka, then suspends, resumes and terminates the supervisor', async ({ + page, + request, + newDatasourceName, + }) => { + // The supervisor is named after the datasource + const datasourceName = newDatasourceName('load-data-from-kafka'); + await loadData(page, { + connector: { type: 'kafka', bootstrapServers: BOOTSTRAP_SERVERS!, topic: TOPIC! }, + validateConnect: lines => { + expect(lines.length).toBeGreaterThan(0); + // Each message is shown after its Kafka metadata ([ Kafka timestamp: ... ]) + expect(lines[0]).toContain('{"time":"2015-09-12T00:46:58.771Z","channel":"#en.wikipedia",'); + }, + rollup: false, + segmentGranularity: 'day', + datasourceName, + }); + + await expectSupervisorState(request, datasourceName, 'RUNNING'); + await expect + .poll(() => getQueryableRowCount(request, datasourceName), CLUSTER_STATE_POLL) + .toBe(NUM_ROWS); + + const supervisorsOverview = new SupervisorsOverview(page); + await expectSupervisorInView(supervisorsOverview, datasourceName, 'RUNNING'); + + await runSupervisorAction(page, request, datasourceName, 'Suspend', 'SUSPENDED'); + await runSupervisorAction(page, request, datasourceName, 'Resume', 'RUNNING'); + + await supervisorsOverview.runAction(datasourceName, 'Terminate'); + await expectSupervisorState(request, datasourceName, null); + expect(await supervisorsOverview.getSupervisors(datasourceName)).toEqual([]); + + // Terminating the supervisor publishes what its tasks ingested + await expect + .poll(() => getQueryableRowCount(request, datasourceName), CLUSTER_STATE_POLL) + .toBe(NUM_ROWS); + }); +}); + +async function runSupervisorAction( + page: Page, + request: APIRequestContext, + supervisorId: string, + action: SupervisorAction, + expectedState: string, +) { + const supervisorsOverview = new SupervisorsOverview(page); + await supervisorsOverview.runAction(supervisorId, action); + await expectSupervisorState(request, supervisorId, expectedState); + await expectSupervisorInView(supervisorsOverview, supervisorId, expectedState); +} + +async function expectSupervisorState( + request: APIRequestContext, + supervisorId: string, + state: string | null, +) { + await expect + .poll(() => getSupervisorState(request, supervisorId), CLUSTER_STATE_POLL) + .toBe(state); +} + +async function expectSupervisorInView( + supervisorsOverview: SupervisorsOverview, + supervisorId: string, + status: string, +) { + // Retried in case the view is a step behind the cluster state polled above + await expect(async () => { + const supervisors = await supervisorsOverview.getSupervisors(supervisorId); + expect(supervisors).toHaveLength(1); + expect(supervisors[0].status).toContain(status); + }).toPass({ timeout: 30 * 1000 }); +} diff --git a/web-console/e2e-tests/util/playwright.ts b/web-console/e2e-tests/util/playwright.ts index 4f358dd7f9a6..06ab8beb6dab 100644 --- a/web-console/e2e-tests/util/playwright.ts +++ b/web-console/e2e-tests/util/playwright.ts @@ -69,6 +69,19 @@ export function labeledTextarea(scope: Page | Locator, label: string): Locator { return formGroup(scope, label).locator('textarea'); } +/** + * Sets a labeled boolean field of an AutoForm (its False / True buttons). + */ +export async function setLabeledBoolean( + scope: Page | Locator, + label: string, + value: boolean, +): Promise { + await formGroup(scope, label) + .getByText(value ? 'True' : 'False', { exact: true }) + .click(); +} + export async function getLabeledInput(scope: Page | Locator, label: string): Promise { return await labeledInput(scope, label).inputValue(); } diff --git a/web-console/e2e-tests/util/sql.ts b/web-console/e2e-tests/util/sql.ts index cb6e7ea1d1e4..5e760d28b6c7 100644 --- a/web-console/e2e-tests/util/sql.ts +++ b/web-console/e2e-tests/util/sql.ts @@ -17,6 +17,7 @@ */ import type { APIRequestContext } from '@playwright/test'; +import { T } from 'druid-query-toolkit'; /** * For expect.poll() on the cluster state: a task finishing, segments loading. @@ -92,3 +93,39 @@ WHERE "datasource" = ? AND "is_published" = 1 AND "is_overshadowed" = 0`, numRows: row['numRows'] == null ? null : Number(row['numRows']), }; } + +/** + * The number of rows of a datasource that queries see, including the rows of realtime (streaming) tasks that are not + * in published segments yet. + */ +export async function getQueryableRowCount( + request: APIRequestContext, + datasource: string, +): Promise { + // Through INFORMATION_SCHEMA first, as querying a datasource that doesn't exist yet is an error + const tables = await querySql( + request, + `SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE "TABLE_SCHEMA" = 'druid' AND "TABLE_NAME" = ?`, + [datasource], + ); + if (!tables.length) return 0; + + const [row] = await querySql(request, `SELECT COUNT(*) AS "numRows" FROM ${T(datasource)}`); + return Number(row['numRows']); +} + +/** + * The detailed state of a supervisor (like RUNNING or SUSPENDED), or null when there is no such supervisor (not yet, + * or not anymore as it was terminated). + */ +export async function getSupervisorState( + request: APIRequestContext, + supervisorId: string, +): Promise { + const [row] = await querySql( + request, + `SELECT "detailed_state" FROM sys.supervisors WHERE "supervisor_id" = ?`, + [supervisorId], + ); + return row ? String(row['detailed_state']) : null; +} From 5d4097cea0a1f04d303ef1c57ce31b76b3e1693b Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 18:52:21 -0700 Subject: [PATCH 20/26] add a web console e2e test of SQL-based ingestion sql-ingestion.spec.ts loads data through the SQL data loader, then ingests and reindexes with REPLACE in the Query view, run by SqlIngestionWebConsoleTest next to the MSQ ingestion embedded tests. WorkbenchOverview gets runIngestQuery, and waits for the query's own request before its result, as the view shows the last result until then. Co-Authored-By: Claude Opus 5.5 --- .../msq/SqlIngestionWebConsoleTest.java | 36 +++++++ web-console/E2E_BETTERMENT_PLAN.md | 5 +- web-console/e2e-tests/README.md | 7 +- .../component/load-data/data-connector.ts | 12 ++- .../component/load-data/sql-data-loader.ts | 87 +++++++++++++++++ .../e2e-tests/component/workbench/overview.ts | 96 +++++++++++++++---- web-console/e2e-tests/sql-ingestion.spec.ts | 92 ++++++++++++++++++ 7 files changed, 308 insertions(+), 27 deletions(-) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/SqlIngestionWebConsoleTest.java create mode 100644 web-console/e2e-tests/component/load-data/sql-data-loader.ts create mode 100644 web-console/e2e-tests/sql-ingestion.spec.ts diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/SqlIngestionWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/SqlIngestionWebConsoleTest.java new file mode 100644 index 000000000000..3ffc999d8978 --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/SqlIngestionWebConsoleTest.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.msq; + +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; +import org.junit.jupiter.api.Test; + +/** + * Ingests with SQL (MSQ tasks) from the web console: through the SQL data loader, and with REPLACE in the Query view, + * from external data and from a datasource (reindexing). + */ +public class SqlIngestionWebConsoleTest extends WebConsoleTestBase +{ + @Test + public void testSqlIngestion() throws Exception + { + runSpec("sql-ingestion.spec.ts"); + } +} diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index caefd8de4520..75cbc30678e9 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -122,9 +122,10 @@ Streaming and the Supervisors view: - [ ] Editing a running supervisor (`IngestionSmokeTest.test_kafkaSupervisor_modifiedAndRestartedCombinations`) SQL-based ingestion (MSQ) and the Query view: -- [ ] (3) `INSERT` / `REPLACE` from external data (`msq/ITSQLBasedBatchIngestionTest`, `MultiStageQueryTest`, +- [x] (3) `INSERT` / `REPLACE` from external data (`msq/ITSQLBasedBatchIngestionTest`, `MultiStageQueryTest`, `IngestionSmokeTest.test_ingestWikipedia1DayWithMSQ`): Query view with MSQ, "Batch - SQL" data loader -- [ ] (3) Reindex with `REPLACE ... SELECT FROM` a datasource (`msq/ITMSQReindexTest`) +- [x] (3) Reindex with `REPLACE ... SELECT FROM` a datasource (`msq/ITMSQReindexTest`). Done (both): + `sql-ingestion.spec.ts`, run by `msq/SqlIngestionWebConsoleTest` - [ ] Export, `INSERT INTO EXTERN` (`MultiStageQueryTest.testExport`) - [ ] SQL ingestion / MSQ `SELECT` from S3 (`s3/ITS3SQLBasedIngestionTest`, `msq/S3ExternQueryTest`) - [ ] Dart: running and recent queries, reports, cancel (`msq/EmbeddedDartReportApiTest`): Dart engine, current diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index d73130cab516..8fea1d1b913f 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -72,8 +72,10 @@ the spec needs, and runs the spec on it with `npx playwright test `: | `s3-ingestion.spec.ts` | `s3.S3WebConsoleTest` (with an S3 container) | | `input-formats.spec.ts` | `indexer.InputFormatsWebConsoleTest` | | `kafka-ingestion.spec.ts` | `indexing.KafkaWebConsoleTest` (with Kafka) | +| `sql-ingestion.spec.ts` | `msq.SqlIngestionWebConsoleTest` | | `input-formats.spec.ts` | Loads the same 10 rows (over 2 days) of Wikipedia edits from local CSV, TSV, Parquet, ORC and Avro OCF files through the data loader, one test per format. Checks that the **Parse data** step picks the right input format by itself and parses the columns, and that the datasource ends up as 2 segments with 10 rows. Skipped without `DRUID_E2E_TEST_DATA_DIR` (the data files of embedded-tests): it runs on an embedded cluster with the Avro, Parquet and ORC extensions, from `InputFormatsWebConsoleTest`. | | `kafka-ingestion.spec.ts` | Follows the [Kafka tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-kafka): sets up a supervisor through the data loader's **Apache Kafka** connector on a topic with the 39,244 edits of the tutorial file (reading it from the earliest offset), and waits for the rows to be queryable. Then, from the **Supervisors** view, suspends the supervisor, resumes it and terminates it, checking its state each time (through SQL on `sys.supervisors` and in the view). Skipped without `DRUID_E2E_TEST_KAFKA_BOOTSTRAP_SERVERS` and `DRUID_E2E_TEST_KAFKA_TOPIC`: it runs on an embedded cluster with a Kafka container, from `KafkaWebConsoleTest`. | +| `sql-ingestion.spec.ts` | SQL-based ingestion (MSQ tasks). Loads the tutorial file through the **SQL data loader** ("Load data" > "Batch - SQL": input source, parse, schema with a new datasource as the destination, ingestion progress) and waits for its 39,244 rows to be queryable. Then, in the **Query** view, ingests it with `REPLACE ... FROM TABLE(EXTERN(...))` and reindexes the 11,549 rows of `#en.wikipedia` into another datasource with `REPLACE ... FROM` the first, checking what the view says it inserted and the rows queryable each time. | They extend `console.WebConsoleTestBase`. There's no distribution to build and nothing left behind, as every test class gets a new cluster. They need this checkout's Druid modules installed (`mvn install -DskipTests`), with the @@ -131,9 +133,10 @@ e2e-tests/ ingestion/ Tasks view (reads the table) load-data/ the classic data loader: loadData goes through each step with a DataLoaderConfig (data-connector.ts: local disk, S3, Kafka, reindex from Druid; partitions-spec.ts: hashed, - range) + range); sql-data-loader.ts: the SQL data loader (loadDataWithSql) supervisors/ Supervisors view (reads the table, runs and confirms a supervisor's actions) - workbench/ Query view: run a query (accepting the task slot warning), cancel a query + workbench/ Query view: run a query or an ingestion query (accepting the task slot warning), cancel a + query util/ fixtures.ts the `test` and `expect` to import in specs (`test` logs failed responses, shows the last step, and has the newDatasourceName fixture) diff --git a/web-console/e2e-tests/component/load-data/data-connector.ts b/web-console/e2e-tests/component/load-data/data-connector.ts index 0e862378f4ce..44913ff0814c 100644 --- a/web-console/e2e-tests/component/load-data/data-connector.ts +++ b/web-console/e2e-tests/component/load-data/data-connector.ts @@ -65,9 +65,10 @@ export function connectorIsStreaming(connector: DataConnector): boolean { } /** - * Fills in the Connect step and applies it. + * Fills in the fields of the connector (of the classic data loader's Connect step, or of the SQL data loader's input + * source step, which share them). */ -export async function connect(page: Page, connector: DataConnector): Promise { +export async function fillConnector(page: Page, connector: DataConnector): Promise { switch (connector.type) { case 'local': await setLabeledInput(page, 'Base directory', connector.baseDirectory); @@ -89,6 +90,13 @@ export async function connect(page: Page, connector: DataConnector): Promise { + await fillConnector(page, connector); await clickButton(page, 'Apply'); } diff --git a/web-console/e2e-tests/component/load-data/sql-data-loader.ts b/web-console/e2e-tests/component/load-data/sql-data-loader.ts new file mode 100644 index 000000000000..3a25cb83a165 --- /dev/null +++ b/web-console/e2e-tests/component/load-data/sql-data-loader.ts @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from '@playwright/test'; + +import { clickButton, getLabeledInput, openView, setLabeledInput } from '../../util/playwright'; +import { showStep } from '../../util/steps'; +import { waitForTaskSlots } from '../workbench/overview'; + +import type { DataConnector } from './data-connector'; +import { connectorCardTitle, fillConnector } from './data-connector'; + +/** + * What to set at each step of the SQL data loader ("Load data" > "Batch - SQL"), which ingests with an MSQ task. + */ +export interface SqlDataLoaderConfig { + readonly connector: DataConnector; + /** Checks the input format that the data loader picked and the columns it parsed */ + readonly validateParseData?: (parsed: { inputFormat: string; columns: string[] }) => void; + /** The new datasource to create */ + readonly datasourceName: string; +} + +/** + * Goes through each step of the SQL data loader, starts the ingestion and waits for it to finish. Throws if it fails. + */ +export async function loadDataWithSql(page: Page, config: SqlDataLoaderConfig): Promise { + await openView(page, 'sql-data-loader'); + + // Select input type + await page + .locator('.input-source-step .bp6-card') + .filter({ has: page.locator('p', { hasText: connectorCardTitle(config.connector) }) }) + .click(); + await fillConnector(page, config.connector); + await showStep(page, `SQL data loader: ${connectorCardTitle(config.connector)}`); + await clickButton(page, 'Connect data'); + + // Parse + const parseDataTable = page.locator('.input-format-step .parse-data-table'); + await parseDataTable.locator('.column-name').first().waitFor(); + if (config.validateParseData) { + config.validateParseData({ + inputFormat: await getLabeledInput(page, 'Input format'), + columns: await parseDataTable.locator('.column-name').allTextContents(), + }); + } + await showStep(page, 'SQL data loader: Parse'); + await clickButton(page.locator('.input-format-step .prev-next-bar'), 'Next'); + + // Schema, with the destination (a new datasource) + const schemaStep = page.locator('.schema-step'); + await schemaStep.locator('.destination-button').click(); + const destinationDialog = page.locator('.destination-dialog'); + await clickButton(destinationDialog, 'New table'); + await setLabeledInput(destinationDialog, 'New table name', config.datasourceName); + await showStep(page, 'SQL data loader: Destination'); + await clickButton(destinationDialog, 'Save'); + await destinationDialog.waitFor({ state: 'detached' }); + await showStep(page, 'SQL data loader: Schema'); + await clickButton(schemaStep.locator('.prev-next-bar'), 'Start loading data'); + + // Ingestion progress + const progressDialog = page.locator('.ingestion-progress-dialog'); + const done = progressDialog.getByText('Done loading data into'); + const error = progressDialog.getByText('Error ingesting data'); + await waitForTaskSlots(page, done.or(error)); + await showStep(page, 'SQL data loader: Ingestion progress, done'); + if (await error.isVisible()) { + throw new Error(`SQL data loader failed: ${await error.innerText()}`); + } +} diff --git a/web-console/e2e-tests/component/workbench/overview.ts b/web-console/e2e-tests/component/workbench/overview.ts index af4c484e6248..2a4fb7c02e45 100644 --- a/web-console/e2e-tests/component/workbench/overview.ts +++ b/web-console/e2e-tests/component/workbench/overview.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import type { Page } from '@playwright/test'; +import type { Locator, Page, Request } from '@playwright/test'; import { clickButton, openView, setQueryInput } from '../../util/playwright'; import { showStep } from '../../util/steps'; @@ -36,31 +36,49 @@ export class WorkbenchOverview { * Runs a query and returns its results (as text, one array of cells per row). Throws if the query fails. */ async runQuery(query: string): Promise { + const results = this.page.locator('.result-table-pane'); + await this.run(query, results); + await showStep(this.page, 'Query view: the query results'); + return await extractTable(results); + } + + /** + * Runs an ingestion query (INSERT or REPLACE, run as an MSQ task) and returns what the view says when it is done + * (like "39,244 rows inserted into ..."). Throws if the query fails. + */ + async runIngestQuery(query: string): Promise { + const ingestSuccess = this.page.locator('.ingest-success-pane'); + await this.run(query, ingestSuccess); + await showStep(this.page, 'Query view: the data ingested'); + return await ingestSuccess.innerText(); + } + + private async run(query: string, done: Locator): Promise { await openView(this.page, 'workbench'); await setQueryInput(this.page, query); + // The view shows the result of the last query until it sends the new one (even after a reload) + const queryRequest = this.page.waitForRequest(request => isQueryRequest(request, query), { + timeout: QUERY_TIMEOUT, + }); await clickButton(this.page, 'Run'); - const results = this.page.locator('.result-table-pane'); - const error = this.page.locator('.execution-error-pane'); - // Shown before running a query with the MSQ task engine when the cluster lacks task slots - const capacityAlert = this.page.locator('.alert-dialog').filter({ - hasText: 'The cluster does not currently have enough available task slots', - }); - const timeout = 4 * 60 * 1000; - await results.or(error).or(capacityAlert).waitFor({ timeout }); + const capacityAlert = getCapacityAlert(this.page); + await Promise.race([ + queryRequest, + capacityAlert.waitFor({ timeout: QUERY_TIMEOUT }).catch(() => {}), + ]); if (await capacityAlert.isVisible()) { - await showStep(this.page, 'Query view: not enough task slots, to confirm'); - await clickButton(capacityAlert, 'Run it anyway'); - await results.or(error).waitFor({ timeout }); + await runAnyway(this.page, capacityAlert); } + await queryRequest; + const error = this.page.locator('.execution-error-pane'); + await done.or(error).waitFor({ timeout: QUERY_TIMEOUT }); if (await error.isVisible()) { await showStep(this.page, 'Query view: the query failed'); throw new Error(`Query failed: ${await error.innerText()}`); } - await showStep(this.page, 'Query view: the query results'); - return await extractTable(results); } /** @@ -71,13 +89,7 @@ export class WorkbenchOverview { await setQueryInput(this.page, query); - // Matched on the query, as the view sends other SQL (like the one of its column tree) as it loads - const queryRequest = this.page.waitForRequest( - request => - request.url().includes('druid/v2') && - request.method() === 'POST' && - (request.postData() ?? '').includes(JSON.stringify(query).slice(1, -1)), - ); + const queryRequest = this.page.waitForRequest(request => isQueryRequest(request, query)); await clickButton(this.page, 'Run'); await queryRequest; await showStep(this.page, 'Query view: the query running'); @@ -92,3 +104,45 @@ export class WorkbenchOverview { return status; } } + +const QUERY_TIMEOUT = 4 * 60 * 1000; + +/** + * Whether the request runs the query. Matched on the query (its first line), as the view sends other SQL (like the one + * of its column tree) as it loads. + */ +function isQueryRequest(request: Request, query: string): boolean { + return ( + request.url().includes('druid/v2') && + request.method() === 'POST' && + (request.postData() ?? '').includes(JSON.stringify(query.split('\n')[0]).slice(1, -1)) + ); +} + +/** + * The alert that the console shows before running a query with the MSQ task engine when the cluster lacks the task + * slots for it. + */ +function getCapacityAlert(page: Page): Locator { + return page.locator('.alert-dialog').filter({ + hasText: 'The cluster does not currently have enough available task slots', + }); +} + +async function runAnyway(page: Page, capacityAlert: Locator): Promise { + await showStep(page, 'Not enough task slots, to confirm'); + await clickButton(capacityAlert, 'Run it anyway'); +} + +/** + * Waits for `done`, after starting an MSQ task (like the SQL data loader does), running it anyway if the console + * asks because the cluster lacks the task slots for it. + */ +export async function waitForTaskSlots(page: Page, done: Locator): Promise { + const capacityAlert = getCapacityAlert(page); + await done.or(capacityAlert).waitFor({ timeout: QUERY_TIMEOUT }); + if (await capacityAlert.isVisible()) { + await runAnyway(page, capacityAlert); + await done.waitFor({ timeout: QUERY_TIMEOUT }); + } +} diff --git a/web-console/e2e-tests/sql-ingestion.spec.ts b/web-console/e2e-tests/sql-ingestion.spec.ts new file mode 100644 index 000000000000..6a76a906dc94 --- /dev/null +++ b/web-console/e2e-tests/sql-ingestion.spec.ts @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { T } from 'druid-query-toolkit'; + +import { loadDataWithSql } from './component/load-data/sql-data-loader'; +import { WorkbenchOverview } from './component/workbench/overview'; +import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR } from './util/druid'; +import { expect, test } from './util/fixtures'; +import { CLUSTER_STATE_POLL, getQueryableRowCount } from './util/sql'; + +const DATA_FILE = 'wikiticker-2015-09-12-sampled.json.gz'; +const NUM_ROWS = 39244; +const NUM_EN_WIKIPEDIA_ROWS = 11549; + +test.describe('SQL-based ingestion', () => { + test('Loads data through the SQL data loader', async ({ page, request, newDatasourceName }) => { + const datasourceName = newDatasourceName('sql-data-loader'); + await loadDataWithSql(page, { + connector: { + type: 'local', + baseDirectory: DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, + fileFilter: DATA_FILE, + }, + validateParseData: ({ inputFormat, columns }) => { + expect(inputFormat).toBe('json'); + expect(columns).toEqual(expect.arrayContaining(['time', 'channel', 'page', 'added'])); + }, + datasourceName, + }); + + await expect + .poll(() => getQueryableRowCount(request, datasourceName), CLUSTER_STATE_POLL) + .toBe(NUM_ROWS); + }); + + test('Ingests with REPLACE in the Query view, then reindexes with REPLACE from the datasource', async ({ + page, + request, + newDatasourceName, + }) => { + const workbench = new WorkbenchOverview(page); + + const datasourceName = newDatasourceName('sql-replace'); + const ingested = await workbench.runIngestQuery(`REPLACE INTO ${T(datasourceName)} OVERWRITE ALL +SELECT + TIME_PARSE("time") AS __time, + channel, + page, + added +FROM TABLE( + EXTERN( + '{"type":"local","filter":"${DATA_FILE}","baseDir":${JSON.stringify( + DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, + )}}', + '{"type":"json"}' + ) +) EXTEND ("time" VARCHAR, channel VARCHAR, page VARCHAR, added BIGINT) +PARTITIONED BY DAY`); + expect(ingested).toContain(`39,244 rows inserted into ${T(datasourceName)}`); + await expect + .poll(() => getQueryableRowCount(request, datasourceName), CLUSTER_STATE_POLL) + .toBe(NUM_ROWS); + + // Reindex the rows of one channel into another datasource + const reindexedName = newDatasourceName('sql-reindex'); + const reindexed = await workbench.runIngestQuery(`REPLACE INTO ${T(reindexedName)} OVERWRITE ALL +SELECT * +FROM ${T(datasourceName)} +WHERE channel = '#en.wikipedia' +PARTITIONED BY DAY`); + expect(reindexed).toContain(`11,549 rows inserted into ${T(reindexedName)}`); + await expect + .poll(() => getQueryableRowCount(request, reindexedName), CLUSTER_STATE_POLL) + .toBe(NUM_EN_WIKIPEDIA_ROWS); + }); +}); From 2ad3a7f8eaef107d0cf21d9a74dcbd439cb00833 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 18:59:46 -0700 Subject: [PATCH 21/26] add a web console e2e test of the Datasources view's destructive actions datasource-actions.spec.ts marks a datasource's segments unused, used again, then deletes them with a kill task from the Datasources view, run by DatasourceActionsWebConsoleTest. DatasourcesOverview can show unused datasources and run and confirm a datasource's actions. Co-Authored-By: Claude Opus 5.5 --- .../DatasourceActionsWebConsoleTest.java | 36 ++++++ web-console/E2E_BETTERMENT_PLAN.md | 7 +- web-console/e2e-tests/README.md | 5 +- .../component/datasources/overview.ts | 68 ++++++++++- .../e2e-tests/datasource-actions.spec.ts | 106 ++++++++++++++++++ 5 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/DatasourceActionsWebConsoleTest.java create mode 100644 web-console/e2e-tests/datasource-actions.spec.ts diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/DatasourceActionsWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/DatasourceActionsWebConsoleTest.java new file mode 100644 index 000000000000..4d3ad9abc823 --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/DatasourceActionsWebConsoleTest.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.indexing; + +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; +import org.junit.jupiter.api.Test; + +/** + * Marks the segments of a datasource unused and used again, then deletes them with a kill task, from the Datasources + * view of the web console. + */ +public class DatasourceActionsWebConsoleTest extends WebConsoleTestBase +{ + @Test + public void testDatasourceActions() throws Exception + { + runSpec("datasource-actions.spec.ts"); + } +} diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index 75cbc30678e9..6fee5f6d4ef9 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -134,9 +134,10 @@ SQL-based ingestion (MSQ) and the Query view: - [ ] Query blocklist, default query context (`server/EmbeddedBrokerDynamicConfigTest`): Broker dynamic config dialog Datasources, Segments and Tasks views: -- [ ] (4) Mark segments unused / used (`IngestionSmokeTest`, `OverlordClientTest`, `ConcurrentAppendReplaceTest`) -- [ ] (4) Delete data with a kill task (`IngestionSmokeTest.test_runIndexTask_andKillData`, - `OverlordClientTest.test_runKillTask`) +- [x] (4) Mark segments unused / used (`IngestionSmokeTest`, `OverlordClientTest`, `ConcurrentAppendReplaceTest`) +- [x] (4) Delete data with a kill task (`IngestionSmokeTest.test_runIndexTask_andKillData`, + `OverlordClientTest.test_runKillTask`). Done (both): `datasource-actions.spec.ts`, run by + `indexing/DatasourceActionsWebConsoleTest` - [ ] (5) Retention rules (`query/BroadcastJoinQueryTest`, `server/CoordinatorClientTest`) - [ ] Compaction supervisors and cluster compaction config (`compact/CompactionSupervisorTest`) - [ ] Compaction to another segment granularity (`CompactionSupervisorTest`, `CompactionTaskTest`) diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index 8fea1d1b913f..e038a5782c43 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -73,9 +73,11 @@ the spec needs, and runs the spec on it with `npx playwright test `: | `input-formats.spec.ts` | `indexer.InputFormatsWebConsoleTest` | | `kafka-ingestion.spec.ts` | `indexing.KafkaWebConsoleTest` (with Kafka) | | `sql-ingestion.spec.ts` | `msq.SqlIngestionWebConsoleTest` | +| `datasource-actions.spec.ts` | `indexing.DatasourceActionsWebConsoleTest` | | `input-formats.spec.ts` | Loads the same 10 rows (over 2 days) of Wikipedia edits from local CSV, TSV, Parquet, ORC and Avro OCF files through the data loader, one test per format. Checks that the **Parse data** step picks the right input format by itself and parses the columns, and that the datasource ends up as 2 segments with 10 rows. Skipped without `DRUID_E2E_TEST_DATA_DIR` (the data files of embedded-tests): it runs on an embedded cluster with the Avro, Parquet and ORC extensions, from `InputFormatsWebConsoleTest`. | | `kafka-ingestion.spec.ts` | Follows the [Kafka tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-kafka): sets up a supervisor through the data loader's **Apache Kafka** connector on a topic with the 39,244 edits of the tutorial file (reading it from the earliest offset), and waits for the rows to be queryable. Then, from the **Supervisors** view, suspends the supervisor, resumes it and terminates it, checking its state each time (through SQL on `sys.supervisors` and in the view). Skipped without `DRUID_E2E_TEST_KAFKA_BOOTSTRAP_SERVERS` and `DRUID_E2E_TEST_KAFKA_TOPIC`: it runs on an embedded cluster with a Kafka container, from `KafkaWebConsoleTest`. | | `sql-ingestion.spec.ts` | SQL-based ingestion (MSQ tasks). Loads the tutorial file through the **SQL data loader** ("Load data" > "Batch - SQL": input source, parse, schema with a new datasource as the destination, ingestion progress) and waits for its 39,244 rows to be queryable. Then, in the **Query** view, ingests it with `REPLACE ... FROM TABLE(EXTERN(...))` and reindexes the 11,549 rows of `#en.wikipedia` into another datasource with `REPLACE ... FROM` the first, checking what the view says it inserted and the rows queryable each time. | +| `datasource-actions.spec.ts` | Loads `wikipedia-index.json` through the API, then from the **Datasources** view: marks all its segments unused (the datasource then shows, as "Unused", only with "Show unused"), marks them used again (the data is back, fully available), marks them unused again and deletes them with a kill task (ticking the dialog's checks of what it does), after which the kill task succeeds and the datasource is gone even with "Show unused". | They extend `console.WebConsoleTestBase`. There's no distribution to build and nothing left behind, as every test class gets a new cluster. They need this checkout's Druid modules installed (`mvn install -DskipTests`), with the @@ -129,7 +131,8 @@ for up to 2 minutes. e2e-tests/ *.spec.ts the tests, one describe per file component/ page objects: one per view, wrapping its selectors - datasources/ Datasources view (reads the table, edits and triggers compaction) + datasources/ Datasources view (reads the table, with unused datasources or not, runs and confirms a + datasource's actions, edits and triggers compaction) ingestion/ Tasks view (reads the table) load-data/ the classic data loader: loadData goes through each step with a DataLoaderConfig (data-connector.ts: local disk, S3, Kafka, reindex from Druid; partitions-spec.ts: hashed, diff --git a/web-console/e2e-tests/component/datasources/overview.ts b/web-console/e2e-tests/component/datasources/overview.ts index 3b81db6c4ae9..b96cd9b48b8b 100644 --- a/web-console/e2e-tests/component/datasources/overview.ts +++ b/web-console/e2e-tests/component/datasources/overview.ts @@ -49,6 +49,18 @@ export interface CompactionConfig { const SKIP_OFFSET_FROM_LATEST = 'Skip offset from latest'; +/** + * An action of a datasource's menu that asks for confirmation, with the text of its confirm button. The menu of a + * datasource with used segments has the "unused" actions, the menu of one shown only as unused the others. + */ +const CONFIRMED_ACTIONS = { + 'Mark as unused all segments': 'Mark as unused all segments', + 'Mark as used all segments': 'Mark as used all segments', + 'Delete segments (issue kill task)': 'Permanently delete unused segments', +} as const; + +export type DatasourceAction = keyof typeof CONFIRMED_ACTIONS; + /** * Represents datasource overview tab. */ @@ -63,12 +75,19 @@ export class DatasourcesOverview { /** * The datasource named `datasourceName` (none, if it doesn't exist yet), as the view shows it. + * @param showUnused whether to show the datasource also when all of its segments are unused (with "Show unused") */ - async getDatasources(datasourceName: string): Promise { - await openView(this.page, 'datasources', { datasource: datasourceName }); + async getDatasources( + datasourceName: string, + { showUnused = false }: { showUnused?: boolean } = {}, + ): Promise { + await this.open(datasourceName, showUnused); const records = await extractTableRecords(this.table()); - await showStep(this.page, `Datasources view, filtered on ${datasourceName}`); + await showStep( + this.page, + `Datasources view, filtered on ${datasourceName}${showUnused ? ', with unused' : ''}`, + ); return records.map(record => ({ name: record['Datasource name'], @@ -101,9 +120,50 @@ export class DatasourcesOverview { return { skipOffsetFromLatest, partitionsSpec: partitionsSpec! }; } - private async openCompactionConfigurationDialog(datasourceName: string) { + /** + * Runs an action from the datasource's menu, and confirms it (ticking the checks of what it does, if it asks). + * @param showUnused whether the datasource is only shown with "Show unused" (all of its segments are unused) + */ + async runAction( + datasourceName: string, + action: DatasourceAction, + { showUnused = false }: { showUnused?: boolean } = {}, + ): Promise { + await this.open(datasourceName, showUnused); + await this.openActionMenu(); + await clickMenuItem(this.page, action); + + const confirmation = this.page.locator('.async-action-dialog'); + await confirmation.waitFor(); + // The checks of what it does (like "I understand that this operation cannot be undone") are switches + for (const check of await confirmation.locator('.warning-checklist .bp6-switch').all()) { + await check.click(); + } + await showStep(this.page, `Datasources view: ${action}, to confirm`); + await clickButton(confirmation, CONFIRMED_ACTIONS[action]); + await confirmation.waitFor({ state: 'detached' }); + } + + private async open(datasourceName: string, showUnused: boolean): Promise { await openView(this.page, 'datasources', { datasource: datasourceName }); + if (!showUnused) return; + + // Showing the unused datasources fetches them; until then the table shows what it had + const unusedFetched = this.page.waitForResponse(response => + response.url().includes('/druid/coordinator/v1/metadata/datasources?includeUnused'), + ); + await this.page.locator('.datasources-view').getByText('Show unused', { exact: true }).click(); + await unusedFetched; + await this.table().locator('.loader').waitFor({ state: 'hidden' }); + } + + private async openActionMenu(): Promise { await this.table().locator('.ct-tbody .action-cell .bp6-icon-more').click(); + } + + private async openCompactionConfigurationDialog(datasourceName: string) { + await this.open(datasourceName, false); + await this.openActionMenu(); await clickMenuItem(this.page, 'Edit compaction configuration'); const dialog = this.page.locator('.compaction-config-dialog'); diff --git a/web-console/e2e-tests/datasource-actions.spec.ts b/web-console/e2e-tests/datasource-actions.spec.ts new file mode 100644 index 000000000000..f15224b84568 --- /dev/null +++ b/web-console/e2e-tests/datasource-actions.spec.ts @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { APIRequestContext } from '@playwright/test'; + +import type { Datasource } from './component/datasources/overview'; +import { DatasourcesOverview } from './component/datasources/overview'; +import { readTutorialIngestionSpec, runTask } from './util/druid'; +import { expect, test } from './util/fixtures'; +import { CLUSTER_STATE_POLL, getDatasourceSegments, getTaskStatuses } from './util/sql'; + +const NUM_ROWS = 39244; + +test.describe('Datasource actions', () => { + test('Marks the segments unused, used again, then deletes them with a kill task', async ({ + page, + request, + newDatasourceName, + }) => { + const datasourceName = newDatasourceName('datasource-actions'); + await loadData(request, datasourceName); + const datasourcesOverview = new DatasourcesOverview(page); + + // Mark unused: the datasource is only shown with "Show unused", as unused + await datasourcesOverview.runAction(datasourceName, 'Mark as unused all segments'); + await expect + .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual({ numSegments: 0, numAvailableSegments: 0, numRows: null }); + await expectInView(datasourcesOverview, datasourceName, false, []); + await expectInView(datasourcesOverview, datasourceName, true, [ + expect.objectContaining({ + name: datasourceName, + availability: expect.stringContaining('Unused'), + }), + ]); + + // Mark used: the data is back + await datasourcesOverview.runAction(datasourceName, 'Mark as used all segments', { + showUnused: true, + }); + await expect + .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual({ numSegments: 1, numAvailableSegments: 1, numRows: NUM_ROWS }); + await expectInView(datasourcesOverview, datasourceName, false, [ + { + name: datasourceName, + availability: expect.stringContaining('Fully available (1 segment)'), + totalRows: NUM_ROWS, + }, + ]); + + // Mark unused again, and delete the unused segments with a kill task: the datasource is gone + await datasourcesOverview.runAction(datasourceName, 'Mark as unused all segments'); + await expect + .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual({ numSegments: 0, numAvailableSegments: 0, numRows: null }); + await expectInView(datasourcesOverview, datasourceName, true, [ + expect.objectContaining({ name: datasourceName }), + ]); + await datasourcesOverview.runAction(datasourceName, 'Delete segments (issue kill task)', { + showUnused: true, + }); + await expect + .poll(() => getTaskStatuses(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual(['SUCCESS', 'SUCCESS']); // the index task, and the kill task + await expectInView(datasourcesOverview, datasourceName, true, []); + }); +}); + +async function loadData(request: APIRequestContext, datasourceName: string) { + const ingestionSpec = readTutorialIngestionSpec('wikipedia-index.json'); + ingestionSpec.spec.dataSchema.dataSource = datasourceName; + await runTask(request, ingestionSpec); + await expect + .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual({ numSegments: 1, numAvailableSegments: 1, numRows: NUM_ROWS }); +} + +async function expectInView( + datasourcesOverview: DatasourcesOverview, + datasourceName: string, + showUnused: boolean, + expected: (Datasource | ReturnType)[], +) { + // Retried in case the view is a step behind the cluster state polled before + await expect(async () => { + expect(await datasourcesOverview.getDatasources(datasourceName, { showUnused })).toEqual( + expected, + ); + }).toPass({ timeout: 30 * 1000 }); +} From c7a832646e4278d2a84664ea5941ab7c65c2f4c5 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 8 Oct 2026 20:23:42 -0700 Subject: [PATCH 22/26] add web console e2e tests of retention rules and JDBC lookups retention-rules.spec.ts edits a datasource's retention rules from the Datasources view (loadForever, then dropForever, which drops the data), run by RetentionRulesWebConsoleTest. jdbc-lookup.spec.ts initializes the lookups and adds a JDBC lookup from the Lookups view, then queries it, run by JdbcLookupWebConsoleTest (with the table in the Derby metadata store, as JdbcLookupTest does). Co-Authored-By: Claude Opus 5.5 --- .../lookup/JdbcLookupWebConsoleTest.java | 85 ++++++++++++ .../server/RetentionRulesWebConsoleTest.java | 36 +++++ web-console/E2E_BETTERMENT_PLAN.md | 7 +- web-console/e2e-tests/README.md | 7 +- .../component/datasources/overview.ts | 39 ++++++ .../e2e-tests/component/lookups/overview.ts | 123 ++++++++++++++++++ .../e2e-tests/datasource-actions.spec.ts | 4 +- web-console/e2e-tests/jdbc-lookup.spec.ts | 78 +++++++++++ web-console/e2e-tests/retention-rules.spec.ts | 87 +++++++++++++ web-console/e2e-tests/util/druid.ts | 12 ++ 10 files changed, 473 insertions(+), 5 deletions(-) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/lookup/JdbcLookupWebConsoleTest.java create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/RetentionRulesWebConsoleTest.java create mode 100644 web-console/e2e-tests/component/lookups/overview.ts create mode 100644 web-console/e2e-tests/jdbc-lookup.spec.ts create mode 100644 web-console/e2e-tests/retention-rules.spec.ts diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/lookup/JdbcLookupWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/lookup/JdbcLookupWebConsoleTest.java new file mode 100644 index 000000000000..17a3c467e463 --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/lookup/JdbcLookupWebConsoleTest.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.lookup; + +import org.apache.druid.metadata.SQLMetadataConnector; +import org.apache.druid.metadata.TestDerbyConnector; +import org.apache.druid.server.lookup.namespace.NamespaceExtractionModule; +import org.apache.druid.testing.embedded.EmbeddedDruidCluster; +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +/** + * Initializes the lookups and adds a JDBC lookup (on a table of the Derby metadata store, as {@link JdbcLookupTest} + * does) from the Lookups view of the web console, then queries it. + */ +public class JdbcLookupWebConsoleTest extends WebConsoleTestBase +{ + private static final String LOOKUP_TABLE = "web_console_lookups"; + + @Override + protected void configureCluster(EmbeddedDruidCluster cluster) + { + cluster.addExtension(NamespaceExtractionModule.class); + // Push the lookups to the services every second (rather than every 2 minutes), for the Broker to have them soon + coordinator.addProperty("druid.manager.lookups.period", "1000"); + } + + @BeforeAll + public void createLookupTable() + { + final SQLMetadataConnector connector = coordinator.bindings().sqlMetadataConnector(); + connector.retryWithHandle( + handle -> handle.update( + "CREATE TABLE " + LOOKUP_TABLE + "(" + + " created_date TIMESTAMP NOT NULL,\n" + + " country_code VARCHAR(10) NOT NULL,\n" + + " country_name VARCHAR(255) NOT NULL,\n" + + " PRIMARY KEY (country_code)" + + ")" + ) + ); + connector.retryWithHandle( + handle -> handle.insert( + "INSERT INTO " + LOOKUP_TABLE + + " (created_date, country_code, country_name) VALUES" + + " ('2025-06-01 00:00:00', 'AU', 'Australia')," + + " ('2025-06-02 00:00:00', 'PR', 'Puerto Rico')" + ) + ); + } + + @Test + public void testJdbcLookup() throws Exception + { + runSpec( + "jdbc-lookup.spec.ts", + Map.of( + "DRUID_E2E_TEST_LOOKUP_CONNECT_URI", + ((TestDerbyConnector) coordinator.bindings().sqlMetadataConnector()).getJdbcUri(), + "DRUID_E2E_TEST_LOOKUP_TABLE", + LOOKUP_TABLE + ) + ); + } +} diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/RetentionRulesWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/RetentionRulesWebConsoleTest.java new file mode 100644 index 000000000000..f40dca52c307 --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/RetentionRulesWebConsoleTest.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.server; + +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; +import org.junit.jupiter.api.Test; + +/** + * Edits the retention rules of a datasource from the Datasources view of the web console, checking that the + * Coordinator drops and loads its segments by them. + */ +public class RetentionRulesWebConsoleTest extends WebConsoleTestBase +{ + @Test + public void testRetentionRules() throws Exception + { + runSpec("retention-rules.spec.ts"); + } +} diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index 6fee5f6d4ef9..b0a38fc4b0a2 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -138,7 +138,9 @@ Datasources, Segments and Tasks views: - [x] (4) Delete data with a kill task (`IngestionSmokeTest.test_runIndexTask_andKillData`, `OverlordClientTest.test_runKillTask`). Done (both): `datasource-actions.spec.ts`, run by `indexing/DatasourceActionsWebConsoleTest` -- [ ] (5) Retention rules (`query/BroadcastJoinQueryTest`, `server/CoordinatorClientTest`) +- [x] (5) Retention rules (`query/BroadcastJoinQueryTest`, `server/CoordinatorClientTest`). Done: + `retention-rules.spec.ts`, run by `server/RetentionRulesWebConsoleTest`. A drop rule marks the segments unused (so + the datasource loses its "Edit retention rules" action), it doesn't only unload them - [ ] Compaction supervisors and cluster compaction config (`compact/CompactionSupervisorTest`) - [ ] Compaction to another segment granularity (`CompactionSupervisorTest`, `CompactionTaskTest`) - [ ] Cancel a running task (`OverlordClientTest.test_cancelTask_*`): Tasks view "Kill" @@ -148,7 +150,8 @@ Datasources, Segments and Tasks views: Services, Lookups, dynamic configs: - [ ] Server types, workers and capacity (`SystemTableQueryTest`, `OverlordClientTest.test_getWorkers`): Services view -- [ ] (5) JDBC lookup (`lookup/JdbcLookupTest`): Lookups view +- [x] (5) JDBC lookup (`lookup/JdbcLookupTest`): Lookups view. Done: `jdbc-lookup.spec.ts`, run by + `lookup/JdbcLookupWebConsoleTest` - [ ] Pause coordination (`server/CoordinatorPauseTest`), turbo loading (`server/HistoricalCloningTest`): Coordinator dynamic config dialog diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index e038a5782c43..9dfb7d202e72 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -74,10 +74,14 @@ the spec needs, and runs the spec on it with `npx playwright test `: | `kafka-ingestion.spec.ts` | `indexing.KafkaWebConsoleTest` (with Kafka) | | `sql-ingestion.spec.ts` | `msq.SqlIngestionWebConsoleTest` | | `datasource-actions.spec.ts` | `indexing.DatasourceActionsWebConsoleTest` | +| `retention-rules.spec.ts` | `server.RetentionRulesWebConsoleTest` | +| `jdbc-lookup.spec.ts` | `lookup.JdbcLookupWebConsoleTest` | | `input-formats.spec.ts` | Loads the same 10 rows (over 2 days) of Wikipedia edits from local CSV, TSV, Parquet, ORC and Avro OCF files through the data loader, one test per format. Checks that the **Parse data** step picks the right input format by itself and parses the columns, and that the datasource ends up as 2 segments with 10 rows. Skipped without `DRUID_E2E_TEST_DATA_DIR` (the data files of embedded-tests): it runs on an embedded cluster with the Avro, Parquet and ORC extensions, from `InputFormatsWebConsoleTest`. | | `kafka-ingestion.spec.ts` | Follows the [Kafka tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-kafka): sets up a supervisor through the data loader's **Apache Kafka** connector on a topic with the 39,244 edits of the tutorial file (reading it from the earliest offset), and waits for the rows to be queryable. Then, from the **Supervisors** view, suspends the supervisor, resumes it and terminates it, checking its state each time (through SQL on `sys.supervisors` and in the view). Skipped without `DRUID_E2E_TEST_KAFKA_BOOTSTRAP_SERVERS` and `DRUID_E2E_TEST_KAFKA_TOPIC`: it runs on an embedded cluster with a Kafka container, from `KafkaWebConsoleTest`. | | `sql-ingestion.spec.ts` | SQL-based ingestion (MSQ tasks). Loads the tutorial file through the **SQL data loader** ("Load data" > "Batch - SQL": input source, parse, schema with a new datasource as the destination, ingestion progress) and waits for its 39,244 rows to be queryable. Then, in the **Query** view, ingests it with `REPLACE ... FROM TABLE(EXTERN(...))` and reindexes the 11,549 rows of `#en.wikipedia` into another datasource with `REPLACE ... FROM` the first, checking what the view says it inserted and the rows queryable each time. | | `datasource-actions.spec.ts` | Loads `wikipedia-index.json` through the API, then from the **Datasources** view: marks all its segments unused (the datasource then shows, as "Unused", only with "Show unused"), marks them used again (the data is back, fully available), marks them unused again and deletes them with a kill task (ticking the dialog's checks of what it does), after which the kill task succeeds and the datasource is gone even with "Show unused". | +| `retention-rules.spec.ts` | Loads `wikipedia-index.json` through the API, then edits its retention rules from the **Datasources** view (deleting the rules there, adding new ones and picking their type, then giving the reason the dialog asks for): a `loadForever` rule (as "New rule" makes it, 2 replicas), which the Retention column shows and with which the data stays loaded, then a `dropForever` rule in its place, with which the Coordinator drops the data (marking it unused). Checks the rules through the Coordinator API each time. | +| `jdbc-lookup.spec.ts` | From the **Lookups** view, initializes the lookups (if the cluster has none) and adds a JDBC lookup (cachedNamespace, jdbc) on a table of country codes and names, checks that the view lists it, and waits for `LOOKUP('PR', ...)` and `LOOKUP('AU', ...)` to return the names. Skipped without `DRUID_E2E_TEST_LOOKUP_CONNECT_URI` and `DRUID_E2E_TEST_LOOKUP_TABLE`: it runs on an embedded cluster, with the table in its Derby metadata store, from `JdbcLookupWebConsoleTest`. | They extend `console.WebConsoleTestBase`. There's no distribution to build and nothing left behind, as every test class gets a new cluster. They need this checkout's Druid modules installed (`mvn install -DskipTests`), with the @@ -132,7 +136,8 @@ e2e-tests/ *.spec.ts the tests, one describe per file component/ page objects: one per view, wrapping its selectors datasources/ Datasources view (reads the table, with unused datasources or not, runs and confirms a - datasource's actions, edits and triggers compaction) + datasource's actions, edits retention rules, edits and triggers compaction) + lookups/ Lookups view (initializes the lookups, adds a JDBC lookup, reads the table) ingestion/ Tasks view (reads the table) load-data/ the classic data loader: loadData goes through each step with a DataLoaderConfig (data-connector.ts: local disk, S3, Kafka, reindex from Druid; partitions-spec.ts: hashed, diff --git a/web-console/e2e-tests/component/datasources/overview.ts b/web-console/e2e-tests/component/datasources/overview.ts index b96cd9b48b8b..461bd49524d6 100644 --- a/web-console/e2e-tests/component/datasources/overview.ts +++ b/web-console/e2e-tests/component/datasources/overview.ts @@ -37,6 +37,8 @@ export interface Datasource { readonly name: string; readonly availability: string; readonly totalRows: number; + /** The retention rules (like "dropForever()"), or the cluster default ones ("Cluster default: ...") */ + readonly retention: string; } /** @@ -93,6 +95,7 @@ export class DatasourcesOverview { name: record['Datasource name'], availability: record['Availability'], totalRows: Number(record['Total rows'].replace(/,/g, '')), + retention: record['Retention'], })); } @@ -144,6 +147,42 @@ export class DatasourcesOverview { await confirmation.waitFor({ state: 'detached' }); } + /** + * Replaces the retention rules of the datasource with rules of the given types (with their defaults), in order. With + * no rules, the datasource uses the cluster default rules. + * @param comment why (the dialog asks, for the audit history) + */ + async setRetentionRules( + datasourceName: string, + ruleTypes: string[], + comment: string, + ): Promise { + await this.open(datasourceName, false); + await this.openActionMenu(); + await clickMenuItem(this.page, 'Edit retention rules'); + + const dialog = this.page.locator('.retention-dialog'); + // The datasource's rules (the dialog also shows the cluster default rules, which can't be deleted here) + const ruleEditors = dialog + .locator('.rule-editor') + .filter({ has: this.page.locator('.title .bp6-icon-trash') }); + await dialog.waitFor(); + while ((await ruleEditors.count()) > 0) { + await ruleEditors.first().locator('.title .bp6-icon-trash').click(); + } + for (const ruleType of ruleTypes) { + await clickButton(dialog, 'New rule'); + await ruleEditors.last().locator('select').selectOption(ruleType); + } + await showStep(this.page, 'Retention rules dialog, filled in'); + await clickButton(dialog, 'Next'); + + await this.page.getByPlaceholder('Enter description here').fill(comment); + await showStep(this.page, 'Retention rules dialog, why'); + await clickButton(dialog, 'Save'); + await dialog.waitFor({ state: 'detached' }); + } + private async open(datasourceName: string, showUnused: boolean): Promise { await openView(this.page, 'datasources', { datasource: datasourceName }); if (!showUnused) return; diff --git a/web-console/e2e-tests/component/lookups/overview.ts b/web-console/e2e-tests/component/lookups/overview.ts new file mode 100644 index 000000000000..0983be89bc29 --- /dev/null +++ b/web-console/e2e-tests/component/lookups/overview.ts @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from '@playwright/test'; + +import { button, clickButton, openView, setLabeledInput } from '../../util/playwright'; +import { showStep } from '../../util/steps'; +import { extractTableRecords } from '../../util/table'; + +/** + * A row of the Lookups view. + */ +export interface Lookup { + readonly name: string; + readonly tier: string; + readonly type: string; +} + +/** + * A lookup on a table of a database (a cachedNamespace lookup with a jdbc extraction namespace). + */ +export interface JdbcLookup { + readonly name: string; + readonly connectUri: string; + readonly table: string; + readonly keyColumn: string; + readonly valueColumn: string; + readonly timestampColumn?: string; +} + +/** + * Represents the Lookups view. + */ +export class LookupsOverview { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + private readonly table = () => this.page.locator('.lookups-view .console-table'); + + /** + * Opens the view, initializing the lookups (their config, with the default tier) if the cluster has none yet, as + * the view asks. + */ + async open(): Promise { + await this.load(); + + const initialize = button(this.page, 'Initialize lookups'); + if (await initialize.isVisible()) { + await showStep(this.page, 'Lookups view, not initialized'); + await initialize.click(); + await this.table().waitFor(); + } + } + + /** + * Opens the view and waits for it to fetch the lookups, as it shows "No lookups" (and the "Add lookup" button) also + * while it does. It reloads, to be sure the fetch it waits for is one that starts after this. + */ + private async load(filter?: Record): Promise { + await openView(this.page, 'lookups', filter); + const lookupsFetched = this.page.waitForResponse(response => + response.url().includes('/druid/coordinator/v1/lookups/config/all'), + ); + await this.page.reload(); + await lookupsFetched; + await this.table().or(button(this.page, 'Initialize lookups')).waitFor(); + await this.table().locator('.loader').waitFor({ state: 'hidden' }); + } + + async addJdbcLookup(lookup: JdbcLookup): Promise { + await this.open(); + await clickButton(this.page, 'Add lookup'); + + const dialog = this.page.locator('.lookup-edit-dialog'); + await setLabeledInput(dialog, 'Name', lookup.name); + await setLabeledInput(dialog, 'Type', 'cachedNamespace'); + await setLabeledInput(dialog, 'Extraction type', 'jdbc'); + await setLabeledInput(dialog, 'Connect URI', lookup.connectUri); + await setLabeledInput(dialog, 'Table', lookup.table); + await setLabeledInput(dialog, 'Key column', lookup.keyColumn); + await setLabeledInput(dialog, 'Value column', lookup.valueColumn); + if (lookup.timestampColumn) { + await setLabeledInput(dialog, 'Timestamp column', lookup.timestampColumn); + } + await showStep(this.page, 'Lookup dialog, filled in'); + await clickButton(dialog, 'Submit'); + await dialog.waitFor({ state: 'detached' }); + } + + /** + * The lookup named `lookupName` (none, if it doesn't exist), as the view shows it. + */ + async getLookups(lookupName: string): Promise { + await this.load({ lookup_name: lookupName }); + + const records = await extractTableRecords(this.table()); + await showStep(this.page, `Lookups view, filtered on ${lookupName}`); + + return records.map(record => ({ + name: record['Lookup name'], + tier: record['Lookup tier'], + type: record['Type'], + })); + } +} diff --git a/web-console/e2e-tests/datasource-actions.spec.ts b/web-console/e2e-tests/datasource-actions.spec.ts index f15224b84568..a6c175007c32 100644 --- a/web-console/e2e-tests/datasource-actions.spec.ts +++ b/web-console/e2e-tests/datasource-actions.spec.ts @@ -57,11 +57,11 @@ test.describe('Datasource actions', () => { .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) .toEqual({ numSegments: 1, numAvailableSegments: 1, numRows: NUM_ROWS }); await expectInView(datasourcesOverview, datasourceName, false, [ - { + expect.objectContaining({ name: datasourceName, availability: expect.stringContaining('Fully available (1 segment)'), totalRows: NUM_ROWS, - }, + }), ]); // Mark unused again, and delete the unused segments with a kill task: the datasource is gone diff --git a/web-console/e2e-tests/jdbc-lookup.spec.ts b/web-console/e2e-tests/jdbc-lookup.spec.ts new file mode 100644 index 000000000000..db24845ca605 --- /dev/null +++ b/web-console/e2e-tests/jdbc-lookup.spec.ts @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { APIRequestContext } from '@playwright/test'; +import { L } from 'druid-query-toolkit'; + +import { LookupsOverview } from './component/lookups/overview'; +import { expect, test } from './util/fixtures'; +import { CLUSTER_STATE_POLL, querySql } from './util/sql'; + +// The JDBC URI of a database with a table of country codes and names, which the cluster can read. JdbcLookupWebConsoleTest +// (in embedded-tests) sets it up, in the cluster's (Derby) metadata store. +const CONNECT_URI = process.env['DRUID_E2E_TEST_LOOKUP_CONNECT_URI']; +const TABLE = process.env['DRUID_E2E_TEST_LOOKUP_TABLE']; + +test.describe('JDBC lookup', () => { + test.skip( + !CONNECT_URI || !TABLE, + 'needs DRUID_E2E_TEST_LOOKUP_CONNECT_URI and DRUID_E2E_TEST_LOOKUP_TABLE (run by JdbcLookupWebConsoleTest in embedded-tests)', + ); + + test('Adds a JDBC lookup and queries it', async ({ page, request }) => { + // Lookups are not deleted with the datasources, so the name is unique + const lookupName = `country_names_${Date.now()}`; + const lookupsOverview = new LookupsOverview(page); + await lookupsOverview.addJdbcLookup({ + name: lookupName, + connectUri: CONNECT_URI!, + table: TABLE!, + keyColumn: 'country_code', + valueColumn: 'country_name', + timestampColumn: 'created_date', + }); + + expect(await lookupsOverview.getLookups(lookupName)).toEqual([ + { name: lookupName, tier: '__default', type: 'cachedNamespace' }, + ]); + + // Once the Broker loads the lookup (from the table), queries can use it + await expect + .poll(() => lookUp(request, lookupName, ['PR', 'AU']), CLUSTER_STATE_POLL) + .toEqual(['Puerto Rico', 'Australia']); + }); +}); + +/** + * The values of the keys in the lookup, or null while the lookup can not be queried (yet). + */ +async function lookUp( + request: APIRequestContext, + lookupName: string, + keys: string[], +): Promise { + try { + const [row] = await querySql( + request, + `SELECT ${keys.map((key, i) => `LOOKUP(${L(key)}, ${L(lookupName)}) AS "v${i}"`).join(', ')}`, + ); + return keys.map((_, i) => row[`v${i}`]); + } catch { + return null; + } +} diff --git a/web-console/e2e-tests/retention-rules.spec.ts b/web-console/e2e-tests/retention-rules.spec.ts new file mode 100644 index 000000000000..e77d928e016f --- /dev/null +++ b/web-console/e2e-tests/retention-rules.spec.ts @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { APIRequestContext } from '@playwright/test'; + +import { DatasourcesOverview } from './component/datasources/overview'; +import { getRetentionRules, readTutorialIngestionSpec, runTask } from './util/druid'; +import { expect, test } from './util/fixtures'; +import { CLUSTER_STATE_POLL, getDatasourceSegments } from './util/sql'; + +test.describe('Retention rules', () => { + test('Sets a loadForever rule, then replaces it with a dropForever rule, which drops the data', async ({ + page, + request, + newDatasourceName, + }) => { + const datasourceName = newDatasourceName('retention-rules'); + await loadData(request, datasourceName); + const datasourcesOverview = new DatasourcesOverview(page); + + // loadForever (as "New rule" makes it, with 2 replicas): the data stays loaded + await datasourcesOverview.setRetentionRules(datasourceName, ['loadForever'], 'Load it all'); + expect(await getRetentionRules(request, datasourceName)).toEqual([ + expect.objectContaining({ type: 'loadForever', tieredReplicants: { _default_tier: 2 } }), + ]); + await expectRetentionInView(datasourcesOverview, datasourceName, 'loadForever(2x)'); + expect(await getUsedAndAvailableSegments(request, datasourceName)).toEqual([1, 1]); + + // dropForever (replacing loadForever): the Coordinator drops the segment, marking it unused, so the datasource is + // only shown with "Show unused" + await datasourcesOverview.setRetentionRules(datasourceName, ['dropForever'], 'Drop it all'); + expect(await getRetentionRules(request, datasourceName)).toEqual([{ type: 'dropForever' }]); + await expect + .poll(() => getUsedAndAvailableSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual([0, 0]); + await expect(async () => { + expect(await datasourcesOverview.getDatasources(datasourceName)).toEqual([]); + expect( + await datasourcesOverview.getDatasources(datasourceName, { showUnused: true }), + ).toEqual([expect.objectContaining({ availability: expect.stringContaining('Unused') })]); + }).toPass({ timeout: 30 * 1000 }); + }); +}); + +async function loadData(request: APIRequestContext, datasourceName: string) { + const ingestionSpec = readTutorialIngestionSpec('wikipedia-index.json'); + ingestionSpec.spec.dataSchema.dataSource = datasourceName; + await runTask(request, ingestionSpec); + await expect + .poll(() => getUsedAndAvailableSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual([1, 1]); +} + +async function getUsedAndAvailableSegments(request: APIRequestContext, datasourceName: string) { + const { numSegments, numAvailableSegments } = await getDatasourceSegments( + request, + datasourceName, + ); + return [numSegments, numAvailableSegments]; +} + +async function expectRetentionInView( + datasourcesOverview: DatasourcesOverview, + datasourceName: string, + retention: string, +) { + // Retried in case the view is a step behind the cluster state polled before + await expect(async () => { + const [datasource] = await datasourcesOverview.getDatasources(datasourceName); + expect(datasource.retention).toContain(retention); + }).toPass({ timeout: 30 * 1000 }); +} diff --git a/web-console/e2e-tests/util/druid.ts b/web-console/e2e-tests/util/druid.ts index 170f8aa1cded..1ca2defab1d9 100644 --- a/web-console/e2e-tests/util/druid.ts +++ b/web-console/e2e-tests/util/druid.ts @@ -121,3 +121,15 @@ export async function deleteDatasource( interval: '1000-01-01/3000-01-01', }); } + +/** + * The retention rules of a datasource (none when it uses the cluster default rules). + */ +export async function getRetentionRules( + request: APIRequestContext, + datasourceName: string, +): Promise[]> { + const response = await request.get(`/druid/coordinator/v1/rules/${encodePath(datasourceName)}`); + expect(response.status(), await response.text()).toBe(200); + return (await response.json()) as Record[]; +} From 36e60e5351979cf07976c94ed4a4894b716ad151 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Fri, 9 Oct 2026 00:15:14 -0700 Subject: [PATCH 23/26] add a web console e2e test of Dart dart.spec.ts runs a query with the Dart engine picked in the Query view, follows it in the current Dart queries panel (with its details), and cancels a running Dart query from the panel, run by DartWebConsoleTest. The embedded clusters keep the reports of finished Dart queries, as script/druid build does. Co-Authored-By: Claude Opus 5.5 --- .../embedded/console/WebConsoleTestBase.java | 3 + .../embedded/msq/DartWebConsoleTest.java | 49 ++++++++ web-console/E2E_BETTERMENT_PLAN.md | 5 +- web-console/e2e-tests/README.md | 8 +- .../component/workbench/dart-panel.ts | 90 ++++++++++++++ .../e2e-tests/component/workbench/overview.ts | 74 ++++++++++-- web-console/e2e-tests/dart.spec.ts | 112 ++++++++++++++++++ web-console/e2e-tests/util/sql.ts | 16 +++ 8 files changed, 339 insertions(+), 18 deletions(-) create mode 100644 embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/DartWebConsoleTest.java create mode 100644 web-console/e2e-tests/component/workbench/dart-panel.ts create mode 100644 web-console/e2e-tests/dart.spec.ts diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java index e765bfabdc7d..ee62e292ad17 100644 --- a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/console/WebConsoleTestBase.java @@ -97,6 +97,9 @@ protected final EmbeddedDruidCluster createCluster() // SleepModule has the sleep() function, for queries that run long enough to be canceled cluster.addExtensions(SketchModule.class, HllSketchModule.class, DoublesSketchModule.class, SleepModule.class) .addCommonProperty("druid.msq.dart.enabled", "true") + // Keep the reports of finished Dart queries, for the console to show them (as script/druid build does) + .addCommonProperty("druid.msq.dart.controller.maxRetainedReportCount", "100") + .addCommonProperty("druid.msq.dart.controller.maxRetainedReportDuration", "PT3600S") .addCommonProperty("druid.sql.planner.enableSysQueriesTable", "true") // The console waits for the cluster state to change, so make it change quickly .addCommonProperty("druid.manager.segments.pollDuration", "PT1S") diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/DartWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/DartWebConsoleTest.java new file mode 100644 index 000000000000..49081fc525db --- /dev/null +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/DartWebConsoleTest.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.druid.testing.embedded.msq; + +import org.apache.druid.testing.embedded.EmbeddedDruidCluster; +import org.apache.druid.testing.embedded.console.WebConsoleTestBase; +import org.junit.jupiter.api.Test; + +/** + * Runs and cancels Dart queries from the Query view of the web console, following them in its current Dart queries + * panel, as {@link EmbeddedDartReportApiTest} does through the API. + */ +public class DartWebConsoleTest extends WebConsoleTestBase +{ + @Override + protected void configureCluster(EmbeddedDruidCluster cluster) + { + // Dart runs the controller of a query on the Broker and its workers on the Historicals, with a part of their heap + // (as in EmbeddedDartReportApiTest), which needs to be more than the 100 MB that embedded servers have by default + broker.setServerMemory(1_000_000_000L) + .addProperty("druid.msq.dart.controller.heapFraction", "0.5"); + historical.setServerMemory(1_000_000_000L) + .addProperty("druid.msq.dart.worker.heapFraction", "0.5") + .addProperty("druid.msq.dart.worker.concurrentQueries", "1"); + } + + @Test + public void testDart() throws Exception + { + runSpec("dart.spec.ts"); + } +} diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md index b0a38fc4b0a2..a0b49940538b 100644 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ b/web-console/E2E_BETTERMENT_PLAN.md @@ -128,8 +128,9 @@ SQL-based ingestion (MSQ) and the Query view: `sql-ingestion.spec.ts`, run by `msq/SqlIngestionWebConsoleTest` - [ ] Export, `INSERT INTO EXTERN` (`MultiStageQueryTest.testExport`) - [ ] SQL ingestion / MSQ `SELECT` from S3 (`s3/ITS3SQLBasedIngestionTest`, `msq/S3ExternQueryTest`) -- [ ] Dart: running and recent queries, reports, cancel (`msq/EmbeddedDartReportApiTest`): Dart engine, current - Dart queries panel +- [x] Dart: running and recent queries, reports, cancel (`msq/EmbeddedDartReportApiTest`): Dart engine, current + Dart queries panel. Done: `dart.spec.ts`, run by `msq/DartWebConsoleTest` (which gives the Broker and Historical + the memory Dart needs; the base cluster now keeps Dart reports, as `script/druid build` does) - [ ] Query errors: parse, validation, timeout, capacity, resource limits (`query/QueryErrorTest`): error pane - [ ] Query blocklist, default query context (`server/EmbeddedBrokerDynamicConfigTest`): Broker dynamic config dialog diff --git a/web-console/e2e-tests/README.md b/web-console/e2e-tests/README.md index 9dfb7d202e72..e6fbf5c5e514 100644 --- a/web-console/e2e-tests/README.md +++ b/web-console/e2e-tests/README.md @@ -76,12 +76,14 @@ the spec needs, and runs the spec on it with `npx playwright test `: | `datasource-actions.spec.ts` | `indexing.DatasourceActionsWebConsoleTest` | | `retention-rules.spec.ts` | `server.RetentionRulesWebConsoleTest` | | `jdbc-lookup.spec.ts` | `lookup.JdbcLookupWebConsoleTest` | +| `dart.spec.ts` | `msq.DartWebConsoleTest` | | `input-formats.spec.ts` | Loads the same 10 rows (over 2 days) of Wikipedia edits from local CSV, TSV, Parquet, ORC and Avro OCF files through the data loader, one test per format. Checks that the **Parse data** step picks the right input format by itself and parses the columns, and that the datasource ends up as 2 segments with 10 rows. Skipped without `DRUID_E2E_TEST_DATA_DIR` (the data files of embedded-tests): it runs on an embedded cluster with the Avro, Parquet and ORC extensions, from `InputFormatsWebConsoleTest`. | | `kafka-ingestion.spec.ts` | Follows the [Kafka tutorial](https://druid.apache.org/docs/latest/tutorials/tutorial-kafka): sets up a supervisor through the data loader's **Apache Kafka** connector on a topic with the 39,244 edits of the tutorial file (reading it from the earliest offset), and waits for the rows to be queryable. Then, from the **Supervisors** view, suspends the supervisor, resumes it and terminates it, checking its state each time (through SQL on `sys.supervisors` and in the view). Skipped without `DRUID_E2E_TEST_KAFKA_BOOTSTRAP_SERVERS` and `DRUID_E2E_TEST_KAFKA_TOPIC`: it runs on an embedded cluster with a Kafka container, from `KafkaWebConsoleTest`. | | `sql-ingestion.spec.ts` | SQL-based ingestion (MSQ tasks). Loads the tutorial file through the **SQL data loader** ("Load data" > "Batch - SQL": input source, parse, schema with a new datasource as the destination, ingestion progress) and waits for its 39,244 rows to be queryable. Then, in the **Query** view, ingests it with `REPLACE ... FROM TABLE(EXTERN(...))` and reindexes the 11,549 rows of `#en.wikipedia` into another datasource with `REPLACE ... FROM` the first, checking what the view says it inserted and the rows queryable each time. | | `datasource-actions.spec.ts` | Loads `wikipedia-index.json` through the API, then from the **Datasources** view: marks all its segments unused (the datasource then shows, as "Unused", only with "Show unused"), marks them used again (the data is back, fully available), marks them unused again and deletes them with a kill task (ticking the dialog's checks of what it does), after which the kill task succeeds and the datasource is gone even with "Show unused". | | `retention-rules.spec.ts` | Loads `wikipedia-index.json` through the API, then edits its retention rules from the **Datasources** view (deleting the rules there, adding new ones and picking their type, then giving the reason the dialog asks for): a `loadForever` rule (as "New rule" makes it, 2 replicas), which the Retention column shows and with which the data stays loaded, then a `dropForever` rule in its place, with which the Coordinator drops the data (marking it unused). Checks the rules through the Coordinator API each time. | | `jdbc-lookup.spec.ts` | From the **Lookups** view, initializes the lookups (if the cluster has none) and adds a JDBC lookup (cachedNamespace, jdbc) on a table of country codes and names, checks that the view lists it, and waits for `LOOKUP('PR', ...)` and `LOOKUP('AU', ...)` to return the names. Skipped without `DRUID_E2E_TEST_LOOKUP_CONNECT_URI` and `DRUID_E2E_TEST_LOOKUP_TABLE`: it runs on an embedded cluster, with the table in its Derby metadata store, from `JdbcLookupWebConsoleTest`. | +| `dart.spec.ts` | Dart (the multi-stage query engine on the Broker and Historicals). Loads `wikipedia-index.json` through the API, picks **SQL (Dart)** in the **Query** view's engine menu and runs a top channels query, checking the results, that the query was sent with the `msq-dart` engine and that it is in `sys.queries` as SUCCESS. Then, in the **Current Dart queries** panel, finds it as succeeded and opens its details (the stages of its report). A second test starts a long Dart query (sleeping for each row) and cancels it from the panel, which then shows it as canceled, as `sys.queries` does. | They extend `console.WebConsoleTestBase`. There's no distribution to build and nothing left behind, as every test class gets a new cluster. They need this checkout's Druid modules installed (`mvn install -DskipTests`), with the @@ -143,8 +145,8 @@ e2e-tests/ (data-connector.ts: local disk, S3, Kafka, reindex from Druid; partitions-spec.ts: hashed, range); sql-data-loader.ts: the SQL data loader (loadDataWithSql) supervisors/ Supervisors view (reads the table, runs and confirms a supervisor's actions) - workbench/ Query view: run a query or an ingestion query (accepting the task slot warning), cancel a - query + workbench/ Query view: run or start a query, or an ingestion query, with the engine picked (accepting the + task slot warning), cancel a query; dart-panel.ts: the current Dart queries panel util/ fixtures.ts the `test` and `expect` to import in specs (`test` logs failed responses, shows the last step, and has the newDatasourceName fixture) @@ -152,7 +154,7 @@ e2e-tests/ druid.ts the tutorial data dir and ingestion specs, runTask (submits a task and waits for it), deleteDatasource sql.ts querySql, and the cluster state the tests wait for: task statuses, a datasource's segments - and queryable rows, a supervisor's state + and queryable rows, a supervisor's state, a Dart query's state playwright.ts openView, and helpers that find inputs and buttons by their label or text table.ts extractTable / extractTableRecords: read a table's rows as text (by position / by header) steps.ts showStep: a screenshot of a step of the test, with E2E_SHOW_STEPS=true diff --git a/web-console/e2e-tests/component/workbench/dart-panel.ts b/web-console/e2e-tests/component/workbench/dart-panel.ts new file mode 100644 index 000000000000..ab5d55197186 --- /dev/null +++ b/web-console/e2e-tests/component/workbench/dart-panel.ts @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Locator, Page } from '@playwright/test'; + +import { clickButton, clickMenuItem } from '../../util/playwright'; +import { showStep } from '../../util/steps'; + +const STATES = ['ACCEPTED', 'RUNNING', 'SUCCESS', 'FAILED', 'CANCELED']; + +/** + * Represents the "Current Dart queries" panel of the Query view, which lists the Dart queries (running and recent). + */ +export class CurrentDartPanel { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + private readonly panel = () => this.page.locator('.current-dart-panel'); + + /** + * Shows the panel (in the Query view, which must be open), if it isn't shown. The view remembers it. + */ + async show(): Promise { + if (await this.panel().isVisible()) return; + await this.page.locator('[data-tooltip="Open helper panels"]').click(); + await this.page.getByText('Current Dart query panel', { exact: true }).click(); + await this.panel().waitFor(); + } + + /** + * The state of the query (like RUNNING or SUCCESS) as the panel shows it, or null when it doesn't list it (yet). + */ + async getQueryState(sqlQueryId: string): Promise { + const entry = this.entry(sqlQueryId); + if (!(await entry.count())) return null; + + const iconClasses = (await entry.locator('.status-icon').getAttribute('class')) ?? ''; + return STATES.find(state => iconClasses.split(' ').includes(state.toLowerCase())) ?? null; + } + + /** + * Opens the execution details of the query (its report) and returns the dialog. + */ + async showDetails(sqlQueryId: string): Promise { + await this.entry(sqlQueryId).click(); + await clickMenuItem(this.page, 'Show details'); + + const dialog = this.page.locator('.execution-details-dialog'); + await dialog.waitFor(); + return dialog; + } + + /** + * Cancels the query, confirming it. + */ + async cancelQuery(sqlQueryId: string): Promise { + await this.entry(sqlQueryId).click(); + await clickMenuItem(this.page, 'Cancel query'); + + const confirmation = this.page.locator('.bp6-alert'); + await confirmation.waitFor(); + await showStep(this.page, 'Current Dart queries panel: cancel the query, to confirm'); + await clickButton(confirmation, 'Cancel query'); + await confirmation.waitFor({ state: 'detached' }); + } + + private entry(sqlQueryId: string): Locator { + return this.panel() + .locator('.work-entry') + .filter({ has: this.page.locator(`[data-tooltip$="SQL ID: ${sqlQueryId}"]`) }); + } +} diff --git a/web-console/e2e-tests/component/workbench/overview.ts b/web-console/e2e-tests/component/workbench/overview.ts index 2a4fb7c02e45..74b34054ed14 100644 --- a/web-console/e2e-tests/component/workbench/overview.ts +++ b/web-console/e2e-tests/component/workbench/overview.ts @@ -18,16 +18,38 @@ import type { Locator, Page, Request } from '@playwright/test'; -import { clickButton, openView, setQueryInput } from '../../util/playwright'; +import { expect } from '../../util/fixtures'; +import { clickButton, clickMenuItem, openView, setQueryInput } from '../../util/playwright'; import { showStep } from '../../util/steps'; import { extractTable } from '../../util/table'; +/** + * How to run a query. + */ +export interface QueryOptions { + /** The engine to pick in the view's engine menu, by its name there (like "SQL (Dart)"), rather than "Auto" */ + readonly engine?: string; +} + +/** + * What the view sent to run a query. + */ +export interface SubmittedQuery { + /** The engine of the query's context, which the view sets for the engine picked (like "msq-dart") */ + readonly engine?: string; + /** The ID that the view gives the query (SQL queries, natively or with Dart), to cancel it */ + readonly sqlQueryId?: string; +} + /** * Represents the Query view (the workbench). */ export class WorkbenchOverview { private readonly page: Page; + /** What the view sent to run the last query run (or started) */ + lastSubmittedQuery: SubmittedQuery | undefined; + constructor(page: Page) { this.page = page; } @@ -35,9 +57,9 @@ export class WorkbenchOverview { /** * Runs a query and returns its results (as text, one array of cells per row). Throws if the query fails. */ - async runQuery(query: string): Promise { + async runQuery(query: string, options: QueryOptions = {}): Promise { const results = this.page.locator('.result-table-pane'); - await this.run(query, results); + await this.run(query, options, results); await showStep(this.page, 'Query view: the query results'); return await extractTable(results); } @@ -46,17 +68,38 @@ export class WorkbenchOverview { * Runs an ingestion query (INSERT or REPLACE, run as an MSQ task) and returns what the view says when it is done * (like "39,244 rows inserted into ..."). Throws if the query fails. */ - async runIngestQuery(query: string): Promise { + async runIngestQuery(query: string, options: QueryOptions = {}): Promise { const ingestSuccess = this.page.locator('.ingest-success-pane'); - await this.run(query, ingestSuccess); + await this.run(query, options, ingestSuccess); await showStep(this.page, 'Query view: the data ingested'); return await ingestSuccess.innerText(); } - private async run(query: string, done: Locator): Promise { + /** + * Starts a query, without waiting for it to finish, and returns what the view sent to run it. + */ + async startQuery(query: string, options: QueryOptions = {}): Promise { + await this.submit(query, options); + await showStep(this.page, 'Query view: the query running'); + return this.lastSubmittedQuery!; + } + + private async run(query: string, options: QueryOptions, done: Locator): Promise { + await this.submit(query, options); + + const error = this.page.locator('.execution-error-pane'); + await done.or(error).waitFor({ timeout: QUERY_TIMEOUT }); + if (await error.isVisible()) { + await showStep(this.page, 'Query view: the query failed'); + throw new Error(`Query failed: ${await error.innerText()}`); + } + } + + private async submit(query: string, options: QueryOptions): Promise { await openView(this.page, 'workbench'); await setQueryInput(this.page, query); + if (options.engine) await this.pickEngine(options.engine); // The view shows the result of the last query until it sends the new one (even after a reload) const queryRequest = this.page.waitForRequest(request => isQueryRequest(request, query), { timeout: QUERY_TIMEOUT, @@ -71,14 +114,19 @@ export class WorkbenchOverview { if (await capacityAlert.isVisible()) { await runAnyway(this.page, capacityAlert); } - await queryRequest; - const error = this.page.locator('.execution-error-pane'); - await done.or(error).waitFor({ timeout: QUERY_TIMEOUT }); - if (await error.isVisible()) { - await showStep(this.page, 'Query view: the query failed'); - throw new Error(`Query failed: ${await error.innerText()}`); - } + const context = (await queryRequest).postDataJSON()?.context ?? {}; + this.lastSubmittedQuery = { engine: context.engine, sqlQueryId: context.sqlQueryId }; + } + + private async pickEngine(engine: string): Promise { + const engineButton = this.page.getByRole('button', { name: /^Engine: / }); + await engineButton.click(); + await clickMenuItem(this.page, engine); + // The menu stays open (to pick other settings in it) + await this.page.keyboard.press('Escape'); + await expect(engineButton).toHaveText(`Engine: ${engine}`); + await showStep(this.page, `Query view: engine ${engine} picked`); } /** diff --git a/web-console/e2e-tests/dart.spec.ts b/web-console/e2e-tests/dart.spec.ts new file mode 100644 index 000000000000..4048d8b2b844 --- /dev/null +++ b/web-console/e2e-tests/dart.spec.ts @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { APIRequestContext } from '@playwright/test'; +import { T } from 'druid-query-toolkit'; + +import { CurrentDartPanel } from './component/workbench/dart-panel'; +import { WorkbenchOverview } from './component/workbench/overview'; +import { readTutorialIngestionSpec, runTask } from './util/druid'; +import { expect, test } from './util/fixtures'; +import { CLUSTER_STATE_POLL, getDartQueryState, getDatasourceSegments } from './util/sql'; +import { showStep } from './util/steps'; + +const DART = { engine: 'SQL (Dart)' }; + +// Dart runs queries with the multi-stage query engine on the Broker and the Historicals (rather than in tasks), so it +// needs to be enabled (druid.msq.dart.enabled=true), as it is on the embedded clusters +test.describe('Dart', () => { + test('Runs a query with Dart, which the current Dart queries panel shows with its details', async ({ + page, + request, + newDatasourceName, + }) => { + const datasourceName = newDatasourceName('dart'); + await loadData(request, datasourceName); + + const workbench = new WorkbenchOverview(page); + const results = await workbench.runQuery( + `SELECT channel, CAST(COUNT(*) AS VARCHAR) AS "count" +FROM ${T(datasourceName)} +GROUP BY 1 +ORDER BY COUNT(*) DESC +LIMIT 2`, + DART, + ); + expect(results).toEqual([ + ['#en.wikipedia', '11549'], + ['#vi.wikipedia', '9747'], + ]); + const { engine, sqlQueryId } = workbench.lastSubmittedQuery!; + expect(engine).toBe('msq-dart'); + expect(await getDartQueryState(request, sqlQueryId!)).toBe('SUCCESS'); + + const dartPanel = new CurrentDartPanel(page); + await dartPanel.show(); + await expect + .poll(() => dartPanel.getQueryState(sqlQueryId!), CLUSTER_STATE_POLL) + .toBe('SUCCESS'); + await showStep(page, 'Current Dart queries panel: the query SUCCESS'); + + // The details are the report of the query, with its stages + const details = await dartPanel.showDetails(sqlQueryId!); + await expect(details.locator('.execution-stages-pane .ct-tbody .ct-tr').first()).toBeVisible(); + }); + + test('Cancels a running Dart query from the current Dart queries panel', async ({ + page, + request, + newDatasourceName, + }) => { + const datasourceName = newDatasourceName('dart-cancel'); + await loadData(request, datasourceName); + + // Sleeps for each row (10 ms, so minutes for all of them). The sleep depends on a column, as a constant one would + // be done once, when the query is planned. + const workbench = new WorkbenchOverview(page); + const { sqlQueryId } = await workbench.startQuery( + `SELECT COUNT(*) +FROM ${T(datasourceName)} +WHERE sleep(CASE WHEN "added" > 0 THEN 0.01 ELSE 0.02 END) IS NULL`, + DART, + ); + + const dartPanel = new CurrentDartPanel(page); + await dartPanel.show(); + await expect + .poll(() => dartPanel.getQueryState(sqlQueryId!), CLUSTER_STATE_POLL) + .toBe('RUNNING'); + await showStep(page, 'Current Dart queries panel: the query RUNNING'); + + await dartPanel.cancelQuery(sqlQueryId!); + await expect + .poll(() => dartPanel.getQueryState(sqlQueryId!), CLUSTER_STATE_POLL) + .toBe('CANCELED'); + await showStep(page, 'Current Dart queries panel: the query CANCELED'); + expect(await getDartQueryState(request, sqlQueryId!)).toBe('CANCELED'); + }); +}); + +async function loadData(request: APIRequestContext, datasourceName: string) { + const ingestionSpec = readTutorialIngestionSpec('wikipedia-index.json'); + ingestionSpec.spec.dataSchema.dataSource = datasourceName; + await runTask(request, ingestionSpec); + await expect + .poll(() => getDatasourceSegments(request, datasourceName), CLUSTER_STATE_POLL) + .toEqual({ numSegments: 1, numAvailableSegments: 1, numRows: 39244 }); +} diff --git a/web-console/e2e-tests/util/sql.ts b/web-console/e2e-tests/util/sql.ts index 5e760d28b6c7..33abd51e40fb 100644 --- a/web-console/e2e-tests/util/sql.ts +++ b/web-console/e2e-tests/util/sql.ts @@ -129,3 +129,19 @@ export async function getSupervisorState( ); return row ? String(row['detailed_state']) : null; } + +/** + * The state of a Dart query (like RUNNING or SUCCESS) in sys.queries, or null when it isn't there (not yet, or not + * anymore). + */ +export async function getDartQueryState( + request: APIRequestContext, + sqlQueryId: string, +): Promise { + const [row] = await querySql( + request, + `SELECT "state" FROM sys.queries WHERE "engine" = 'msq-dart' AND "info" LIKE ?`, + [`%${sqlQueryId}%`], + ); + return row ? String(row['state']) : null; +} From 79af9c07e051c29886eb117db7180191e3a2ec54 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Fri, 9 Oct 2026 10:11:39 -0700 Subject: [PATCH 24/26] delete the e2e betterment plan Co-Authored-By: Claude Opus 5.5 --- web-console/E2E_BETTERMENT_PLAN.md | 187 ----------------------------- 1 file changed, 187 deletions(-) delete mode 100644 web-console/E2E_BETTERMENT_PLAN.md diff --git a/web-console/E2E_BETTERMENT_PLAN.md b/web-console/E2E_BETTERMENT_PLAN.md deleted file mode 100644 index a0b49940538b..000000000000 --- a/web-console/E2E_BETTERMENT_PLAN.md +++ /dev/null @@ -1,187 +0,0 @@ - - -# E2E test betterment plan (temporary, delete before merging) - -The e2e tests (`e2e-tests/`) were written in a Puppeteer style: Jest as the runner, the `playwright-chromium` library, -XPath selectors and element handles. Much of `util/` re-implements what Playwright now provides. This is the plan to -modernize them, in order. Each step should leave the suite passing. - -## 1. Move to the `@playwright/test` runner (done) - -- Add `@playwright/test` (same version as `playwright-chromium`, which stays for now because its install script - downloads the Chromium the runner uses, so CI needs no extra `playwright install` step). -- Add `playwright.config.ts`: `testDir: 'e2e-tests'`, `workers: 1` (the specs share one cluster), the 5 minute - timeout, `baseURL` from `DRUID_E2E_TEST_UNIFIED_CONSOLE_PORT`, `headless` from `DRUID_E2E_TEST_HEADLESS`, - `screenshot: 'only-on-failure'`, `trace: 'retain-on-failure'`, a `globalSetup` that waits for the console. -- `npm run test-e2e` becomes `playwright test`; drop `jest.e2e.config.js`. -- Replaces: the `beforeAll`/`beforeEach`/`afterAll` boilerplate in every spec (`page` fixture), - `util/setup.ts` (`globalSetup`), `util/debug.ts` (failure screenshots and traces), `util/retry.ts` - (`expect.poll` / `expect(...).toPass()`), and `createBrowser`/`createPage` (the page fixture; the logging of failed - responses moves to a fixture). -- Update `e2e-tests/README.md`, `README.md` (running e2e tests), `AGENTS.md`, `.gitignore` (`test-results/`, - `playwright-report/` instead of `/*.jpeg`), and the eslint override. - -## 2. Locators instead of XPath and element handles (done) - -- `util/playwright.ts`: `getByRole('button', { name })`, labeled fields found by their `FormGroup` label rather than - `//*[text()="..."]/following-sibling::div//input`, no `page.$` / `$$eval` / `waitForSelector` / `ElementHandle`. -- Web-first assertions (`expect(locator).toBeVisible()` / `toHaveText()`) instead of `waitForSelector`. -- `openEditActions`: find the datasource's row and click its actions button, rather than the Nth `.bp6-icon-more`. -- `selectSuggestibleInput`: no legacy `'"value"'` text selector. -- Found on the way: the Datasources and Tasks tables page at 50 rows, so on a cluster with leftover datasources the - test's own row could be on page 2. `openView` now filters the view to the datasource through its hash route. -- Found on the way (console bugs, fixed in their own commit, could be their own PR): `TableFilters.toString()` didn't - encode `#` or `?`, which end the hash route, and a filter value couldn't contain `|`, which separates values (so - going from a datasource with any of those in its name to its tasks or segments filtered on the wrong thing). - Now `#` and `?` are encoded, and `\|` / `\\` escape `|` / `\` in a value. - -## 3. Don't read tables by column position (done) - -- The `DatasourceColumn` / `TaskColumn` enums broke when a default visible column changed. -- The tests now wait for the cluster state through SQL (`util/sql.ts`: top-level task statuses from `sys.tasks`, - used and available segments and rows from `sys.segments`), with `expect.poll`. Only the tutorial test still checks - the Datasources and Tasks views, once the cluster is in the state they should show, reading the tables by their - headers (`extractTableRecords`). -- Learned: `index_parallel` subtasks share the datasource (filter on `task_id = group_id`), `sys.segments` returns - no row for an aggregate over no segments, and `expect.poll` stops (does not retry) when its callback throws. - -## 4. Set up data through the HTTP API (done) - -- `runIndexTask` shelled out to `sed` + `examples/bin/post-index-task` (bash + Python) and hard-coded the Coordinator - on :8081. Now `readTutorialIngestionSpec` reads the spec JSON (pointing its input at this checkout), the spec sets - the datasource name / interval on it, and `runIndexTask` `POST`s it to `/druid/indexer/v1/task` with the `request` - fixture (so through the console's service) and polls the task status, failing with the task's error if it fails. - -## 5. Clean up after each test (done) - -- The `newDatasourceName(prefix)` fixture makes a unique name and, after the test passes, deletes the datasource - (`deleteDatasource`: shut down its tasks, delete its compaction config, mark its segments unused, run a `kill` task - and wait for it). After a failure the datasource is kept to look into. -- Found on the way (console bug, fixed in its own commit, could be its own PR): `Api.encodePath` left `^` and `|` as - they are, which the browser sends as is and Jetty rejects (400 Illegal Path Character), so every action on a - datasource with one of those in its name (mark unused, kill, retention rules, compaction config) failed. - -## 6. Simplify the code (done) - -- Merged `component/query/overview.ts` into `component/workbench/overview.ts` (both drove `#workbench`). -- Plain object types instead of the `Object.assign(this, props)` + interface merging data classes, and union types - with functions for the partitions specs and data connectors; the data loader is a `loadData(page, config)` - function with one flat config. The `no-unsafe-declaration-merging` eslint override is gone. -- Done earlier, in steps 1-2: no `goto` + `reload({ waitUntil: 'networkidle' })`, one `expect` in `cancel-query`, - no `--disable-local-storage`. - -## 7. More coverage: what the embedded tests cover that the console can do, without a console e2e test - -In the order to do them: (1) input formats, (2) Kafka and the Supervisors view, (3) SQL-based ingestion, (4) the -Datasources view's destructive actions, (5) retention rules and lookups; then the rest. Each goes in a -`*WebConsoleTest` next to the embedded tests of its functionality. - -Classic data loader, connectors: -- [ ] HTTP (`indexer/ITHttpInputSourceTest`): "HTTP(s)" card -- [ ] Inline (`indexing/IndexTaskTest`): "Paste data" card -- [ ] Azure (`azure/ITAzureToAzureParallelIndexTest`, `ITAzureV2ParallelIndexTest`): "Azure" card -- [ ] Google Cloud Storage (`gcs/ITGcsToGcsParallelIndexTest`): "Google Cloud Storage" card -- [ ] HDFS (`hdfs/HdfsToHdfsParallelIndexTest`): "HDFS" card -- [ ] Delta Lake (`deltalake/DeltaLakeInputSourceIngestionTest`): "Delta Lake" card - -Classic data loader, formats and steps: -- [x] (1) CSV, TSV, Parquet, ORC, Avro OCF (`indexer/ITLocalInputSourceAllInputFormatTest`, - `ITLocalInputSourceAllFormatSchemalessTest`): Parse data step. Done: `input-formats.spec.ts`, run by - `indexer/InputFormatsWebConsoleTest` -- [ ] Transforms (`indexer/ITTransformTest`): Transform step -- [ ] Nested columns (`indexing/NestedDataFormatsTest`): Configure schema step -- [ ] Overwrite, with and without dropping existing data (`indexer/ITOverwriteBatchIndexTest`): Publish step - -Streaming and the Supervisors view: -- [x] (2) Kafka supervisor (`IngestionSmokeTest.test_runKafkaSupervisor`, `kafka/simulate/EmbeddedKafkaSupervisorTest`): - "Apache Kafka" card. Done: `kafka-ingestion.spec.ts`, run by `indexing/KafkaWebConsoleTest` -- [ ] Kafka formats: Avro and Protobuf with or without a schema registry, CSV... (`indexing/KafkaIndexDataFormatsTest`) -- [ ] Kinesis supervisor and formats (`kinesis/KinesisDataFormatsTest`): "Amazon Kinesis" card -- [x] (2) Suspend / resume (`KafkaIndexFaultToleranceTest`, `KinesisFaultToleranceTest`), terminate - (`server/KillSupervisorsCustomDutyTest`): Supervisors view actions. Done: in `kafka-ingestion.spec.ts` -- [ ] Handoff early (`StreamIndexFaultToleranceTest`), reset to latest and backfill (`KafkaBoundedSupervisorTest`): - Supervisors view actions (`SupervisorsOverview.runAction` has them, or is a line away) -- [ ] Several topics through a topic pattern (`server/KafkaMultiSupervisorTest`): Kafka connector's "Topic pattern" -- [ ] Editing a running supervisor (`IngestionSmokeTest.test_kafkaSupervisor_modifiedAndRestartedCombinations`) - -SQL-based ingestion (MSQ) and the Query view: -- [x] (3) `INSERT` / `REPLACE` from external data (`msq/ITSQLBasedBatchIngestionTest`, `MultiStageQueryTest`, - `IngestionSmokeTest.test_ingestWikipedia1DayWithMSQ`): Query view with MSQ, "Batch - SQL" data loader -- [x] (3) Reindex with `REPLACE ... SELECT FROM` a datasource (`msq/ITMSQReindexTest`). Done (both): - `sql-ingestion.spec.ts`, run by `msq/SqlIngestionWebConsoleTest` -- [ ] Export, `INSERT INTO EXTERN` (`MultiStageQueryTest.testExport`) -- [ ] SQL ingestion / MSQ `SELECT` from S3 (`s3/ITS3SQLBasedIngestionTest`, `msq/S3ExternQueryTest`) -- [x] Dart: running and recent queries, reports, cancel (`msq/EmbeddedDartReportApiTest`): Dart engine, current - Dart queries panel. Done: `dart.spec.ts`, run by `msq/DartWebConsoleTest` (which gives the Broker and Historical - the memory Dart needs; the base cluster now keeps Dart reports, as `script/druid build` does) -- [ ] Query errors: parse, validation, timeout, capacity, resource limits (`query/QueryErrorTest`): error pane -- [ ] Query blocklist, default query context (`server/EmbeddedBrokerDynamicConfigTest`): Broker dynamic config dialog - -Datasources, Segments and Tasks views: -- [x] (4) Mark segments unused / used (`IngestionSmokeTest`, `OverlordClientTest`, `ConcurrentAppendReplaceTest`) -- [x] (4) Delete data with a kill task (`IngestionSmokeTest.test_runIndexTask_andKillData`, - `OverlordClientTest.test_runKillTask`). Done (both): `datasource-actions.spec.ts`, run by - `indexing/DatasourceActionsWebConsoleTest` -- [x] (5) Retention rules (`query/BroadcastJoinQueryTest`, `server/CoordinatorClientTest`). Done: - `retention-rules.spec.ts`, run by `server/RetentionRulesWebConsoleTest`. A drop rule marks the segments unused (so - the datasource loses its "Edit retention rules" action), it doesn't only unload them -- [ ] Compaction supervisors and cluster compaction config (`compact/CompactionSupervisorTest`) -- [ ] Compaction to another segment granularity (`CompactionSupervisorTest`, `CompactionTaskTest`) -- [ ] Cancel a running task (`OverlordClientTest.test_cancelTask_*`): Tasks view "Kill" -- [ ] Stream a task's log (`IngestionSmokeTest.test_streamLogs_ofCancelledTask`) -- [ ] Submit a JSON task (`OverlordClientTest.test_runTask_ofTypeNoop`) -- [ ] Segment counts (`query/SystemTableQueryTest`): Segments view - -Services, Lookups, dynamic configs: -- [ ] Server types, workers and capacity (`SystemTableQueryTest`, `OverlordClientTest.test_getWorkers`): Services view -- [x] (5) JDBC lookup (`lookup/JdbcLookupTest`): Lookups view. Done: `jdbc-lookup.spec.ts`, run by - `lookup/JdbcLookupWebConsoleTest` -- [ ] Pause coordination (`server/CoordinatorPauseTest`), turbo loading (`server/HistoricalCloningTest`): Coordinator - dynamic config dialog - -Left out: no console UI (SQL, combining and Iceberg input sources, catalog DDL, JDBC queries), not a console user's concern (TLS, HA, Consul, Kubernetes, metadata stores, emitters, autoscaling, -partial loading, faults, performance). Borderline: basic auth, what a restricted user sees (with `auth/`). - -## 8. Run on embedded clusters (prototype done) - -Prototype (option B: JUnit drives, the specs stay in TypeScript): `WebConsoleTestBase` in `embedded-tests` -(`org.apache.druid.testing.embedded.console`), tag `web-console`, profile `web-console-tests`, and a -`*WebConsoleTest` per functionality, in its package (`indexing`, `compact`, `msq`, `query`, `s3`). The 5 -specs pass unchanged on it (50s for the whole Maven run), with a keep-alive mode for working on a spec. -`S3WebConsoleTest` (with `S3StorageResource`) runs `s3-ingestion.spec.ts`, which loads from `s3://` through the data -loader (24s for the test class). - -CI: `web-checks.sh` installs `web-console` (with the console built), then runs -`mvn verify -pl 'embedded-tests,!web-console' -am -Pweb-console-tests -DskipUTs`, which builds the 38 modules -embedded-tests needs (not the whole of Druid, nor a distribution). `worker.yml` uploads -`embedded-tests/target/failsafe-reports/` (the Druid and Playwright logs) and `web-console/test-results/` (instead of -the distribution's logs), and the JUnit report shows the specs' results. `script/druid` stays for running the tests -against a quickstart locally. - -Found on the way (fixed in the specs, they were latent races that the quickstart's timing hid): -- `extractTableRecords` read a view's table right after opening it, before the view loaded its data. -- `cancelQuery` waited for any SQL `POST` (it could be the column tree's) and canceled before the query was sent. - -Also done: -- The specs don't delete their datasources on an embedded cluster (`runSpec` sets - `DRUID_E2E_TEST_CLUSTER_IS_DISPOSABLE=true`, except in keep-alive mode), as it's thrown away after the class. -- `WebConsoleSpecsTest` (a unit test, no cluster) checks that every spec is run by a `*WebConsoleTest`, and that they - run only specs that exist. -- Each spec has its own `@Test`, so the JUnit report names them. From 9a4bc19fad6467ebf3cf244098fe7d25a3570a70 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Fri, 9 Oct 2026 16:21:32 -0700 Subject: [PATCH 25/26] close the data file in KafkaWebConsoleTest even when reading its gzip header fails Addresses the CodeQL alert (potential input resource leak): the FileInputStream was created inside the GZIPInputStream wrapper, whose constructor reads the header and can throw before the try-with-resources has a resource to close. Co-Authored-By: Claude Opus 5.5 --- .../testing/embedded/indexing/KafkaWebConsoleTest.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/KafkaWebConsoleTest.java b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/KafkaWebConsoleTest.java index 49e6e2c2addf..1351ecab7df9 100644 --- a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/KafkaWebConsoleTest.java +++ b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/KafkaWebConsoleTest.java @@ -29,6 +29,7 @@ import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; +import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -61,9 +62,11 @@ public void publishData() throws Exception { final File dataFile = new File(webConsoleDir(), "../examples/quickstart/tutorial/wikiticker-2015-09-12-sampled.json.gz"); final List records = new ArrayList<>(); - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(new GZIPInputStream(new FileInputStream(dataFile)), StandardCharsets.UTF_8) - )) { + // The file is its own resource, to be closed even when reading the gzip header fails + try (InputStream fileIn = new FileInputStream(dataFile); + BufferedReader reader = new BufferedReader( + new InputStreamReader(new GZIPInputStream(fileIn), StandardCharsets.UTF_8) + )) { String line; while ((line = reader.readLine()) != null) { records.add(StringUtils.toUtf8(line)); From 97edd159df188e5df2b6b576609d9c83fa90526a Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Fri, 9 Oct 2026 23:59:13 -0700 Subject: [PATCH 26/26] DO NOT MERGE: rename the SQL data loader's Start loading data button, to see an e2e test fail in CI Temporary, to be reverted: the SQL data loader test of sql-ingestion.spec.ts (run by SqlIngestionWebConsoleTest) clicks 'Start loading data', which this renames, so it should time out looking for the button. Co-Authored-By: Claude Opus 5.5 --- .../src/views/sql-data-loader-view/schema-step/schema-step.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx b/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx index b3ab62be6183..1c135d657d8c 100644 --- a/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx +++ b/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx @@ -1004,7 +1004,7 @@ export const SchemaStep = function SchemaStep(props: SchemaStepProps) {