Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
16 changes: 15 additions & 1 deletion lib/core/database/mongodb_service.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:mongo_dart/mongo_dart.dart';

import '../storage/local_db.dart';
Expand Down Expand Up @@ -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<int, MongoConnection> _connections = {};

Expand Down
223 changes: 223 additions & 0 deletions test/e2e/04_nosql_workspaces/e2e_mongo_explorer_and_crud_test.dart
Original file line number Diff line number Diff line change
@@ -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<List<String>> listCollections(String databaseName) async =>
const ['orders', 'users'];
}

/// Documents in memory, with every write recorded.
class _FakeMongo extends MongoService {
_FakeMongo() : super.forTest();

final docs = <Map<String, dynamic>>[
{'_id': 1, 'name': 'ann', 'city': 'Berlin'},
{'_id': 2, 'name': 'bob', 'city': 'Paris'},
];
final inserted = <Map<String, dynamic>>[];
final updates = <(Map<String, dynamic>, Map<String, dynamic>)>[];
final deletes = <Map<String, dynamic>>[];
var nextId = 3;

@override
Future<List<Map<String, dynamic>>> find(
MongoConnection connection,
String database,
String collection, {
Map<String, dynamic>? filter,
Map<String, dynamic>? projection,
Map<String, dynamic>? sort,
int? limit,
int? skip,
}) async =>
[for (final d in docs) Map<String, dynamic>.of(d)];

@override
Future<int> countDocuments(
MongoConnection connection,
String database,
String collection, {
Map<String, dynamic>? filter,
}) async =>
docs.length;

@override
Future<Map<String, dynamic>> insertDocument(
MongoConnection connection,
String database,
String collection,
Map<String, dynamic> document,
) async {
inserted.add(document);
docs.add({'_id': nextId++, ...document});
return document;
}

@override
Future<void> updateDocument(
MongoConnection connection,
String database,
String collection,
Map<String, dynamic> filter,
Map<String, dynamic> update,
) async {
updates.add((filter, update));
final doc = docs.firstWhere((d) => d['_id'] == filter['_id']);
doc.addAll((update[r'$set'] as Map).cast<String, dynamic>());
}

@override
Future<void> deleteDocument(
MongoConnection connection,
String database,
String collection,
Map<String, dynamic> 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<void> 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));
});
}
Original file line number Diff line number Diff line change
@@ -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<void> 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<void>.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);
});
}
Loading
Loading