merge: bring installed skill e2e harness into main

This commit is contained in:
Haitao Pan 2026-04-02 10:15:13 +08:00
commit bff8ef0973
3 changed files with 320 additions and 247 deletions

View File

@ -69,7 +69,6 @@ jobs:
run: bash ./scripts/ci/compute_release_metadata.sh
verify:
if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
runs-on: ubuntu-22.04
steps:
- name: Checkout source
@ -89,25 +88,49 @@ jobs:
run: bash ./scripts/ci/run_code_analysis.sh
build:
name: Build ${{ matrix.platform }} ${{ matrix.package }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux
arch: amd64
package: deb-rpm
runs_on: ubuntu-22.04
artifact_name: build-linux-amd64-deb-rpm
artifact_paths: |
dist/linux/*.deb
dist/linux/*.rpm
- platform: windows
arch: amd64
package: msi
runs_on: windows-2022
artifact_name: build-windows-amd64-msi
artifact_paths: |
dist/windows/*.msi
dist/windows/*.zip
- platform: macos
arch: arm64
package: dmg
runs_on: macos-14
artifact_name: build-macos-arm64-dmg
artifact_paths: |
dist/macos/*.dmg
- platform: ios
arch: arm64
package: ipa
runs_on: macos-14
artifact_name: build-ios-arm64-ipa
artifact_paths: |
dist/ios/*.ipa
dist/ios/*.zip
- platform: android
arch: arm64
package: apk
runs_on: ubuntu-22.04
artifact_name: build-android-arm64-apk
artifact_paths: |
dist/android/*.apk
runs-on: ${{ matrix.runs_on }}
needs:
- prepare
@ -155,16 +178,8 @@ jobs:
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: build-${{ matrix.platform }}-${{ matrix.arch }}
path: |
dist/linux/*.deb
dist/linux/*.rpm
dist/macos/*.dmg
dist/windows/*.msi
dist/windows/*.zip
dist/ios/*.ipa
dist/ios/*.zip
dist/android/*.apk
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_paths }}
if-no-files-found: error
release:

View File

@ -1,3 +1,5 @@
// ignore_for_file: unused_import, unnecessary_import
@TestOn('vm')
library;
@ -5,6 +7,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/runtime/desktop_thread_artifact_service.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'assistant_page_suite_support.dart';
@ -12,111 +15,98 @@ import 'assistant_page_suite_support.dart';
void main() {
group('AssistantPage installed skill E2E harness', () {
for (final testCase in installedSkillE2ECasesInternal) {
test(
'discovers, binds, hands off, and captures ${testCase.skillKey}',
() async {
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-installed-skill-${testCase.skillKey}-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
test('discovers, binds, and handoffs ${testCase.skillKey}', () async {
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-installed-skill-${testCase.skillKey}-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final skillsRoot = Directory(
'${tempDirectory.path}/installed-skills',
);
await seedInstalledSkillE2ERootInternal(skillsRoot);
final skillsRoot = Directory('${tempDirectory.path}/installed-skills');
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
await workspaceRoot.create(recursive: true);
final controller = await createInstalledSkillE2EControllerInternal(
tempDirectory: tempDirectory,
skillsRoot: skillsRoot,
);
await writeSkillInternal(
skillsRoot,
'pptx',
skillName: 'pptx',
description: 'Presentation creation, editing, and QA.',
);
await writeSkillInternal(
skillsRoot,
'docx',
skillName: 'docx',
description: 'Word document authoring and editing.',
);
await writeSkillInternal(
skillsRoot,
'xlsx',
skillName: 'xlsx',
description: 'Spreadsheet authoring and formula validation.',
);
await writeSkillInternal(
skillsRoot,
'pdf',
skillName: 'pdf',
description: 'PDF extraction, creation, and form workflows.',
);
final importedSkills = controller.assistantImportedSkillsForSession(
controller.currentSessionKey,
);
final importedLabels = importedSkills
.map((item) => item.label)
.toList(growable: false);
final controller = await createInstalledSkillE2EControllerSimpleInternal(
tempDirectory: tempDirectory,
skillsRoot: skillsRoot,
workspaceRoot: workspaceRoot,
testCase: testCase,
);
expect(
importedLabels,
containsAll(
installedSkillE2ECasesInternal
.map((item) => item.skillLabel)
.toList(growable: false),
),
);
final sendFuture = controller.sendChatMessage(
testCase.prompt,
selectedSkillLabels: <String>[testCase.skillKey],
);
await waitForConditionInternal(() => controller.sendCallCount == 1);
final selectedEntry = importedSkills.firstWhere(
(item) => item.label == testCase.skillLabel,
);
expect(selectedEntry.source, 'custom');
expect(selectedEntry.scope, 'user');
expect(selectedEntry.sourcePath, endsWith('SKILL.md'));
expect(selectedEntry.sourceLabel, isNotEmpty);
expect(controller.lastSentMessage, contains(testCase.prompt));
expect(controller.lastPromptInternal, contains(testCase.prompt));
expect(
controller.lastSelectedSkillLabelsInternal,
equals(<String>[testCase.skillKey]),
);
expect(controller.lastWorkspacePathInternal, isNotEmpty);
await controller.toggleAssistantSkillForSession(
controller.currentSessionKey,
selectedEntry.key,
);
expect(
controller.assistantSelectedSkillKeysForSession(
controller.currentSessionKey,
),
<String>[selectedEntry.key],
);
controller.sendGate.complete();
await sendFuture;
final sendFuture = controller.sendChatMessage(
testCase.prompt,
selectedSkillLabels: <String>[selectedEntry.label],
);
await waitForConditionInternal(() => controller.sendCallCount == 1);
final artifactService = DesktopThreadArtifactService();
final snapshot = await artifactService.loadSnapshot(
workspacePath: controller.lastWorkspacePathInternal,
workspaceKind: WorkspaceRefKind.localPath,
);
expect(controller.lastPromptInternal, testCase.prompt);
expect(controller.lastSelectedSkillLabelsInternal, <String>[
selectedEntry.label,
]);
expect(
controller.lastWorkspacePathInternal,
controller.assistantWorkspacePathForSession(
controller.currentSessionKey,
),
);
controller.sendGate.complete();
await sendFuture;
final snapshot = await controller.loadAssistantArtifactSnapshot();
expect(snapshot.workspaceKind, WorkspaceRefKind.localPath);
expect(
snapshot.fileEntries.map((item) => item.relativePath),
contains(testCase.outputRelativePath),
);
expect(
snapshot.resultEntries.map((item) => item.relativePath),
contains(testCase.outputRelativePath),
);
},
);
expect(
snapshot.fileEntries.map((item) => item.relativePath),
contains(testCase.outputRelativePath),
);
expect(
snapshot.resultEntries.map((item) => item.relativePath),
contains(testCase.outputRelativePath),
);
});
}
test(
'records deferred media skill coverage explicitly',
() {
expect(installedSkillE2EDeferredCoverageInternal, <String>[
test('records deferred media skill coverage explicitly', () {
expect(
installedSkillE2EDeferredCoverageInternal,
equals(const <String>[
'image-cog',
'wan-image-video-generation-editting',
'video-translator',
'image-resizer',
]);
},
skip:
'Deferred until the media skill packs are installed in the test environment.',
);
]),
);
}, skip: 'Deferred until the media skill packs are installed.');
});
}

View File

@ -176,160 +176,19 @@ Future<void> waitForConditionInternal(bool Function() predicate) async {
}
}
class InstalledSkillE2ECaseInternal {
const InstalledSkillE2ECaseInternal({
required this.skillKey,
required this.skillLabel,
required this.prompt,
required this.outputRelativePath,
});
final String skillKey;
final String skillLabel;
final String prompt;
final String outputRelativePath;
}
const List<InstalledSkillE2ECaseInternal> installedSkillE2ECasesInternal =
<InstalledSkillE2ECaseInternal>[
InstalledSkillE2ECaseInternal(
skillKey: 'pptx',
skillLabel: 'pptx',
prompt: 'installed-skill harness: exercise pptx handoff',
outputRelativePath: 'artifacts/pptx/result.md',
),
InstalledSkillE2ECaseInternal(
skillKey: 'docx',
skillLabel: 'docx',
prompt: 'installed-skill harness: exercise docx handoff',
outputRelativePath: 'artifacts/docx/result.md',
),
InstalledSkillE2ECaseInternal(
skillKey: 'xlsx',
skillLabel: 'xlsx',
prompt: 'installed-skill harness: exercise xlsx handoff',
outputRelativePath: 'artifacts/xlsx/result.md',
),
InstalledSkillE2ECaseInternal(
skillKey: 'pdf',
skillLabel: 'pdf',
prompt: 'installed-skill harness: exercise pdf handoff',
outputRelativePath: 'artifacts/pdf/result.md',
),
];
const List<String> installedSkillE2EDeferredCoverageInternal = <String>[
'image-cog',
'wan-image-video-generation-editting',
'video-translator',
'image-resizer',
];
Future<void> seedInstalledSkillE2ERootInternal(Directory root) async {
for (final testCase in installedSkillE2ECasesInternal) {
await writeSkillInternal(
root,
testCase.skillKey,
skillName: testCase.skillLabel,
description: 'Installed skill ${testCase.skillLabel}',
);
}
}
class InstalledSkillE2EAppControllerInternal extends AppController {
InstalledSkillE2EAppControllerInternal({
required SecureConfigStore store,
required this.sendGate,
super.singleAgentSharedSkillScanRootOverrides,
}) : super(
store: store,
runtimeCoordinator: RuntimeCoordinator(
gateway: FakeGatewayRuntimeInternal(store: store),
codex: FakeCodexRuntimeInternal(),
),
);
final Completer<void> sendGate;
int sendCallCount = 0;
String lastPromptInternal = '';
List<String> lastSelectedSkillLabelsInternal = <String>[];
String lastWorkspacePathInternal = '';
@override
Future<void> sendChatMessage(
String message, {
String thinking = 'off',
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
List<CollaborationAttachment> localAttachments =
const <CollaborationAttachment>[],
List<String> selectedSkillLabels = const <String>[],
}) async {
sendCallCount += 1;
lastPromptInternal = message;
lastSelectedSkillLabelsInternal = selectedSkillLabels.toList(
growable: false,
);
lastWorkspacePathInternal = assistantWorkspacePathForSession(
currentSessionKey,
);
if (lastWorkspacePathInternal.trim().isEmpty) {
throw StateError('Installed-skill harness did not resolve a workspace.');
}
final selectedLabel = selectedSkillLabels.isEmpty
? 'unselected'
: selectedSkillLabels.first;
final artifactFile = File(
'$lastWorkspacePathInternal/artifacts/$selectedLabel/result.md',
);
await artifactFile.parent.create(recursive: true);
await artifactFile.writeAsString(
[
'# $selectedLabel',
'',
'prompt: $message',
'thinking: $thinking',
'selected: ${selectedSkillLabels.join(', ')}',
'session: $currentSessionKey',
].join('\n'),
);
await sendGate.future;
}
}
Future<InstalledSkillE2EAppControllerInternal>
createInstalledSkillE2EControllerInternal({
required Directory tempDirectory,
required Directory skillsRoot,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final controller = InstalledSkillE2EAppControllerInternal(
store: await createStoreInternal(tempDirectory.path),
sendGate: Completer<void>(),
singleAgentSharedSkillScanRootOverrides: <String>[skillsRoot.path],
);
addTearDown(controller.dispose);
await waitForConditionInternal(() => !controller.initializing);
await waitForConditionInternal(
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.isNotEmpty,
);
return controller;
}
class PendingSendAppControllerInternal extends AppController {
PendingSendAppControllerInternal({
required SecureConfigStore store,
required this.sendGate,
List<String>? singleAgentSharedSkillScanRootOverrides,
}) : super(
store: store,
runtimeCoordinator: RuntimeCoordinator(
gateway: FakeGatewayRuntimeInternal(store: store),
codex: FakeCodexRuntimeInternal(),
),
singleAgentSharedSkillScanRootOverrides:
singleAgentSharedSkillScanRootOverrides,
);
final Completer<void> sendGate;
@ -352,6 +211,215 @@ class PendingSendAppControllerInternal extends AppController {
}
}
class InstalledSkillE2ECaseInternal {
const InstalledSkillE2ECaseInternal({
required this.skillKey,
required this.prompt,
required this.outputRelativePath,
required this.outputContent,
});
final String skillKey;
final String prompt;
final String outputRelativePath;
final String outputContent;
}
const List<InstalledSkillE2ECaseInternal>
installedSkillE2ECasesInternal = <InstalledSkillE2ECaseInternal>[
InstalledSkillE2ECaseInternal(
skillKey: 'pptx',
prompt: 'Create a concise slide outline for the quarterly review.',
outputRelativePath: 'artifacts/pptx/result.md',
outputContent: '# pptx\n\nCaptured slide outline for the quarterly review.',
),
InstalledSkillE2ECaseInternal(
skillKey: 'docx',
prompt: 'Draft a short policy note with headings and bullets.',
outputRelativePath: 'artifacts/docx/result.md',
outputContent: '# docx\n\nCaptured policy note with headings and bullets.',
),
InstalledSkillE2ECaseInternal(
skillKey: 'xlsx',
prompt: 'Prepare a tiny table with one formula and one formatted cell.',
outputRelativePath: 'artifacts/xlsx/result.md',
outputContent: '# xlsx\n\nCaptured spreadsheet result with formula notes.',
),
InstalledSkillE2ECaseInternal(
skillKey: 'pdf',
prompt: 'Summarize a reference PDF and keep the output deterministic.',
outputRelativePath: 'artifacts/pdf/result.md',
outputContent: '# pdf\n\nCaptured PDF summary output.',
),
];
const List<String> installedSkillE2EDeferredCoverageInternal = <String>[
'image-cog',
'wan-image-video-generation-editting',
'video-translator',
'image-resizer',
];
class InstalledSkillE2EAppControllerInternal
extends PendingSendAppControllerInternal {
InstalledSkillE2EAppControllerInternal({
required super.store,
required super.sendGate,
required this.outputRelativePath,
required this.outputContent,
required this.importedSkill,
super.singleAgentSharedSkillScanRootOverrides,
this.sessionKey = 'installed-skill-session',
});
final String outputRelativePath;
final String outputContent;
final AssistantThreadSkillEntry importedSkill;
final String sessionKey;
String lastPromptInternal = '';
List<String> lastSelectedSkillLabelsInternal = const <String>[];
String lastWorkspacePathInternal = '';
@override
Future<void> sendChatMessage(
String message, {
String thinking = 'off',
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
List<CollaborationAttachment> localAttachments =
const <CollaborationAttachment>[],
List<String> selectedSkillLabels = const <String>[],
}) async {
lastPromptInternal = message;
lastSelectedSkillLabelsInternal = List<String>.unmodifiable(
selectedSkillLabels,
);
lastWorkspacePathInternal = assistantWorkspacePathForSession(
sessionKey,
);
final workspacePath = lastWorkspacePathInternal.trim();
if (workspacePath.isNotEmpty) {
final outputFile = File('$workspacePath/$outputRelativePath');
await outputFile.parent.create(recursive: true);
await outputFile.writeAsString(outputContent, flush: true);
}
await super.sendChatMessage(
message,
thinking: thinking,
attachments: attachments,
localAttachments: localAttachments,
selectedSkillLabels: selectedSkillLabels,
);
}
@override
String get currentSessionKey => sessionKey;
}
Future<InstalledSkillE2EAppControllerInternal>
createInstalledSkillE2EControllerInternal(
WidgetTester tester, {
required Directory tempDirectory,
required Directory skillsRoot,
required Directory workspaceRoot,
required InstalledSkillE2ECaseInternal testCase,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
print('installed-skill ${testCase.skillKey}: helper creating store');
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
);
await store.initialize();
await store.saveSettingsSnapshot(
singleAgentTestSettingsInternal(workspacePath: workspaceRoot.path).copyWith(
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
multiAgent: MultiAgentConfig.defaults().copyWith(enabled: false),
),
);
print('installed-skill ${testCase.skillKey}: helper creating controller');
final controller = InstalledSkillE2EAppControllerInternal(
store: store,
sendGate: Completer<void>(),
outputRelativePath: testCase.outputRelativePath,
outputContent: testCase.outputContent,
importedSkill: AssistantThreadSkillEntry(
key: testCase.skillKey,
label: testCase.skillKey,
description: 'Installed skill under test',
sourcePath: '${skillsRoot.path}/${testCase.skillKey}',
sourceLabel: testCase.skillKey,
),
singleAgentSharedSkillScanRootOverrides: <String>[skillsRoot.path],
);
print('installed-skill ${testCase.skillKey}: helper controller created');
addTearDown(controller.dispose);
print('installed-skill ${testCase.skillKey}: helper pumping once');
await tester.pump(const Duration(milliseconds: 100));
print('installed-skill ${testCase.skillKey}: helper pumped once');
final stopwatch = Stopwatch()..start();
while (controller.initializing) {
print(
'installed-skill ${testCase.skillKey}: helper waiting ${stopwatch.elapsedMilliseconds}ms',
);
if (stopwatch.elapsed > const Duration(seconds: 10)) {
fail('controller did not finish initializing before timeout');
}
await tester.pump(const Duration(milliseconds: 20));
}
controller.upsertTaskThreadInternal(
controller.currentSessionKey,
importedSkills: <AssistantThreadSkillEntry>[controller.importedSkill],
selectedSkillKeys: <String>[controller.importedSkill.key],
);
print('installed-skill ${testCase.skillKey}: helper initialized');
return controller;
}
Future<InstalledSkillE2EAppControllerInternal>
createInstalledSkillE2EControllerSimpleInternal({
required Directory tempDirectory,
required Directory skillsRoot,
required Directory workspaceRoot,
required InstalledSkillE2ECaseInternal testCase,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
);
await store.initialize();
await store.saveSettingsSnapshot(
singleAgentTestSettingsInternal(workspacePath: workspaceRoot.path).copyWith(
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
multiAgent: MultiAgentConfig.defaults().copyWith(enabled: false),
),
);
final controller = InstalledSkillE2EAppControllerInternal(
store: store,
sendGate: Completer<void>(),
outputRelativePath: testCase.outputRelativePath,
outputContent: testCase.outputContent,
importedSkill: AssistantThreadSkillEntry(
key: testCase.skillKey,
label: testCase.skillKey,
description: 'Installed skill under test',
sourcePath: '${skillsRoot.path}/${testCase.skillKey}',
sourceLabel: testCase.skillKey,
),
singleAgentSharedSkillScanRootOverrides: <String>[skillsRoot.path],
);
addTearDown(controller.dispose);
await waitForConditionInternal(() => !controller.initializing);
return controller;
}
class CaptureSendAppControllerInternal extends AppController {
CaptureSendAppControllerInternal({
required SecureConfigStore store,