diff --git a/docs/cases/core-integration-manual-cases.md b/docs/cases/core-integration-manual-cases.md index 2ff876bc..77405342 100644 --- a/docs/cases/core-integration-manual-cases.md +++ b/docs/cases/core-integration-manual-cases.md @@ -258,6 +258,184 @@ - 标题、来源、摘要等字段完整 - 结果留在当前线程内 - 建议记录项 + - 当前 provider / endpoint + - 输入提示词 + - 结果中的标题 / 来源 / 摘要 + - 是否回到当前线程 + +## 5. XWorkmate App -> XWorkmate Bridge 远端单 Agent / Gateway 验收 + +这些 case 用于验证 `xworkmate-app` 通过本地 `GoAcpStdioBridge` 调用 +`xworkmate-bridge`,再转发到远端 `codex / opencode / gemini / openclaw` +时的真实线程行为,重点关注: + +- provider 选择是否正确 +- follow-up 是否保持同一 thread +- artifact 是否写回当前线程本地 workspace +- `lastRemoteWorkingDirectory` / `remoteWorkspaceRefKind` 是否只作为 metadata + +统一新增记录项: + +- 当前模式 +- 当前 provider / endpoint +- 输入提示词 +- 线程 ID +- 本地线程 workspace 路径 +- 产物路径列表 +- `lastRemoteWorkingDirectory` +- `remoteWorkspaceRefKind` +- 是否需要外部服务人工确认 + +### `MANUAL-REMOTE-001` Codex 对话 + `pptx` + +- 前置条件 + - 已选择任务对话模式 `codex` + - bridge/provider 连通 +- 操作步骤 + 1. 输入“生成一个两页产品介绍演示稿,输出为 `deck.pptx`” + 2. 等待任务完成并确认 artifact 区出现 `.pptx` + 3. 在同一线程继续追问“把第二页改成总结页” +- 期望结果 + - 对话可用 + - `.pptx` 写回当前线程本地 workspace + - follow-up 复用同一线程,不漂移到其他 provider + - `lastRemoteWorkingDirectory` 更新,但 `workspaceBinding` 仍是本地目录 + +### `MANUAL-REMOTE-002` Codex `docx/xlsx/pdf` + +- 前置条件 + - 已选择任务对话模式 `codex` +- 操作步骤 + 1. 执行 `docx`:生成一份周报文档 + 2. 执行 `xlsx`:生成一个带汇总公式的销售表 + 3. 执行 `pdf`:生成或转换出一个 PDF 摘要文件 +- 期望结果 + - 三类任务均可执行 + - 产物分别出现在 artifact 区 + - 文件落回当前线程本地 workspace + +### `MANUAL-REMOTE-003` Codex `image-resizer` + +- 前置条件 + - 已选择任务对话模式 `codex` + - 线程目录内有一张待处理图片 +- 操作步骤 + 1. 输入“将 `input.png` 缩放到 1200x800 并输出 `resized.png`” + 2. 等待结果完成 +- 期望结果 + - 图片处理成功 + - 输出图片写回当前线程本地 workspace + - 结果摘要含尺寸或压缩信息 + +### `MANUAL-REMOTE-004` OpenCode 对话 + `pptx` + +- 前置条件 + - 已选择任务对话模式 `opencode` +- 操作步骤 + 1. 输入“生成一个两页演示稿 `deck.pptx`” + 2. 等待完成 + 3. 同线程继续追问修改第二页内容 +- 期望结果 + - 对话可用 + - `.pptx` 落回当前线程本地 workspace + - follow-up 继续复用同一线程上下文 + +### `MANUAL-REMOTE-005` OpenCode `docx/xlsx/pdf` + +- 前置条件 + - 已选择任务对话模式 `opencode` +- 操作步骤 + 1. 执行 `docx` + 2. 执行 `xlsx` + 3. 执行 `pdf` +- 期望结果 + - 三类任务可用 + - 产物可见且落回当前线程本地 workspace + +### `MANUAL-REMOTE-006` OpenCode `image-resizer` + +- 前置条件 + - 已选择任务对话模式 `opencode` + - 已准备本地输入图片 +- 操作步骤 + 1. 输入图片缩放任务 + 2. 等待结果 +- 期望结果 + - 输出图片可见 + - 线程 artifact 和本地 workspace 均可确认结果 + +### `MANUAL-REMOTE-007` Gemini 基础对话 + +- 前置条件 + - 已选择任务对话模式 `gemini` +- 操作步骤 + 1. 输入“回复 exactly pong” + 2. 在同一线程继续追问“回复 exactly round2” +- 期望结果 + - 基础对话可用 + - 两轮消息都停留在同一线程 + - provider 显示仍为 `gemini` + +### `MANUAL-REMOTE-008` Gemini 文档 / 图片任务能力边界确认 + +- 前置条件 + - 已选择任务对话模式 `gemini` +- 操作步骤 + 1. 分别尝试 `docx / pptx / xlsx / pdf / image-resizer` + 2. 记录每项成功或失败 +- 期望结果 + - 若成功:artifact 落回当前线程本地 workspace + - 若失败:错误摘要明确,可区分是 provider 能力限制还是 bridge/app 落盘问题 + +### `MANUAL-GATEWAY-001` OpenClaw Gateway 基础对话 + +- 前置条件 + - 任务线程使用 remote gateway / `openclaw gateway` + - `openclaw.svc.plus` 可连通 +- 操作步骤 + 1. 输入普通对话任务 + 2. 等待 gateway 返回结果 +- 期望结果 + - 可建立对话 + - 线程消息返回成功或明确失败摘要 + - provider / mode 显示为 gateway 路径 + +### `MANUAL-GATEWAY-002` OpenClaw Gateway 文档类任务 + +- 前置条件 + - Gateway 路径可用 +- 操作步骤 + 1. 执行 `docx` 或 `pptx` + 2. 执行 `xlsx` 或 `pdf` +- 期望结果 + - 至少 1-2 类文档任务成功 + - 若返回 artifact,应回写当前线程本地 workspace + - 若只返回文本摘要,应记录为“对话成功但无 artifact” + +### `MANUAL-GATEWAY-003` OpenClaw Gateway 浏览器自动化 + +- 前置条件 + - Gateway 浏览器能力可用 + - 有可访问网页 +- 操作步骤 + 1. 输入“打开示例页面并提取标题” + 2. 如支持截图,再追问“截图并保存结果” +- 期望结果 + - 可执行浏览器任务 + - 返回网页摘要 + - 若有截图 / 日志产物,应进入当前线程 artifact + +### `MANUAL-GATEWAY-004` OpenClaw Gateway 在线资讯汇总 + +- 前置条件 + - Gateway 联网能力可用 +- 操作步骤 + 1. 输入“汇总今天关于 AI Agent 的 5 条资讯” + 2. 查看结构化结果 +- 期望结果 + - 能返回标题、来源、摘要 + - 结果留在当前线程 + - 若生成文档或截图,写回当前线程本地 workspace - 查询词 - 结果条数 - 结果摘要截图 diff --git a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart index b94d1cc5..914ca38e 100644 --- a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart +++ b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart @@ -6,6 +6,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; +import '../runtime/desktop_thread_artifact_sync.dart'; import '../runtime/go_task_service_client.dart'; import '../runtime/runtime_models.dart'; import 'app_controller_desktop_core.dart'; @@ -364,81 +365,17 @@ Future _persistSingleAgentArtifactsDesktopInternal( return; } final root = Directory(existingThread.workspaceBinding.workspacePath); - await root.create(recursive: true); - - var wroteArtifact = false; - for (final artifact in artifacts) { - if (!artifact.hasInlineContent) { - continue; - } - final relativePath = _sanitizeArtifactRelativePathInternal( - artifact.relativePath, - ); - if (relativePath.isEmpty) { - continue; - } - final target = await _nextArtifactTargetFileInternal(root, relativePath); - await target.parent.create(recursive: true); - await target.writeAsBytes( - _decodeArtifactContentInternal(artifact), - flush: true, - ); - wroteArtifact = true; - } + final syncResult = await syncInlineArtifactsToLocalWorkspace( + root: root, + artifacts: artifacts, + ); controller.upsertTaskThreadInternal( sessionKey, lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastArtifactSyncStatus: wroteArtifact ? 'synced' : 'no-inline-content', + lastArtifactSyncStatus: syncResult.wroteArtifact + ? 'synced' + : 'no-inline-content', updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); } - -String _sanitizeArtifactRelativePathInternal(String raw) { - final trimmed = raw.trim().replaceAll('\\', '/'); - if (trimmed.isEmpty) { - return ''; - } - final cleaned = trimmed - .split('/') - .where( - (segment) => segment.isNotEmpty && segment != '.' && segment != '..', - ) - .join('/'); - return cleaned; -} - -List _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) { - final encoding = artifact.encoding.trim().toLowerCase(); - if (encoding == 'base64') { - return base64Decode(artifact.content); - } - return utf8.encode(artifact.content); -} - -Future _nextArtifactTargetFileInternal( - Directory root, - String relativePath, -) async { - final segments = relativePath.split('/'); - final fileName = segments.removeLast(); - final parent = segments.isEmpty - ? root - : Directory('${root.path}/${segments.join('/')}'); - final dotIndex = fileName.lastIndexOf('.'); - final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex); - final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex); - var candidate = File('${parent.path}/$fileName'); - if (!await candidate.exists()) { - return candidate; - } - for (var version = 2; version < 1000; version += 1) { - candidate = File('${parent.path}/$baseName.v$version$extension'); - if (!await candidate.exists()) { - return candidate; - } - } - return File( - '${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension', - ); -} diff --git a/lib/runtime/desktop_thread_artifact_sync.dart b/lib/runtime/desktop_thread_artifact_sync.dart new file mode 100644 index 00000000..8c2f60fa --- /dev/null +++ b/lib/runtime/desktop_thread_artifact_sync.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'go_task_service_client.dart'; + +class DesktopThreadArtifactSyncResult { + const DesktopThreadArtifactSyncResult({ + required this.wroteArtifact, + required this.writtenFiles, + }); + + final bool wroteArtifact; + final List writtenFiles; +} + +Future syncInlineArtifactsToLocalWorkspace({ + required Directory root, + required List artifacts, +}) async { + await root.create(recursive: true); + final writtenFiles = []; + for (final artifact in artifacts) { + if (!artifact.hasInlineContent) { + continue; + } + final relativePath = sanitizeArtifactRelativePath(artifact.relativePath); + if (relativePath.isEmpty) { + continue; + } + final target = await nextArtifactTargetFile(root, relativePath); + await target.parent.create(recursive: true); + await target.writeAsBytes(decodeArtifactContent(artifact), flush: true); + writtenFiles.add(target.path); + } + return DesktopThreadArtifactSyncResult( + wroteArtifact: writtenFiles.isNotEmpty, + writtenFiles: List.unmodifiable(writtenFiles), + ); +} + +String sanitizeArtifactRelativePath(String raw) { + final trimmed = raw.trim().replaceAll('\\', '/'); + if (trimmed.isEmpty) { + return ''; + } + return trimmed + .split('/') + .where( + (segment) => segment.isNotEmpty && segment != '.' && segment != '..', + ) + .join('/'); +} + +List decodeArtifactContent(GoTaskServiceArtifact artifact) { + final encoding = artifact.encoding.trim().toLowerCase(); + if (encoding == 'base64') { + return base64Decode(artifact.content); + } + return utf8.encode(artifact.content); +} + +Future nextArtifactTargetFile(Directory root, String relativePath) async { + final segments = relativePath.split('/'); + final fileName = segments.removeLast(); + final parent = segments.isEmpty + ? root + : Directory('${root.path}/${segments.join('/')}'); + final dotIndex = fileName.lastIndexOf('.'); + final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex); + final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex); + var candidate = File('${parent.path}/$fileName'); + if (!await candidate.exists()) { + return candidate; + } + for (var version = 2; version < 1000; version += 1) { + candidate = File('${parent.path}/$baseName.v$version$extension'); + if (!await candidate.exists()) { + return candidate; + } + } + return File( + '${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension', + ); +} diff --git a/test/runtime/bridge_real_e2e_test.dart b/test/runtime/bridge_real_e2e_test.dart new file mode 100644 index 00000000..77332d12 --- /dev/null +++ b/test/runtime/bridge_real_e2e_test.dart @@ -0,0 +1,352 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/desktop_thread_artifact_sync.dart'; +import 'package:xworkmate/runtime/external_code_agent_acp_desktop_transport.dart'; +import 'package:xworkmate/runtime/go_task_service_client.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +const _providerEndpoints = { + 'codex': 'https://acp-server.svc.plus/codex/acp/rpc', + 'opencode': 'https://acp-server.svc.plus/opencode/acp/rpc', + 'gemini': 'https://acp-server.svc.plus/gemini/acp/rpc', +}; + +const _tinyPngBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0x8AAAAASUVORK5CYII='; + +void main() { + final runRealE2E = + Platform.environment['RUN_REAL_BRIDGE_E2E'] == '1' || + Platform.environment['RUN_REAL_BRIDGE_E2E'] == 'true'; + final bridgeAuthToken = + Platform.environment['BRIDGE_AUTH_TOKEN']?.trim() ?? ''; + final openclawGatewayToken = + Platform.environment['OPENCLAW_GATEWAY_TOKEN']?.trim() ?? ''; + + group('real bridge provider matrix', () { + late ExternalCodeAgentAcpDesktopTransport transport; + + setUpAll(() async { + if (!runRealE2E || bridgeAuthToken.isEmpty) { + return; + } + transport = ExternalCodeAgentAcpDesktopTransport(); + await transport.syncExternalProviders( + _providerEndpoints.entries + .map( + (entry) => ExternalCodeAgentAcpSyncedProvider( + providerId: entry.key, + label: entry.key, + endpoint: entry.value, + authorizationHeader: 'Bearer $bridgeAuthToken', + enabled: true, + ), + ) + .toList(growable: false), + ); + }); + + tearDownAll(() async { + if (runRealE2E && bridgeAuthToken.isNotEmpty) { + await transport.dispose(); + } + }); + + test('loads external ACP capabilities and provider catalog', () async { + if (!runRealE2E || bridgeAuthToken.isEmpty) { + return; + } + final capabilities = await transport.loadExternalAcpCapabilities( + target: AssistantExecutionTarget.singleAgent, + ); + expect(capabilities.singleAgent, isTrue); + expect( + capabilities.providerCatalog.map((item) => item.providerId), + containsAll(['codex', 'opencode', 'gemini']), + ); + }); + + for (final providerId in _providerEndpoints.keys) { + test('$providerId supports a two-turn conversation', () async { + if (!runRealE2E || bridgeAuthToken.isEmpty) { + return; + } + final workdir = await Directory.systemTemp.createTemp( + 'xworkmate-$providerId-conversation-', + ); + addTearDown(() async { + if (await workdir.exists()) { + await workdir.delete(recursive: true); + } + }); + + final startResult = await transport.executeTask( + _buildRequest( + providerId: providerId, + sessionId: 'conversation-$providerId', + threadId: 'conversation-$providerId', + workingDirectory: workdir.path, + prompt: 'Reply with exactly pong.', + ), + onUpdate: (_) {}, + ); + expect(startResult.success, isTrue); + expect(startResult.resolvedProviderId, providerId); + + final messageResult = await transport.executeTask( + _buildRequest( + providerId: providerId, + sessionId: 'conversation-$providerId', + threadId: 'conversation-$providerId', + workingDirectory: workdir.path, + prompt: 'Reply with exactly round2.', + resumeSession: true, + ), + onUpdate: (_) {}, + ); + expect(messageResult.success, isTrue); + expect(messageResult.resolvedProviderId, providerId); + expect( + messageResult.message.toLowerCase(), + contains('round2'), + reason: 'follow-up should stay on the same provider/thread', + ); + }); + } + + for (final providerId in ['codex', 'opencode']) { + for (final scenario in _artifactScenarios) { + test( + '$providerId can return ${scenario.skill} artifacts to local workspace', + () async { + if (!runRealE2E || bridgeAuthToken.isEmpty) { + return; + } + final workdir = await Directory.systemTemp.createTemp( + 'xworkmate-$providerId-${scenario.skill}-', + ); + addTearDown(() async { + if (await workdir.exists()) { + await workdir.delete(recursive: true); + } + }); + await scenario.prepare?.call(workdir); + + final result = await transport.executeTask( + _buildRequest( + providerId: providerId, + sessionId: '${providerId}-${scenario.skill}', + threadId: '${providerId}-${scenario.skill}', + workingDirectory: workdir.path, + prompt: scenario.prompt, + selectedSkills: [scenario.skill], + ), + onUpdate: (_) {}, + ); + + expect(result.success, isTrue, reason: result.errorMessage); + expect(result.resolvedProviderId, providerId); + expect(result.remoteWorkingDirectory.trim(), isNotEmpty); + expect(result.remoteWorkspaceRefKind, WorkspaceRefKind.remotePath); + expect(result.resultSummary.trim(), isNotEmpty); + expect(result.artifacts, isNotEmpty); + + final syncResult = await syncInlineArtifactsToLocalWorkspace( + root: workdir, + artifacts: result.artifacts, + ); + expect(syncResult.wroteArtifact, isTrue); + expect( + syncResult.writtenFiles.any( + (path) => path.endsWith(scenario.expectedSuffix), + ), + isTrue, + ); + }, + timeout: const Timeout(Duration(minutes: 4)), + ); + } + } + + for (final scenario in _artifactScenarios) { + test( + 'gemini reports either success or a provider limitation for ${scenario.skill}', + () async { + if (!runRealE2E || bridgeAuthToken.isEmpty) { + return; + } + final workdir = await Directory.systemTemp.createTemp( + 'xworkmate-gemini-${scenario.skill}-', + ); + addTearDown(() async { + if (await workdir.exists()) { + await workdir.delete(recursive: true); + } + }); + await scenario.prepare?.call(workdir); + + final result = await transport.executeTask( + _buildRequest( + providerId: 'gemini', + sessionId: 'gemini-${scenario.skill}', + threadId: 'gemini-${scenario.skill}', + workingDirectory: workdir.path, + prompt: scenario.prompt, + selectedSkills: [scenario.skill], + ), + onUpdate: (_) {}, + ); + + expect(result.resolvedProviderId, 'gemini'); + if (result.success) { + final syncResult = await syncInlineArtifactsToLocalWorkspace( + root: workdir, + artifacts: result.artifacts, + ); + expect(syncResult.wroteArtifact, isTrue); + } else { + expect( + result.errorMessage.trim().isNotEmpty || + result.message.trim().isNotEmpty, + isTrue, + reason: + 'provider limitation should still surface a clear summary', + ); + } + }, + timeout: const Timeout(Duration(minutes: 4)), + ); + } + }); + + group('openclaw gateway smoke', () { + test('defaultsRemote still targets openclaw.svc.plus:443', () { + final profile = GatewayConnectionProfile.defaultsRemote(); + expect(profile.host, 'openclaw.svc.plus'); + expect(profile.port, 443); + expect(profile.tls, isTrue); + }); + + test('wss endpoint is reachable', () async { + if (!runRealE2E) { + return; + } + final client = HttpClient(); + addTearDown(client.close); + final request = await client.getUrl( + Uri.parse('https://openclaw.svc.plus'), + ); + final response = await request.close(); + expect(response.statusCode, anyOf(200, 400, 401, 403, 404, 426)); + }); + + test( + 'gateway token is wired for future remote runtime coverage', + () { + if (!runRealE2E) { + return; + } + expect( + openclawGatewayToken.isNotEmpty, + isTrue, + reason: + 'Set OPENCLAW_GATEWAY_TOKEN to run remote gateway-chat coverage against openclaw.svc.plus.', + ); + }, + skip: !runRealE2E || openclawGatewayToken.isNotEmpty, + ); + }); +} + +class _ArtifactScenario { + const _ArtifactScenario({ + required this.skill, + required this.prompt, + required this.expectedSuffix, + this.prepare, + }); + + final String skill; + final String prompt; + final String expectedSuffix; + final Future Function(Directory root)? prepare; +} + +final _artifactScenarios = <_ArtifactScenario>[ + const _ArtifactScenario( + skill: 'docx', + prompt: + 'Use the docx skill to create report.docx in the working directory. Include a title and a 2-column table with two rows.', + expectedSuffix: '/report.docx', + ), + const _ArtifactScenario( + skill: 'pptx', + prompt: + 'Use the pptx skill to create deck.pptx in the working directory with two slides titled Intro and Summary.', + expectedSuffix: '/deck.pptx', + ), + const _ArtifactScenario( + skill: 'xlsx', + prompt: + 'Use the xlsx skill to create sales.xlsx in the working directory with a totals formula column.', + expectedSuffix: '/sales.xlsx', + ), + const _ArtifactScenario( + skill: 'pdf', + prompt: + 'Use the pdf skill to create summary.pdf in the working directory with a one-page summary of bridge validation.', + expectedSuffix: '/summary.pdf', + ), + _ArtifactScenario( + skill: 'image-resizer', + prompt: + 'Use the image-resizer skill to resize input.png to 1200x800 and save the output as resized.png in the working directory.', + expectedSuffix: '/resized.png', + prepare: (root) async { + final bytes = base64Decode(_tinyPngBase64); + await File('${root.path}/input.png').writeAsBytes(bytes, flush: true); + }, + ), +]; + +GoTaskServiceRequest _buildRequest({ + required String providerId, + required String sessionId, + required String threadId, + required String workingDirectory, + required String prompt, + List selectedSkills = const [], + bool resumeSession = false, +}) { + return GoTaskServiceRequest( + sessionId: sessionId, + threadId: threadId, + target: AssistantExecutionTarget.singleAgent, + prompt: prompt, + workingDirectory: workingDirectory, + model: '', + thinking: '', + selectedSkills: selectedSkills, + inlineAttachments: const [], + localAttachments: const [], + aiGatewayBaseUrl: '', + aiGatewayApiKey: '', + agentId: '', + metadata: const {}, + routing: ExternalCodeAgentAcpRoutingConfig( + mode: ExternalCodeAgentAcpRoutingMode.explicit, + preferredGatewayTarget: 'local', + explicitExecutionTarget: 'singleAgent', + explicitProviderId: providerId, + explicitModel: '', + explicitSkills: selectedSkills, + allowSkillInstall: false, + availableSkills: const [], + ), + provider: SingleAgentProviderCopy.fromJsonValue(providerId), + remoteWorkingDirectoryHint: '', + resumeSession: resumeSession, + ); +} diff --git a/test/runtime/desktop_thread_artifact_sync_test.dart b/test/runtime/desktop_thread_artifact_sync_test.dart new file mode 100644 index 00000000..3bad8536 --- /dev/null +++ b/test/runtime/desktop_thread_artifact_sync_test.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/desktop_thread_artifact_sync.dart'; +import 'package:xworkmate/runtime/go_task_service_client.dart'; + +void main() { + group('syncInlineArtifactsToLocalWorkspace', () { + test('writes inline artifacts into the local workspace', () async { + final root = Directory.systemTemp.createTempSync( + 'xworkmate-artifact-sync-', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + final result = await syncInlineArtifactsToLocalWorkspace( + root: root, + artifacts: const [ + GoTaskServiceArtifact( + relativePath: 'reports/weekly.docx', + label: 'weekly.docx', + contentType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + encoding: 'utf8', + content: 'docx-bytes-placeholder', + downloadUrl: '', + sizeBytes: null, + sha256: '', + ), + ], + ); + + expect(result.wroteArtifact, isTrue); + expect(result.writtenFiles, hasLength(1)); + final file = File(result.writtenFiles.single); + expect(await file.exists(), isTrue); + expect(await file.readAsString(), 'docx-bytes-placeholder'); + }); + + test( + 'sanitizes parent traversal and preserves nested relative paths', + () async { + final root = Directory.systemTemp.createTempSync( + 'xworkmate-artifact-sanitize-', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + final result = await syncInlineArtifactsToLocalWorkspace( + root: root, + artifacts: const [ + GoTaskServiceArtifact( + relativePath: '../unsafe/../../slides/demo.pptx', + label: 'demo.pptx', + contentType: + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + encoding: 'utf8', + content: 'pptx-bytes-placeholder', + downloadUrl: '', + sizeBytes: null, + sha256: '', + ), + ], + ); + + expect( + result.writtenFiles.single, + endsWith('/unsafe/slides/demo.pptx'), + ); + expect(File('${root.path}/demo.pptx').existsSync(), isFalse); + }, + ); + + test('creates versioned files when the target path already exists', () async { + final root = Directory.systemTemp.createTempSync( + 'xworkmate-artifact-version-', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + final original = File('${root.path}/table.xlsx'); + await original.writeAsString('v1'); + + final first = await syncInlineArtifactsToLocalWorkspace( + root: root, + artifacts: const [ + GoTaskServiceArtifact( + relativePath: 'table.xlsx', + label: 'table.xlsx', + contentType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + encoding: 'utf8', + content: 'v2', + downloadUrl: '', + sizeBytes: null, + sha256: '', + ), + ], + ); + final second = await syncInlineArtifactsToLocalWorkspace( + root: root, + artifacts: const [ + GoTaskServiceArtifact( + relativePath: 'table.xlsx', + label: 'table.xlsx', + contentType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + encoding: 'utf8', + content: 'v3', + downloadUrl: '', + sizeBytes: null, + sha256: '', + ), + ], + ); + + expect(first.writtenFiles.single, endsWith('/table.v2.xlsx')); + expect(second.writtenFiles.single, endsWith('/table.v3.xlsx')); + expect(await File(first.writtenFiles.single).readAsString(), 'v2'); + expect(await File(second.writtenFiles.single).readAsString(), 'v3'); + }); + + test('decodes base64 inline content for binary-like artifacts', () async { + final root = Directory.systemTemp.createTempSync( + 'xworkmate-artifact-base64-', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + final payload = base64Encode([1, 2, 3, 4, 5]); + final result = await syncInlineArtifactsToLocalWorkspace( + root: root, + artifacts: [ + GoTaskServiceArtifact( + relativePath: 'images/resized.png', + label: 'resized.png', + contentType: 'image/png', + encoding: 'base64', + content: payload, + downloadUrl: '', + sizeBytes: 5, + sha256: '', + ), + ], + ); + + expect(await File(result.writtenFiles.single).readAsBytes(), [ + 1, + 2, + 3, + 4, + 5, + ]); + }); + }); +}