test: add automation suite coverage
This commit is contained in:
parent
c5b0489654
commit
319d7a383f
45
.github/workflows/testing.yml
vendored
Normal file
45
.github/workflows/testing.yml
vendored
Normal file
@ -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
|
||||
13
AGENTS.md
13
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.
|
||||
|
||||
10
agent.md
Normal file
10
agent.md
Normal file
@ -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.
|
||||
43
docs/README_TESTING.md
Normal file
43
docs/README_TESTING.md
Normal file
@ -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.
|
||||
3
go_service/go.mod
Normal file
3
go_service/go.mod
Normal file
@ -0,0 +1,3 @@
|
||||
module xworkmate/go_service
|
||||
|
||||
go 1.25.0
|
||||
29
go_service/internal/handler/auth_handler.go
Normal file
29
go_service/internal/handler/auth_handler.go
Normal file
@ -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})
|
||||
}
|
||||
22
go_service/internal/handler/auth_handler_test.go
Normal file
22
go_service/internal/handler/auth_handler_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
15
go_service/internal/service/auth_service.go
Normal file
15
go_service/internal/service/auth_service.go
Normal file
@ -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
|
||||
}
|
||||
13
go_service/internal/service/auth_service_test.go
Normal file
13
go_service/internal/service/auth_service_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -57,7 +57,9 @@ void main() {
|
||||
);
|
||||
await _ensureSettingsFocused(tester);
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('assistant-focus-active-title-settings')),
|
||||
find.byKey(
|
||||
const ValueKey<String>('assistant-focus-active-title-settings'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
|
||||
@ -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<String>('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<String>('assistant-focus-active-title-settings'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await settleIntegrationUi(tester);
|
||||
},
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await settleIntegrationUi(tester);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
19
integration_test/home_flow_test.dart
Normal file
19
integration_test/home_flow_test.dart
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
42
integration_test/login_flow_test.dart
Normal file
42
integration_test/login_flow_test.dart
Normal file
@ -0,0 +1,42 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'test_support.dart';
|
||||
|
||||
Map<String, String> loadDotEnvValues() {
|
||||
final file = File('.env');
|
||||
if (!file.existsSync()) {
|
||||
return const <String, String>{};
|
||||
}
|
||||
final values = <String, String>{};
|
||||
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);
|
||||
});
|
||||
}
|
||||
@ -120,6 +120,7 @@ class _CodexIntegrationCardState extends State<CodexIntegrationCard> {
|
||||
),
|
||||
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<CodexIntegrationCard> {
|
||||
'/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<CodexIntegrationCard> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
key: const ValueKey('codex-bridge-toggle-button'),
|
||||
onPressed: controller.isCodexBridgeBusy
|
||||
? null
|
||||
: controller.isCodexBridgeEnabled
|
||||
|
||||
@ -56,6 +56,7 @@ class SectionTabs extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.xxs),
|
||||
child: _SectionTabChip(
|
||||
key: ValueKey<String>('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,
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
5
patrol_test/app_test.dart
Normal file
5
patrol_test/app_test.dart
Normal file
@ -0,0 +1,5 @@
|
||||
import 'package:patrol/patrol.dart';
|
||||
|
||||
void main() {
|
||||
patrolTest('app smoke', ($) async {});
|
||||
}
|
||||
5
patrol_test/camera_test.dart
Normal file
5
patrol_test/camera_test.dart
Normal file
@ -0,0 +1,5 @@
|
||||
import 'package:patrol/patrol.dart';
|
||||
|
||||
void main() {
|
||||
patrolTest('camera smoke', ($) async {});
|
||||
}
|
||||
5
patrol_test/permission_test.dart
Normal file
5
patrol_test/permission_test.dart
Normal file
@ -0,0 +1,5 @@
|
||||
import 'package:patrol/patrol.dart';
|
||||
|
||||
void main() {
|
||||
patrolTest('permission smoke', ($) async {});
|
||||
}
|
||||
64
pubspec.lock
64
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
|
||||
|
||||
@ -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:
|
||||
|
||||
115
test/features/assistant_page_single_agent_flow_suite.dart
Normal file
115
test/features/assistant_page_single_agent_flow_suite.dart
Normal file
@ -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 <String>['codex-chat'],
|
||||
selectedModels: const <String>['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<String>('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<String>('assistant-composer-input-area')),
|
||||
'hello codex',
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('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<void> close() async {
|
||||
await _server.close(force: true);
|
||||
}
|
||||
|
||||
Future<void> _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 = <String, dynamic>{
|
||||
'id': 'chatcmpl-test',
|
||||
'choices': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'index': 0,
|
||||
'delta': <String, dynamic>{'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();
|
||||
}
|
||||
}
|
||||
}
|
||||
117
test/features/settings_page_external_acp_end_to_end_suite.dart
Normal file
117
test/features/settings_page_external_acp_end_to_end_suite.dart
Normal file
@ -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: <ExternalAcpEndpointProfile>[
|
||||
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<String>('external-acp-endpoint-Codex'),
|
||||
);
|
||||
final testButton = find.byKey(
|
||||
const ValueKey<String>('external-acp-test-Codex'),
|
||||
);
|
||||
final saveButton = find.byKey(
|
||||
const ValueKey<String>('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<void> close() async {
|
||||
await _server.close(force: true);
|
||||
}
|
||||
|
||||
Future<void> _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<String, dynamic>;
|
||||
final response = <String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': decoded['id'],
|
||||
'result': <String, dynamic>{
|
||||
'singleAgent': true,
|
||||
'multiAgent': true,
|
||||
'providers': <String>['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();
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
test/golden/goldens/assistant_home_shell.png
Normal file
BIN
test/golden/goldens/assistant_home_shell.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
BIN
test/golden/goldens/settings_integrations_shell.png
Normal file
BIN
test/golden/goldens/settings_integrations_shell.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
21
test/golden/home_golden_test.dart
Normal file
21
test/golden/home_golden_test.dart
Normal file
@ -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');
|
||||
});
|
||||
}
|
||||
22
test/golden/login_golden_test.dart
Normal file
22
test/golden/login_golden_test.dart
Normal file
@ -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');
|
||||
});
|
||||
}
|
||||
35
test/helpers/golden_test_bootstrap.dart
Normal file
35
test/helpers/golden_test_bootstrap.dart
Normal file
@ -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<void> 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<void> 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();
|
||||
}
|
||||
17
test/helpers/pump_app.dart
Normal file
17
test/helpers/pump_app.dart
Normal file
@ -0,0 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app.dart';
|
||||
|
||||
Future<void> 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();
|
||||
}
|
||||
34
test/helpers/test_keys.dart
Normal file
34
test/helpers/test_keys.dart
Normal file
@ -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<String>(
|
||||
'assistant-task-item-main',
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user