test: add automation suite coverage

This commit is contained in:
Haitao Pan 2026-04-08 16:35:25 +08:00
parent c5b0489654
commit 319d7a383f
31 changed files with 738 additions and 17 deletions

45
.github/workflows/testing.yml vendored Normal file
View 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

View File

@ -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
View 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
View 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
View File

@ -0,0 +1,3 @@
module xworkmate/go_service
go 1.25.0

View 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})
}

View 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)
}
}

View 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
}

View 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")
}
}

View File

@ -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,
);

View File

@ -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);
},
);
}

View 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);
});
}

View 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);
});
}

View File

@ -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

View File

@ -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,

View File

@ -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"))
}

View File

@ -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

View File

@ -0,0 +1,5 @@
import 'package:patrol/patrol.dart';
void main() {
patrolTest('app smoke', ($) async {});
}

View File

@ -0,0 +1,5 @@
import 'package:patrol/patrol.dart';
void main() {
patrolTest('camera smoke', ($) async {});
}

View File

@ -0,0 +1,5 @@
import 'package:patrol/patrol.dart';
void main() {
patrolTest('permission smoke', ($) async {});
}

View File

@ -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

View File

@ -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:

View 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();
}
}
}

View 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();
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View 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');
});
}

View 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');
});
}

View 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();
}

View 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();
}

View 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',
);
}