diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 00000000..f7f12d09 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,45 @@ +name: testing + +on: + pull_request: + push: + branches: + - main + - 'release/**' + +jobs: + flutter: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - run: flutter pub get + - run: flutter test + - run: flutter test test/golden + - run: flutter test integration_test + + go: + runs-on: ubuntu-latest + defaults: + run: + working-directory: go_service + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + - run: go test ./... + + patrol: + if: startsWith(github.ref, 'refs/heads/release/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - run: flutter pub get + - run: dart pub global activate patrol_cli + - run: patrol test diff --git a/AGENTS.md b/AGENTS.md index bfa65c3d..8331c7ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,4 +119,17 @@ A refactor task is complete only when: - Any new macOS or iOS entitlement must be least-privilege, justified by the feature, and covered by tests or manual verification notes. - Auth, secret, network, or entitlement changes require `flutter analyze`, relevant unit/widget tests, and serial device-run integration tests when integration coverage is needed. +## Testing Rules + +- Modify any Flutter UI page, and you must add or update widget tests and golden tests. +- Modify any core business flow, and you must add or update `integration_test`. +- Modify permission, camera, file picker, notification, WebView, or native page interaction behavior, and you must add or update Patrol coverage. +- Modify any Go handler, service, or repository, and you must add or update matching `*_test.go` unit tests. +- All UI tests must use `Key`-based locators first. Avoid fragile text-only or hierarchy-only selectors unless no Key exists yet. +- Release/* branches must run the full chain: `flutter test`, `flutter test test/golden`, `flutter test integration_test`, `patrol test`, and `go test ./...`. +- New features must follow test first, then implementation, then full regression. +- Keep tests split by module. Do not pile every scenario into one file. +- Golden baseline refreshes require UI review confirmation before updating reference images. +- CI failures must be fixed in tests or implementation. Do not skip the failing check in merge workflows. + See [docs/security/secure-development-rules.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/security/secure-development-rules.md) for the full checklist. diff --git a/agent.md b/agent.md new file mode 100644 index 00000000..3363c4fe --- /dev/null +++ b/agent.md @@ -0,0 +1,10 @@ +# Agent Rules + +- Add or update widget tests and golden tests for any Flutter UI page change. +- Add or update integration tests for any core business flow change. +- Add or update Patrol tests for permission, camera, file picker, notification, WebView, or native page interaction changes. +- Add or update Go `*_test.go` coverage for any handler, service, or repository change. +- Prefer `Key`-based locators for all UI automation. +- Keep tests modular and split by feature. +- Do not update golden baselines without UI review confirmation. +- Fix failing tests or implementation directly; do not skip CI failures. diff --git a/docs/README_TESTING.md b/docs/README_TESTING.md new file mode 100644 index 00000000..22b7c91a --- /dev/null +++ b/docs/README_TESTING.md @@ -0,0 +1,43 @@ +# Testing Guide + +## Flutter + +Run unit and widget tests: + +```bash +flutter test +``` + +Run golden tests: + +```bash +flutter test test/golden +``` + +Run integration tests: + +```bash +flutter test integration_test +``` + +## Patrol + +Run Patrol tests: + +```bash +patrol test +``` + +## Go + +Run Go unit tests: + +```bash +cd go_service +go test ./... +``` + +## CI Coverage + +- Pull requests run Flutter tests, golden tests, integration tests, and Go tests. +- `release/*` branches run Patrol tests in addition to the PR chain. diff --git a/go_service/go.mod b/go_service/go.mod new file mode 100644 index 00000000..79f6e2c3 --- /dev/null +++ b/go_service/go.mod @@ -0,0 +1,3 @@ +module xworkmate/go_service + +go 1.25.0 diff --git a/go_service/internal/handler/auth_handler.go b/go_service/internal/handler/auth_handler.go new file mode 100644 index 00000000..7a8bbfa9 --- /dev/null +++ b/go_service/internal/handler/auth_handler.go @@ -0,0 +1,29 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "xworkmate/go_service/internal/service" +) + +type AuthHandler struct { + service *service.AuthService +} + +func NewAuthHandler(service *service.AuthService) *AuthHandler { + return &AuthHandler{service: service} +} + +func (h *AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if h.service == nil { + http.Error(w, "service unavailable", http.StatusServiceUnavailable) + return + } + token := r.Header.Get("Authorization") + if !h.service.ValidateToken(token) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) +} diff --git a/go_service/internal/handler/auth_handler_test.go b/go_service/internal/handler/auth_handler_test.go new file mode 100644 index 00000000..4add628a --- /dev/null +++ b/go_service/internal/handler/auth_handler_test.go @@ -0,0 +1,22 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "xworkmate/go_service/internal/service" +) + +func TestAuthHandlerServeHTTP(t *testing.T) { + h := NewAuthHandler(service.NewAuthService("secret")) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "secret") + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } +} diff --git a/go_service/internal/service/auth_service.go b/go_service/internal/service/auth_service.go new file mode 100644 index 00000000..3fd46aab --- /dev/null +++ b/go_service/internal/service/auth_service.go @@ -0,0 +1,15 @@ +package service + +import "strings" + +type AuthService struct { + expectedToken string +} + +func NewAuthService(expectedToken string) *AuthService { + return &AuthService{expectedToken: strings.TrimSpace(expectedToken)} +} + +func (s *AuthService) ValidateToken(token string) bool { + return strings.TrimSpace(token) != "" && strings.TrimSpace(token) == s.expectedToken +} diff --git a/go_service/internal/service/auth_service_test.go b/go_service/internal/service/auth_service_test.go new file mode 100644 index 00000000..3e636274 --- /dev/null +++ b/go_service/internal/service/auth_service_test.go @@ -0,0 +1,13 @@ +package service + +import "testing" + +func TestAuthServiceValidateToken(t *testing.T) { + svc := NewAuthService("secret") + if !svc.ValidateToken("secret") { + t.Fatal("expected valid token") + } + if svc.ValidateToken("wrong") { + t.Fatal("expected invalid token") + } +} diff --git a/integration_test/desktop_navigation_flow_test.dart b/integration_test/desktop_navigation_flow_test.dart index b16f50dd..f76a7781 100644 --- a/integration_test/desktop_navigation_flow_test.dart +++ b/integration_test/desktop_navigation_flow_test.dart @@ -57,7 +57,9 @@ void main() { ); await _ensureSettingsFocused(tester); expect( - find.byKey(const ValueKey('assistant-focus-active-title-settings')), + find.byKey( + const ValueKey('assistant-focus-active-title-settings'), + ), findsOneWidget, ); diff --git a/integration_test/desktop_settings_flow_test.dart b/integration_test/desktop_settings_flow_test.dart index 9b49c1ec..8f9772c1 100644 --- a/integration_test/desktop_settings_flow_test.dart +++ b/integration_test/desktop_settings_flow_test.dart @@ -46,22 +46,24 @@ void main() { (WidgetTester tester) async { await pumpDesktopApp(tester); - await tester.tap( - find.byKey(const Key('assistant-side-pane-tab-navigation')), - ); - await settleIntegrationUi(tester); - expect( - find.byKey(const Key('assistant-focus-panel-title')), - findsOneWidget, - ); - await _ensureSettingsFocused(tester); - expect( - find.byKey(const ValueKey('assistant-focus-active-title-settings')), - findsOneWidget, - ); + await tester.tap( + find.byKey(const Key('assistant-side-pane-tab-navigation')), + ); + await settleIntegrationUi(tester); + expect( + find.byKey(const Key('assistant-focus-panel-title')), + findsOneWidget, + ); + await _ensureSettingsFocused(tester); + expect( + find.byKey( + const ValueKey('assistant-focus-active-title-settings'), + ), + findsOneWidget, + ); - await tester.pumpWidget(const SizedBox.shrink()); - await settleIntegrationUi(tester); - }, + await tester.pumpWidget(const SizedBox.shrink()); + await settleIntegrationUi(tester); + }, ); } diff --git a/integration_test/home_flow_test.dart b/integration_test/home_flow_test.dart new file mode 100644 index 00000000..9220e6a4 --- /dev/null +++ b/integration_test/home_flow_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../test/helpers/test_keys.dart'; +import 'test_support.dart'; + +void main() { + initializeIntegrationHarness(); + + testWidgets('assistant task flow exposes single agent target', ( + WidgetTester tester, + ) async { + await pumpDesktopApp(tester); + expect(find.byKey(TestKeys.assistantTaskRail), findsOneWidget); + expect(find.byKey(TestKeys.assistantExecutionTargetButton), findsOneWidget); + expect(find.byKey(TestKeys.assistantComposerInput), findsOneWidget); + expect(find.byKey(TestKeys.assistantSubmitButton), findsOneWidget); + expect(find.text('单机智能体'), findsWidgets); + }); +} diff --git a/integration_test/login_flow_test.dart b/integration_test/login_flow_test.dart new file mode 100644 index 00000000..a7ad91e4 --- /dev/null +++ b/integration_test/login_flow_test.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'test_support.dart'; + +Map loadDotEnvValues() { + final file = File('.env'); + if (!file.existsSync()) { + return const {}; + } + final values = {}; + for (final rawLine in file.readAsLinesSync()) { + final line = rawLine.trim(); + if (line.isEmpty || line.startsWith('#') || !line.contains('=')) { + continue; + } + final index = line.indexOf('='); + final key = line.substring(0, index).trim(); + var value = line.substring(index + 1).trim(); + if ((value.startsWith("'") && value.endsWith("'")) || + (value.startsWith('"') && value.endsWith('"'))) { + value = value.substring(1, value.length - 1); + } + values[key] = value; + } + return values; +} + +void main() { + initializeIntegrationHarness(); + + testWidgets('loads gateway env values for settings smoke flow', ( + WidgetTester tester, + ) async { + final env = loadDotEnvValues(); + expect(env.containsKey('AI-Gateway-Url'), isTrue); + expect(env.containsKey('AI-Gateway-apiKey'), isTrue); + await pumpDesktopApp(tester); + await settleIntegrationUi(tester); + }); +} diff --git a/lib/features/settings/codex_integration_card.dart b/lib/features/settings/codex_integration_card.dart index 21b34c25..f7e958f5 100644 --- a/lib/features/settings/codex_integration_card.dart +++ b/lib/features/settings/codex_integration_card.dart @@ -120,6 +120,7 @@ class _CodexIntegrationCardState extends State { ), const SizedBox(height: 16), TextField( + key: const ValueKey('codex-cli-path-field'), controller: _pathController, decoration: InputDecoration( labelText: appText('Codex CLI 路径', 'Codex CLI path'), @@ -128,6 +129,7 @@ class _CodexIntegrationCardState extends State { '/opt/homebrew/bin/codex', ), suffixIcon: IconButton( + key: const ValueKey('codex-cli-path-save-button'), onPressed: controller.isCodexBridgeBusy ? null : _savePathOverride, @@ -175,6 +177,7 @@ class _CodexIntegrationCardState extends State { children: [ Expanded( child: FilledButton.icon( + key: const ValueKey('codex-bridge-toggle-button'), onPressed: controller.isCodexBridgeBusy ? null : controller.isCodexBridgeEnabled diff --git a/lib/widgets/section_tabs.dart b/lib/widgets/section_tabs.dart index e5089213..b197cc84 100644 --- a/lib/widgets/section_tabs.dart +++ b/lib/widgets/section_tabs.dart @@ -56,6 +56,7 @@ class SectionTabs extends StatelessWidget { return Padding( padding: const EdgeInsets.only(right: AppSpacing.xxs), child: _SectionTabChip( + key: ValueKey('section-tab-$item'), label: item, selected: selected, padding: padding, @@ -71,6 +72,7 @@ class SectionTabs extends StatelessWidget { class _SectionTabChip extends StatefulWidget { const _SectionTabChip({ + super.key, required this.label, required this.selected, required this.padding, diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c24cc424..9991ad68 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import file_selector_macos import irondash_engine_context import mobile_scanner import package_info_plus +import patrol import shared_preferences_foundation import super_native_extensions @@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PatrolPlugin.register(with: registry.registrar(forPlugin: "PatrolPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 967d88ad..d483bc74 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,5 @@ PODS: + - CocoaAsyncSocket (7.6.5) - device_info_plus (0.0.1): - FlutterMacOS - file_selector_macos (0.0.1): @@ -10,6 +11,10 @@ PODS: - FlutterMacOS - package_info_plus (0.0.1): - FlutterMacOS + - patrol (0.0.1): + - CocoaAsyncSocket (~> 7.6) + - Flutter + - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -23,9 +28,14 @@ DEPENDENCIES: - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - patrol (from `Flutter/ephemeral/.symlinks/plugins/patrol/darwin`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`) +SPEC REPOS: + trunk: + - CocoaAsyncSocket + EXTERNAL SOURCES: device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos @@ -39,18 +49,22 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + patrol: + :path: Flutter/ephemeral/.symlinks/plugins/patrol/darwin shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin super_native_extensions: :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos SPEC CHECKSUMS: + CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba mobile_scanner: 0e365ed56cad24f28c0fd858ca04edefb40dfac3 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + patrol: 5df5d241d7f95f0df12a6906bbf45acb43a1e537 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 diff --git a/patrol_test/app_test.dart b/patrol_test/app_test.dart new file mode 100644 index 00000000..9a876a31 --- /dev/null +++ b/patrol_test/app_test.dart @@ -0,0 +1,5 @@ +import 'package:patrol/patrol.dart'; + +void main() { + patrolTest('app smoke', ($) async {}); +} diff --git a/patrol_test/camera_test.dart b/patrol_test/camera_test.dart new file mode 100644 index 00000000..642698e4 --- /dev/null +++ b/patrol_test/camera_test.dart @@ -0,0 +1,5 @@ +import 'package:patrol/patrol.dart'; + +void main() { + patrolTest('camera smoke', ($) async {}); +} diff --git a/patrol_test/permission_test.dart b/patrol_test/permission_test.dart new file mode 100644 index 00000000..2889717b --- /dev/null +++ b/patrol_test/permission_test.dart @@ -0,0 +1,5 @@ +import 'package:patrol/patrol.dart'; + +void main() { + patrolTest('permission smoke', ($) async {}); +} diff --git a/pubspec.lock b/pubspec.lock index 9aff188c..bfddbed2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" + dispose_scope: + dependency: transitive + description: + name: dispose_scope + sha256: "48ec38ca2631c53c4f8fa96b294c801e55c335db5e3fb9f82cede150cfe5a2af" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" fake_async: dependency: transitive description: @@ -271,6 +287,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + golden_toolkit: + dependency: "direct dev" + description: + name: golden_toolkit + sha256: "8f74adab33154fe7b731395782797021f97d2edc52f7bfb85ff4f1b5c4a215f0" + url: "https://pub.dev" + source: hosted + version: "0.15.0" hooks: dependency: transitive description: @@ -332,6 +356,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" leak_tracker: dependency: transitive description: @@ -507,6 +539,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + patrol: + dependency: "direct dev" + description: + name: patrol + sha256: "32fd0709f3871fa56eb9cd88410e3ca816bfa757122bae806a0f842188acb820" + url: "https://pub.dev" + source: hosted + version: "3.20.0" + patrol_finders: + dependency: transitive + description: + name: patrol_finders + sha256: "4a658d7d560de523f92deb3fa3326c78747ca0bf7e7f4b8788c012463138b628" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + patrol_log: + dependency: transitive + description: + name: patrol_log + sha256: "9fed4143980df1e3bbcfa00d0b443c7d68f04f9132317b7698bbc37f8a5a58c5" + url: "https://pub.dev" + source: hosted + version: "0.5.0" pixel_snap: dependency: transitive description: @@ -603,6 +659,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 540ff38d..44d72978 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,8 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter + golden_toolkit: ^0.15.0 + patrol: ^3.13.0 flutter_lints: ^6.0.0 dependency_overrides: diff --git a/test/features/assistant_page_single_agent_flow_suite.dart b/test/features/assistant_page_single_agent_flow_suite.dart new file mode 100644 index 00000000..2051702b --- /dev/null +++ b/test/features/assistant_page_single_agent_flow_suite.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/features/assistant/assistant_page.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +import '../test_support.dart'; + +void main() { + testWidgets( + 'AssistantPage single agent can be selected and receive streaming reply', + (WidgetTester tester) async { + final server = await _ChatServer.start(); + addTearDown(server.close); + + final controller = await createTestController(tester); + await controller.saveSettings( + controller.settings.copyWith( + aiGateway: controller.settings.aiGateway.copyWith( + baseUrl: server.baseUri.toString(), + availableModels: const ['codex-chat'], + selectedModels: const ['codex-chat'], + ), + defaultModel: 'codex-chat', + ), + ); + await controller.settingsController.saveAiGatewayApiKey('test-key'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); + + final targetButton = find.byKey( + const ValueKey('assistant-execution-target-button'), + ); + await tester.tap(targetButton); + await tester.pumpAndSettle(); + await tester.tap(find.text('单机智能体').last); + await tester.pumpAndSettle(); + + expect(find.text('单机智能体'), findsWidgets); + + await tester.enterText( + find.byKey(const ValueKey('assistant-composer-input-area')), + 'hello codex', + ); + await tester.tap( + find.byKey(const ValueKey('assistant-submit-button')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + + expect(find.textContaining('CODEX_REPLY'), findsWidgets); + expect(server.requestCount, greaterThanOrEqualTo(1)); + expect(controller.chatMessages.any((m) => m.text.contains('hello codex')), + isTrue); + }, + ); +} + +class _ChatServer { + _ChatServer._(this._server); + + final HttpServer _server; + int requestCount = 0; + + Uri get baseUri => Uri.parse('http://127.0.0.1:${_server.port}'); + + static Future<_ChatServer> start() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fake = _ChatServer._(server); + unawaited(fake._listen()); + return fake; + } + + Future close() async { + await _server.close(force: true); + } + + Future _listen() async { + await for (final request in _server) { + requestCount += 1; + if (request.uri.path != '/v1/chat/completions') { + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + continue; + } + final response = { + 'id': 'chatcmpl-test', + 'choices': >[ + { + 'index': 0, + 'delta': {'content': 'CODEX_REPLY'}, + 'finish_reason': 'stop', + }, + ], + }; + request.response.headers.set( + HttpHeaders.contentTypeHeader, + 'text/event-stream; charset=utf-8', + ); + request.response.write('data: ${jsonEncode(response)}\n\n'); + await request.response.close(); + } + } +} diff --git a/test/features/settings_page_external_acp_end_to_end_suite.dart b/test/features/settings_page_external_acp_end_to_end_suite.dart new file mode 100644 index 00000000..f409daa1 --- /dev/null +++ b/test/features/settings_page_external_acp_end_to_end_suite.dart @@ -0,0 +1,117 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/features/settings/settings_page.dart'; +import 'package:xworkmate/models/app_models.dart'; +import 'package:xworkmate/runtime/runtime_models_profiles.dart'; + +import '../test_support.dart'; + +void main() { + testWidgets('SettingsPage Codex external ACP can test and save', ( + WidgetTester tester, + ) async { + final server = await _AcpServer.start(); + addTearDown(server.close); + + final controller = await createTestController(tester); + await controller.saveSettings( + controller.settings.copyWith( + externalAcpEndpoints: [ + const ExternalAcpEndpointProfile( + providerKey: 'codex', + label: 'Codex', + badge: 'C', + endpoint: '', + authRef: '', + enabled: true, + ), + ...controller.settings.externalAcpEndpoints.skip(1), + ], + ), + ); + + await pumpPage( + tester, + child: SettingsPage(controller: controller, initialTab: SettingsTab.gateway), + ); + + final endpointField = find.byKey( + const ValueKey('external-acp-endpoint-Codex'), + ); + final testButton = find.byKey( + const ValueKey('external-acp-test-Codex'), + ); + final saveButton = find.byKey( + const ValueKey('external-acp-apply-Codex'), + ); + + expect(endpointField, findsOneWidget); + await tester.enterText(endpointField, server.baseUri.toString()); + await tester.pumpAndSettle(); + + await tester.tap(testButton); + await tester.pumpAndSettle(); + + expect(find.textContaining('连接成功'), findsOneWidget); + + await tester.tap(saveButton); + await tester.pumpAndSettle(); + + final saved = controller.settings.externalAcpEndpointForProviderId('codex'); + expect(saved?.endpoint, server.baseUri.toString()); + expect(server.requestCount, greaterThanOrEqualTo(1)); + }); +} + +class _AcpServer { + _AcpServer._(this._server); + + final HttpServer _server; + int requestCount = 0; + + Uri get baseUri => Uri.parse('http://127.0.0.1:${_server.port}'); + + static Future<_AcpServer> start() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fake = _AcpServer._(server); + unawaited(fake._listen()); + return fake; + } + + Future close() async { + await _server.close(force: true); + } + + Future _listen() async { + await for (final request in _server) { + requestCount += 1; + if (request.uri.path != '/acp/rpc') { + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + continue; + } + final body = await utf8.decoder.bind(request).join(); + final decoded = jsonDecode(body) as Map; + final response = { + 'jsonrpc': '2.0', + 'id': decoded['id'], + 'result': { + 'singleAgent': true, + 'multiAgent': true, + 'providers': ['codex'], + }, + }; + request.response.headers.set( + HttpHeaders.contentTypeHeader, + 'text/event-stream; charset=utf-8', + ); + request.response.write('data: ${jsonEncode(response)}\n\n'); + await request.response.close(); + } + } +} diff --git a/test/golden/goldens/assistant_home_shell.png b/test/golden/goldens/assistant_home_shell.png new file mode 100644 index 00000000..d019e25a Binary files /dev/null and b/test/golden/goldens/assistant_home_shell.png differ diff --git a/test/golden/goldens/settings_integrations_shell.png b/test/golden/goldens/settings_integrations_shell.png new file mode 100644 index 00000000..0a1d5db1 Binary files /dev/null and b/test/golden/goldens/settings_integrations_shell.png differ diff --git a/test/golden/home_golden_test.dart b/test/golden/home_golden_test.dart new file mode 100644 index 00000000..0a95c62c --- /dev/null +++ b/test/golden/home_golden_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:xworkmate/features/assistant/assistant_page.dart'; + +import '../test_support.dart'; +import '../helpers/golden_test_bootstrap.dart'; + +void main() { + setUpAll(() async { + await loadGoldenFonts(); + }); + + testGoldens('assistant home shell', (tester) async { + final controller = await createTestController(tester); + await pumpGoldenApp( + tester, + AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); + await screenMatchesGolden(tester, 'assistant_home_shell'); + }); +} diff --git a/test/golden/login_golden_test.dart b/test/golden/login_golden_test.dart new file mode 100644 index 00000000..fe685f72 --- /dev/null +++ b/test/golden/login_golden_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:xworkmate/models/app_models.dart'; +import 'package:xworkmate/features/settings/settings_page.dart'; + +import '../helpers/golden_test_bootstrap.dart'; +import '../test_support.dart'; + +void main() { + setUpAll(() async { + await loadGoldenFonts(); + }); + + testGoldens('settings integrations shell', (tester) async { + final controller = await createTestController(tester); + await pumpGoldenApp( + tester, + SettingsPage(controller: controller, initialTab: SettingsTab.gateway), + ); + await screenMatchesGolden(tester, 'settings_integrations_shell'); + }); +} diff --git a/test/helpers/golden_test_bootstrap.dart b/test/helpers/golden_test_bootstrap.dart new file mode 100644 index 00000000..dff5774a --- /dev/null +++ b/test/helpers/golden_test_bootstrap.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:xworkmate/theme/app_theme.dart'; + +Future loadGoldenFonts() async { + await loadAppFonts(); +} + +Widget buildGoldenApp(Widget child) { + return MaterialApp( + debugShowCheckedModeBanner: false, + supportedLocales: const [Locale('zh'), Locale('en')], + localizationsDelegates: GlobalMaterialLocalizations.delegates, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + home: Scaffold(body: child), + ); +} + +Future pumpGoldenApp( + WidgetTester tester, + Widget child, { + Size size = const Size(1440, 960), +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + await tester.pumpWidget(buildGoldenApp(child)); + await tester.pumpAndSettle(); +} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart new file mode 100644 index 00000000..69b6a581 --- /dev/null +++ b/test/helpers/pump_app.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app.dart'; + +Future pumpXWorkmateApp( + WidgetTester tester, { + Size size = const Size(1600, 1000), +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + await tester.pumpWidget(const XWorkmateApp()); + await tester.pumpAndSettle(); +} diff --git a/test/helpers/test_keys.dart b/test/helpers/test_keys.dart new file mode 100644 index 00000000..0f11a35e --- /dev/null +++ b/test/helpers/test_keys.dart @@ -0,0 +1,34 @@ +import 'package:flutter/widgets.dart'; + +class TestKeys { + const TestKeys._(); + + static const Key settingsGatewayTab = Key('sidebar-settings-tab-gateway'); + static const Key settingsIntegrationsTab = Key('section-tab-ACP 外部接入'); + static const Key settingsGatewayIntegrationTab = Key( + 'section-tab-OpenClaw Gateway', + ); + static const Key settingsExternalAcpProvider = Key('external-acp-card-Codex'); + static const Key settingsExternalAcpEndpoint = Key( + 'external-acp-endpoint-Codex', + ); + static const Key settingsExternalAcpAuth = Key('external-acp-auth-Codex'); + static const Key settingsExternalAcpTest = Key('external-acp-test-Codex'); + static const Key settingsExternalAcpSave = Key('external-acp-apply-Codex'); + + static const Key assistantTaskRail = Key('assistant-task-rail'); + static const Key assistantExecutionTargetButton = Key( + 'assistant-execution-target-button', + ); + static const Key assistantSingleAgentProviderButton = Key( + 'assistant-single-agent-provider-button', + ); + static const Key assistantComposerInput = Key( + 'assistant-composer-input-area', + ); + static const Key assistantSubmitButton = Key('assistant-submit-button'); + static const Key assistantNewTaskButton = Key('assistant-new-task-button'); + static const Key assistantTaskItemMain = ValueKey( + 'assistant-task-item-main', + ); +}