diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fac4a9a6..44af14e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,36 @@ jobs: echo "Tag $RAW matches pubspec semver $VER." fi + # End-to-end scenarios (test/e2e) must pass before a release is published. + e2e: + name: E2E tests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.1' + channel: 'stable' + cache: true + + - name: Install Linux dependencies + timeout-minutes: 8 + run: | + APT="-o Acquire::Retries=3 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20" + sudo apt-get $APT update + # bubblewrap runs the extension sandbox scenario; without it it skips. + sudo apt-get $APT install -y libsecret-1-dev bubblewrap + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + + - name: Get dependencies + run: flutter pub get + + - name: Run E2E tests + run: flutter test test/e2e + build-windows: name: Build Windows runs-on: windows-latest @@ -436,7 +466,7 @@ jobs: publish: name: Publish GitHub Release - needs: [build-windows, build-linux, build-macos, build-ext-tester] + needs: [e2e, build-windows, build-linux, build-macos, build-ext-tester] runs-on: ubuntu-latest outputs: version: ${{ needs.build-windows.outputs.version }} diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index fa282dcb..82ad0e04 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:mongo_dart/mongo_dart.dart'; import '../storage/local_db.dart'; @@ -31,7 +32,20 @@ void _throwIfMongoWriteFailed( /// Service for managing MongoDB connections. class MongoService { MongoService._(); - static final MongoService instance = MongoService._(); + + /// A service a test subclasses to answer without a server. + @visibleForTesting + MongoService.forTest(); + + static final MongoService _real = MongoService._(); + static MongoService _instance = _real; + + static MongoService get instance => _instance; + + /// Replaces [instance] in tests; null puts the real service back. + @visibleForTesting + static set instanceForTest(MongoService? service) => + _instance = service ?? _real; final Map _connections = {}; diff --git a/test/e2e/04_nosql_workspaces/e2e_mongo_explorer_and_crud_test.dart b/test/e2e/04_nosql_workspaces/e2e_mongo_explorer_and_crud_test.dart new file mode 100644 index 00000000..b2189023 --- /dev/null +++ b/test/e2e/04_nosql_workspaces/e2e_mongo_explorer_and_crud_test.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/features/mongodb/mongo_add_document_dialog.dart'; +import 'package:querya_desktop/features/mongodb/mongo_collections_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_view.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +const _timeout = Timeout(Duration(seconds: 60)); + +/// A connection that lists two collections and never opens a socket. +class _Connection extends MongoConnection { + _Connection() : super(id: 11, name: 'shop-mongo', host: 'localhost'); + + @override + Future> listCollections(String databaseName) async => + const ['orders', 'users']; +} + +/// Documents in memory, with every write recorded. +class _FakeMongo extends MongoService { + _FakeMongo() : super.forTest(); + + final docs = >[ + {'_id': 1, 'name': 'ann', 'city': 'Berlin'}, + {'_id': 2, 'name': 'bob', 'city': 'Paris'}, + ]; + final inserted = >[]; + final updates = <(Map, Map)>[]; + final deletes = >[]; + var nextId = 3; + + @override + Future>> find( + MongoConnection connection, + String database, + String collection, { + Map? filter, + Map? projection, + Map? sort, + int? limit, + int? skip, + }) async => + [for (final d in docs) Map.of(d)]; + + @override + Future countDocuments( + MongoConnection connection, + String database, + String collection, { + Map? filter, + }) async => + docs.length; + + @override + Future> insertDocument( + MongoConnection connection, + String database, + String collection, + Map document, + ) async { + inserted.add(document); + docs.add({'_id': nextId++, ...document}); + return document; + } + + @override + Future updateDocument( + MongoConnection connection, + String database, + String collection, + Map filter, + Map update, + ) async { + updates.add((filter, update)); + final doc = docs.firstWhere((d) => d['_id'] == filter['_id']); + doc.addAll((update[r'$set'] as Map).cast()); + } + + @override + Future deleteDocument( + MongoConnection connection, + String database, + String collection, + Map filter, + ) async { + deletes.add(filter); + docs.removeWhere((d) => d['_id'] == filter['_id']); + } +} + +/// The sidebar's collection list, and the documents of the one picked. +class _Explorer extends material.StatefulWidget { + const _Explorer(this.connection); + + final MongoConnection connection; + + @override + material.State<_Explorer> createState() => _ExplorerState(); +} + +class _ExplorerState extends material.State<_Explorer> { + String? _collection; + + @override + material.Widget build(material.BuildContext context) { + final collection = _collection; + return material.Scaffold( + body: collection == null + ? MongoCollectionsView( + connection: widget.connection, + database: 'shop', + onCollectionTap: (name) => setState(() => _collection = name), + ) + : MongoDocumentsView( + connection: widget.connection, + database: 'shop', + collection: collection, + initialViewMode: MongoDocumentsViewMode.cards, + ), + ); + } +} + +void main() { + late _FakeMongo mongo; + + setUp(() { + mongo = _FakeMongo(); + MongoService.instanceForTest = mongo; + }); + tearDown(() => MongoService.instanceForTest = null); + + Future settle(WidgetTester t) async { + for (var i = 0; i < 5; i++) { + await t.pump(const Duration(milliseconds: 100)); + } + } + + testWidgets( + 'collections open into documents; add, edit and delete a document (#1052)', + timeout: _timeout, (tester) async { + await tester.binding.setSurfaceSize(const material.Size(1300, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(queryaThemeTestShell(child: _Explorer(_Connection()))); + await settle(tester); + + // The collections of the database, and a tap opens one. + expect(find.text('orders'), findsOneWidget); + expect(find.text('users'), findsOneWidget); + await tester.tap(find.text('orders')); + await settle(tester); + expect(find.byType(MongoDocumentsView), findsOneWidget); + // One card per document. + final cards = find.byIcon(material.Icons.description_rounded); + expect(cards, findsNWidgets(2)); + + // Add a document through the dialog. + await tester.tap(find.text('Add Document')); + await settle(tester); + await tester.enterText( + find.descendant( + of: find.byType(MongoAddDocumentDialog), + matching: find.byType(material.EditableText), + ), + '{"name": "cid", "city": "Rome"}', + ); + await tester.pump(); + await tester.tap(find.text('Insert Document')); + await settle(tester); + expect(mongo.inserted, hasLength(1)); + expect(mongo.inserted.single['name'], 'cid'); + expect(cards, findsNWidgets(3), reason: 'the list reloads with the new one'); + + // Edit a field of the first document from its expanded card. + await tester.tap(find.byIcon(material.Icons.expand_more_rounded).first); + await settle(tester); + await tester.tap(find.text('name').first); + await settle(tester); + await tester.enterText(find.byType(material.EditableText).last, 'annie'); + await tester.pump(); + await tester.tap(find.text('Save to DB')); + await settle(tester); + expect(mongo.updates, hasLength(1)); + expect(mongo.updates.single.$1, {'_id': 1}); + expect(mongo.updates.single.$2, { + r'$set': {'name': 'annie'}, + }); + + // Delete it, after confirming. + await tester.tap(find.byIcon(material.Icons.delete_rounded).first); + await settle(tester); + await tester.tap(find.text( + 'I understand that this query cannot be undone and may result in permanent data loss.')); + await tester.pump(); + await tester.tap(find.text('Execute Destructive Statement')); + await settle(tester); + expect(mongo.deletes, [ + {'_id': 1}, + ]); + expect(cards, findsNWidgets(2), reason: 'the list reloads without it'); + expect(mongo.docs.map((d) => d['name']), ['bob', 'cid']); + }); + + testWidgets('cancelling the delete keeps the document (#1052)', + timeout: _timeout, (tester) async { + await tester.binding.setSurfaceSize(const material.Size(1300, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(queryaThemeTestShell(child: _Explorer(_Connection()))); + await settle(tester); + await tester.tap(find.text('orders')); + await settle(tester); + + await tester.tap(find.byIcon(material.Icons.delete_rounded).first); + await settle(tester); + await tester.tap(find.text('Cancel').last); + await settle(tester); + expect(mongo.deletes, isEmpty); + expect(mongo.docs, hasLength(2)); + }); +} diff --git a/test/e2e/06_shell_theme_and_command_palette/e2e_app_lifecycle_and_shutdown_test.dart b/test/e2e/06_shell_theme_and_command_palette/e2e_app_lifecycle_and_shutdown_test.dart new file mode 100644 index 00000000..fd89d330 --- /dev/null +++ b/test/e2e/06_shell_theme_and_command_palette/e2e_app_lifecycle_and_shutdown_test.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/app/app_lifecycle_cleanup.dart'; +import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/mcp/mcp_server_controller.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/main_screen/main_screen.dart'; + +import '../../support/querya_theme_test_shell.dart'; +import '../helpers/e2e_app_harness.dart'; + +/// #1053: closing the window stops the MCP server and closes pooled database +/// sessions; settings written in one run are there in the next. +void main() { + final app = E2eAppHarness(prefix: 'querya_e2e_lifecycle_'); + setUpAll(app.setUpAll); + tearDownAll(app.tearDownAll); + + Future pumpApp(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const material.Size(1400, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(queryaThemeTestShell( + child: const AppLifecycleCleanup( + child: material.SizedBox(width: 1400, height: 900, child: MainScreen()), + ), + )); + await E2eAppHarness.settle(tester); + } + + testWidgets('closing the app stops MCP and closes pooled sessions', + (tester) async { + final path = '${app.dataDir.path}/lifecycle.db'; + File(path).createSync(); + final row = ConnectionRow( + id: 501, + type: 'sqlite', + name: 'Lifecycle', + host: path, + createdAt: DateTime.utc(2026).toIso8601String(), + ); + final lease = + (await tester.runAsync(() => SqliteService.instance.acquire(row)))!; + expect(lease.connection.isConnected, isTrue); + + final mcp = McpServerController.instance; + await tester.runAsync(mcp.start); + expect(mcp.status.value.running, isTrue); + expect(mcp.endpointFile.existsSync(), isTrue); + + await pumpApp(tester); + + // Close the window: the cleanup runs as the app goes away. + await tester.pumpWidget(const material.SizedBox.shrink()); + for (var i = 0; + i < 80 && (mcp.status.value.running || lease.connection.isConnected); + i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25))); + await tester.pump(const Duration(milliseconds: 50)); + } + + expect(mcp.status.value.running, isFalse); + expect(mcp.endpointFile.existsSync(), isFalse, + reason: 'no client may find a dead endpoint'); + expect(lease.connection.isConnected, isFalse); + lease.release(); + await app.close(tester); + }); + + testWidgets('a setting written in one run is read in the next', + (tester) async { + await pumpApp(tester); + await tester + .runAsync(() => AppSettings.instance.setExportCurrentTheme(true)); + await tester.pumpWidget(const material.SizedBox.shrink()); + await tester.pump(); + + await pumpApp(tester); + final value = await tester + .runAsync(() => AppSettings.instance.getExportCurrentTheme()); + expect(value, isTrue); + await tester + .runAsync(() => AppSettings.instance.setExportCurrentTheme(false)); + await app.close(tester); + }); +} diff --git a/test/e2e/06_shell_theme_and_command_palette/e2e_theme_import_and_morphing_test.dart b/test/e2e/06_shell_theme_and_command_palette/e2e_theme_import_and_morphing_test.dart new file mode 100644 index 00000000..1a3f9edb --- /dev/null +++ b/test/e2e/06_shell_theme_and_command_palette/e2e_theme_import_and_morphing_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_from_vscode.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart'; +import 'package:querya_desktop/features/main_screen/main_screen.dart'; + +import '../../support/querya_theme_test_shell.dart'; +import '../helpers/e2e_app_harness.dart'; + +/// A VS Code theme as users paste it: comments and trailing commas (JSONC). +const _jsonc = ''' +{ + // Exported from VS Code. + "name": "E2E Night", + "type": "dark", + "colors": { + "editor.background": "#101820", + "sideBar.background": "#0b1016", // the sidebar + "foreground": "#e6e6e6", + }, +} +'''; + +/// #1053: a theme imported from VS Code recolours the running app, and the +/// switch never passes through a light frame. +void main() { + final app = E2eAppHarness(prefix: 'querya_e2e_theme_'); + setUpAll(app.setUpAll); + tearDownAll(app.tearDownAll); + + QueryaTheme themeIn(WidgetTester t) => + QueryaThemeScope.of(t.element(find.byType(ConnectionsPanel))); + + testWidgets('an imported JSONC theme recolours the running app', + (tester) async { + final imported = + buildQueryaThemeFromVsCodeManifest(VsCodeThemeManifest.fromJsonString(_jsonc)); + expect(imported.workbench.editorBackground, const material.Color(0xFF101820)); + expect(imported.workbench.sidebarBackground, const material.Color(0xFF0B1016)); + + final active = material.ValueNotifier(QueryaTheme.darkDefault); + addTearDown(active.dispose); + await tester.binding.setSurfaceSize(const material.Size(1400, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(material.ValueListenableBuilder( + valueListenable: active, + builder: (context, theme, _) => queryaThemeTestShell( + data: theme, + child: const material.SizedBox( + width: 1400, height: 900, child: MainScreen()), + ), + )); + await E2eAppHarness.settle(tester); + expect(themeIn(tester).workbench.sidebarBackground, + QueryaTheme.darkDefault.workbench.sidebarBackground); + final panel = tester.state(find.byType(ConnectionsPanel)); + + active.value = imported; + // Every frame of the switch stays dark: no white flash. + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + final w = themeIn(tester).workbench; + expect(w.sidebarBackground.computeLuminance(), lessThan(0.2), + reason: 'frame $i'); + expect(w.editorBackground.computeLuminance(), lessThan(0.2), + reason: 'frame $i'); + } + expect(themeIn(tester).workbench.sidebarBackground, + const material.Color(0xFF0B1016)); + expect(themeIn(tester).workbench.editorBackground, + const material.Color(0xFF101820)); + // The app was recoloured in place, not rebuilt from scratch. + expect(identical(tester.state(find.byType(ConnectionsPanel)), panel), isTrue); + expect(tester.takeException(), isNull); + + // And back to the built-in theme. + active.value = QueryaTheme.darkDefault; + await tester.pump(); + expect(themeIn(tester).workbench.sidebarBackground, + QueryaTheme.darkDefault.workbench.sidebarBackground); + await app.close(tester); + }); + + test('a theme that is not JSON is refused with a clear error', () { + expect( + () => VsCodeThemeManifest.fromJsonString('{ "colors": '), + throwsA(isA()), + ); + }); +}