From 66a26bad84c6206c6430a5f5573bed8372ff5f17 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 09:06:04 +0300 Subject: [PATCH 1/7] test(e2e): type into the connection form fields' EditableText, restore the form scenarios enterText needs the EditableText under the TextField, not the TextField itself; the URI and SQLite scenarios failed on it. Restores the scenarios dropped in 8b876c1 for #1050. --- .../e2e_connection_form_test.dart | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart new file mode 100644 index 00000000..03224a5d --- /dev/null +++ b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' show Text, TextField; + +import '../helpers/e2e_app_harness.dart'; + +/// #1050: connections created through the New connection dialog and the form +/// of each database type, the way a user does it. +void main() { + final app = E2eAppHarness(prefix: 'querya_e2e_forms_'); + setUpAll(app.setUpAll); + tearDownAll(app.tearDownAll); + + const _uriPlaceholder = { + 'postgresql': 'postgresql://user:pass@host:5432/dbname?sslmode=require', + 'mysql': 'mysql://user:pass@host:3306/dbname?ssl-mode=disable', + 'redis': 'rediss://user:pass@host:6379', + 'mongodb': 'mongodb://username:password@host:port/database', + }; + + Finder byPlaceholder(String text) => find.byWidgetPredicate((w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == text); + + /// The field's EditableText: enterText needs the text state, not the + /// TextField widget that wraps it. + Finder editableIn(Finder field) => find.descendant( + of: field, + matching: find.byType(material.EditableText), + ); + + Finder inSidebar(String text) => find.descendant( + of: find.byType(ConnectionsPanel), + matching: find.text(text), + ); + + Future step(WidgetTester t) => t.pump(const Duration(milliseconds: 300)); + + /// Waits for the form's save to reach the database (real I/O). + Future savedConnection(WidgetTester t, String type) async { + for (var i = 0; i < 60; i++) { + final rows = await t.runAsync(() => LocalDb.instance.getConnections()); + for (final r in rows ?? const []) { + if (r.type == type) { + await step(t); + return r; + } + } + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 25))); + await t.pump(const Duration(milliseconds: 100)); + } + return null; + } + + /// Pumps with real time in between until [finder] shows: dialogs read the + /// extension registry and secrets from disk before they open. + Future waitFor(WidgetTester t, Finder finder) async { + for (var i = 0; i < 60 && finder.evaluate().isEmpty; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 25))); + await t.pump(const Duration(milliseconds: 100)); + } + } + + /// Picks [label] in the database type dialog and goes on to its form. + Future pickType(WidgetTester t, String label) async { + await waitFor(t, find.text('Select your database')); + expect(find.text('Select your database'), findsOneWidget); + await t.tap(find.text('Select database…')); + await step(t); + // Only the visible item: the picker's own label is in the tree too. + await t.tap(find.text(label).hitTestable().last); + await step(t); + await t.tap(find.text('Next').hitTestable()); + await step(t); + await step(t); + } + + Future openNewConnection(WidgetTester t) async { + await t.tap(find.byKey(const material.Key('empty_new_connection'))); + // The handler waits for the menu overlay to close before the dialog. + await t.pump(const Duration(milliseconds: 150)); + await step(t); + } + + // The name of a URI connection comes from its address, so only the URI is + // typed; the saved row must carry the host it names. + final cases = <(String, String, String, String)>[ + ( + 'PostgreSQL', + 'postgresql', + 'postgresql://u:p@pg.example.com:5433/app', + 'pg.example.com', + ), + ( + 'MySQL', + 'mysql', + 'mysql://u:p@my.example.com:3307/app', + 'my.example.com', + ), + ( + 'Redis', + 'redis', + 'redis://:p@cache.example.com:6380', + 'cache.example.com', + ), + ( + 'MongoDB', + 'mongodb', + 'mongodb://u:p@mongo.example.com:27018/app', + 'mongo.example.com', + ), + ]; + + for (final (label, type, uri, host) in cases) { + testWidgets('a $label connection is created from its URI', + (tester) async { + await app.launch(tester); + addTearDown(() => app.resetData(tester)); + await openNewConnection(tester); + await pickType(tester, label); + + await waitFor(tester, byPlaceholder(_uriPlaceholder[type]!)); + await tester.enterText( + editableIn(byPlaceholder(_uriPlaceholder[type]!)), uri); + await tester.pump(); + await tester.tap(find.text('Save').hitTestable()); + await step(tester); + + final row = await savedConnection(tester, type); + expect(row, isNotNull, reason: '$label was not saved'); + expect('${row!.host} ${row.connectionString}', contains(host)); + expect(row.name, contains(host)); + await app.close(tester); + }); + } + + testWidgets('a SQLite connection is created through its form', + (tester) async { + await app.launch(tester); + addTearDown(() => app.resetData(tester)); + await openNewConnection(tester); + await pickType(tester, 'SQLite'); + + final path = '${app.dataDir.path}/e2e_form.db'; + await waitFor(tester, byPlaceholder('e.g. Local Cache')); + await tester.enterText( + editableIn(byPlaceholder('e.g. Local Cache')), 'E2E Form SQLite'); + await tester.pump(); + await tester.enterText( + editableIn(byPlaceholder('/path/to/database.db')), path); + await tester.pump(); + await tester.tap(find.text('Save')); + // Saving creates the database file: real I/O. + for (var i = 0; i < 10; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25))); + await tester.pump(const Duration(milliseconds: 50)); + } + + final row = await savedConnection(tester, 'sqlite'); + expect(row, isNotNull); + expect(row!.name, 'E2E Form SQLite'); + expect(row.host, path); + expect(inSidebar('E2E Form SQLite'), findsOneWidget); + await app.close(tester); + }); +} From 166bf4dd5a0e53e74783a64e06cf677d2a9f8e88 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 09:09:36 +0300 Subject: [PATCH 2/7] test(e2e): drop the leading underscore from a local identifier Fixes the no_leading_underscores_for_local_identifiers analyzer info that failed Code Analysis on the form scenarios. --- .../01_connection_lifecycle/e2e_connection_form_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart index 03224a5d..80f055dc 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart @@ -13,7 +13,7 @@ void main() { setUpAll(app.setUpAll); tearDownAll(app.tearDownAll); - const _uriPlaceholder = { + const uriPlaceholder = { 'postgresql': 'postgresql://user:pass@host:5432/dbname?sslmode=require', 'mysql': 'mysql://user:pass@host:3306/dbname?ssl-mode=disable', 'redis': 'rediss://user:pass@host:6379', @@ -124,9 +124,9 @@ void main() { await openNewConnection(tester); await pickType(tester, label); - await waitFor(tester, byPlaceholder(_uriPlaceholder[type]!)); + await waitFor(tester, byPlaceholder(uriPlaceholder[type]!)); await tester.enterText( - editableIn(byPlaceholder(_uriPlaceholder[type]!)), uri); + editableIn(byPlaceholder(uriPlaceholder[type]!)), uri); await tester.pump(); await tester.tap(find.text('Save').hitTestable()); await step(tester); From 375a8650009653d67c26d093761e89aed663b90a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 09:21:39 +0300 Subject: [PATCH 3/7] test(e2e): pick the database type by its card; fail at the step that breaks The open Database type dropdown consumes taps outside its menu, and its items come before the type cards in the element tree, so tapping the last visible label hit a card and only closed the menu. Nothing was selected, Next stayed disabled and the form never opened; waitFor timed out silently and enterText failed with 'No element'. waitFor now asserts what it waited for, and pickType checks that Next left the type dialog. The EditableText finder is gone: enterText already looks for the EditableText under the field. --- .../e2e_connection_form_test.dart | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart index 80f055dc..8bfbf8f5 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart @@ -25,13 +25,6 @@ void main() { w.placeholder is Text && (w.placeholder as Text).data == text); - /// The field's EditableText: enterText needs the text state, not the - /// TextField widget that wraps it. - Finder editableIn(Finder field) => find.descendant( - of: field, - matching: find.byType(material.EditableText), - ); - Finder inSidebar(String text) => find.descendant( of: find.byType(ConnectionsPanel), matching: find.text(text), @@ -58,26 +51,32 @@ void main() { /// Pumps with real time in between until [finder] shows: dialogs read the /// extension registry and secrets from disk before they open. - Future waitFor(WidgetTester t, Finder finder) async { + Future waitFor(WidgetTester t, Finder finder, String what) async { for (var i = 0; i < 60 && finder.evaluate().isEmpty; i++) { await t.runAsync( () => Future.delayed(const Duration(milliseconds: 25))); await t.pump(const Duration(milliseconds: 100)); } + expect(finder, findsWidgets, reason: '$what did not show'); } /// Picks [label] in the database type dialog and goes on to its form. Future pickType(WidgetTester t, String label) async { - await waitFor(t, find.text('Select your database')); - expect(find.text('Select your database'), findsOneWidget); - await t.tap(find.text('Select database…')); - await step(t); - // Only the visible item: the picker's own label is in the tree too. - await t.tap(find.text(label).hitTestable().last); + await waitFor(t, find.text('Select your database'), 'the type dialog'); + // The type card, not the "Database type" dropdown: an open dropdown menu + // consumes taps outside it, and in the element tree its items come before + // the cards, so a tap on the last visible label lands on a card and only + // closes the menu. + await t.tap(find.descendant( + of: find.byType(material.GridView), + matching: find.text(label), + )); await step(t); await t.tap(find.text('Next').hitTestable()); await step(t); await step(t); + expect(find.text('Select your database'), findsNothing, + reason: 'Next did not leave the type dialog: $label was not selected'); } Future openNewConnection(WidgetTester t) async { @@ -124,9 +123,9 @@ void main() { await openNewConnection(tester); await pickType(tester, label); - await waitFor(tester, byPlaceholder(uriPlaceholder[type]!)); - await tester.enterText( - editableIn(byPlaceholder(uriPlaceholder[type]!)), uri); + await waitFor( + tester, byPlaceholder(uriPlaceholder[type]!), 'the $label form'); + await tester.enterText(byPlaceholder(uriPlaceholder[type]!), uri); await tester.pump(); await tester.tap(find.text('Save').hitTestable()); await step(tester); @@ -147,12 +146,11 @@ void main() { await pickType(tester, 'SQLite'); final path = '${app.dataDir.path}/e2e_form.db'; - await waitFor(tester, byPlaceholder('e.g. Local Cache')); - await tester.enterText( - editableIn(byPlaceholder('e.g. Local Cache')), 'E2E Form SQLite'); + await waitFor( + tester, byPlaceholder('e.g. Local Cache'), 'the SQLite form'); + await tester.enterText(byPlaceholder('e.g. Local Cache'), 'E2E Form SQLite'); await tester.pump(); - await tester.enterText( - editableIn(byPlaceholder('/path/to/database.db')), path); + await tester.enterText(byPlaceholder('/path/to/database.db'), path); await tester.pump(); await tester.tap(find.text('Save')); // Saving creates the database file: real I/O. From f6aad9df99bc155f941a8d4da45f4966ad13315a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 09:25:31 +0300 Subject: [PATCH 4/7] test(e2e): tap the type card itself, scrolled into view At the test window size the type grid is shorter than a card and the dialog footer covers the card's label, so a tap on the label hit the footer and nothing was selected. The card's centre is visible. --- .../e2e_connection_form_test.dart | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart index 8bfbf8f5..781c11db 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_hover_surface.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' show Text, TextField; @@ -66,11 +67,18 @@ void main() { // The type card, not the "Database type" dropdown: an open dropdown menu // consumes taps outside it, and in the element tree its items come before // the cards, so a tap on the last visible label lands on a card and only - // closes the menu. - await t.tap(find.descendant( - of: find.byType(material.GridView), - matching: find.text(label), - )); + // closes the menu. The grid is shorter than a card here and the footer + // covers the label, so tap the card itself (its centre is visible). + final card = find.ancestor( + of: find.descendant( + of: find.byType(material.GridView), + matching: find.text(label), + ), + matching: find.byType(QueryaHoverSurface), + ).first; + await t.ensureVisible(card); + await step(t); + await t.tap(card); await step(t); await t.tap(find.text('Next').hitTestable()); await step(t); From 11e0222031e38134f31d464e1d63af915ffd4a1d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 09:29:48 +0300 Subject: [PATCH 5/7] test(e2e): URI connections are checked in the secure store; reach offstage type cards MySQL and Redis keep host empty for a URI connection and name it without the URI's host; every form moves the URI itself to the secure store, so that is where the scenario looks for the address. The MongoDB card is in the grid's second row, built in the cache extent and offstage for a default finder. --- .../e2e_connection_form_test.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart index 781c11db..a2848905 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/motion/querya_hover_surface.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' show Text, TextField; @@ -69,10 +70,12 @@ void main() { // the cards, so a tap on the last visible label lands on a card and only // closes the menu. The grid is shorter than a card here and the footer // covers the label, so tap the card itself (its centre is visible). + // Cards past the first row are built in the cache extent, offstage. final card = find.ancestor( of: find.descendant( of: find.byType(material.GridView), - matching: find.text(label), + matching: find.text(label, skipOffstage: false), + skipOffstage: false, ), matching: find.byType(QueryaHoverSurface), ).first; @@ -94,8 +97,7 @@ void main() { await step(t); } - // The name of a URI connection comes from its address, so only the URI is - // typed; the saved row must carry the host it names. + // Only the URI is typed; the secure store must keep the address it names. final cases = <(String, String, String, String)>[ ( 'PostgreSQL', @@ -140,8 +142,12 @@ void main() { final row = await savedConnection(tester, type); expect(row, isNotNull, reason: '$label was not saved'); - expect('${row!.host} ${row.connectionString}', contains(host)); - expect(row.name, contains(host)); + // The URI is a secret: it goes to the secure store, not querya.db. + expect(row!.connectionString, anyOf(isNull, isEmpty)); + final secrets = await tester + .runAsync(() => ConnectionSecretsStore.readForConnection(row.id!)); + expect(secrets!.connectionString, contains(host)); + expect(inSidebar(row.name), findsOneWidget); await app.close(tester); }); } From 9a889e128112060af33699a9d9cb49879c57b970 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 09:33:39 +0300 Subject: [PATCH 6/7] test(e2e): scroll the type grid to cards past the first row; wait for the sidebar The MongoDB card is not built until the grid scrolls, so scrollUntilVisible brings its label in before the tap. The sidebar reloads from the database after the save, so the scenario waits for the new name instead of checking once. --- .../e2e_connection_form_test.dart | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart index a2848905..d0a29c53 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart @@ -70,15 +70,18 @@ void main() { // the cards, so a tap on the last visible label lands on a card and only // closes the menu. The grid is shorter than a card here and the footer // covers the label, so tap the card itself (its centre is visible). - // Cards past the first row are built in the cache extent, offstage. - final card = find.ancestor( - of: find.descendant( - of: find.byType(material.GridView), - matching: find.text(label, skipOffstage: false), - skipOffstage: false, - ), - matching: find.byType(QueryaHoverSurface), - ).first; + final grid = find.byType(material.GridView); + final text = find.descendant(of: grid, matching: find.text(label)); + // Cards past the first row are not built until the grid scrolls. + await t.scrollUntilVisible( + text, + 60, + scrollable: find + .descendant(of: grid, matching: find.byType(material.Scrollable)) + .first, + ); + final card = + find.ancestor(of: text, matching: find.byType(QueryaHoverSurface)).first; await t.ensureVisible(card); await step(t); await t.tap(card); @@ -147,7 +150,8 @@ void main() { final secrets = await tester .runAsync(() => ConnectionSecretsStore.readForConnection(row.id!)); expect(secrets!.connectionString, contains(host)); - expect(inSidebar(row.name), findsOneWidget); + // The sidebar reloads from the database after the save. + await waitFor(tester, inSidebar(row.name), 'the new connection'); await app.close(tester); }); } From 880bee217fe83146c917daa33976b87c755ca2fc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 09:37:33 +0300 Subject: [PATCH 7/7] test(e2e): turn on the MongoDB form's connection string before typing the URI The MongoDB form shows its URI field only after 'Use connection string' is checked. --- .../e2e_connection_form_test.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart index d0a29c53..0ef582fc 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart @@ -135,6 +135,18 @@ void main() { addTearDown(() => app.resetData(tester)); await openNewConnection(tester); await pickType(tester, label); + if (type == 'mongodb') { + // The MongoDB form shows its URI field behind a checkbox. + await waitFor(tester, find.text('Use connection string'), 'the form'); + await tester.tap(find.descendant( + of: find.ancestor( + of: find.text('Use connection string'), + matching: find.byType(material.Row), + ).first, + matching: find.byType(material.Checkbox), + )); + await step(tester); + } await waitFor( tester, byPlaceholder(uriPlaceholder[type]!), 'the $label form');