Add bridge-to-app remote provider test coverage

This commit is contained in:
Haitao Pan 2026-04-11 08:57:09 +08:00
parent b77a486d2c
commit 56e80fef60
5 changed files with 791 additions and 71 deletions

View File

@ -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
- 查询词
- 结果条数
- 结果摘要截图

View File

@ -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<void> _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<int> _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) {
final encoding = artifact.encoding.trim().toLowerCase();
if (encoding == 'base64') {
return base64Decode(artifact.content);
}
return utf8.encode(artifact.content);
}
Future<File> _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',
);
}

View File

@ -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<String> writtenFiles;
}
Future<DesktopThreadArtifactSyncResult> syncInlineArtifactsToLocalWorkspace({
required Directory root,
required List<GoTaskServiceArtifact> artifacts,
}) async {
await root.create(recursive: true);
final writtenFiles = <String>[];
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<String>.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<int> decodeArtifactContent(GoTaskServiceArtifact artifact) {
final encoding = artifact.encoding.trim().toLowerCase();
if (encoding == 'base64') {
return base64Decode(artifact.content);
}
return utf8.encode(artifact.content);
}
Future<File> 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',
);
}

View File

@ -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 = <String, String>{
'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(<String>['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 <String>['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: <String>[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: <String>[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<void> 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<String> selectedSkills = const <String>[],
bool resumeSession = false,
}) {
return GoTaskServiceRequest(
sessionId: sessionId,
threadId: threadId,
target: AssistantExecutionTarget.singleAgent,
prompt: prompt,
workingDirectory: workingDirectory,
model: '',
thinking: '',
selectedSkills: selectedSkills,
inlineAttachments: const <GatewayChatAttachmentPayload>[],
localAttachments: const <CollaborationAttachment>[],
aiGatewayBaseUrl: '',
aiGatewayApiKey: '',
agentId: '',
metadata: const <String, dynamic>{},
routing: ExternalCodeAgentAcpRoutingConfig(
mode: ExternalCodeAgentAcpRoutingMode.explicit,
preferredGatewayTarget: 'local',
explicitExecutionTarget: 'singleAgent',
explicitProviderId: providerId,
explicitModel: '',
explicitSkills: selectedSkills,
allowSkillInstall: false,
availableSkills: const <ExternalCodeAgentAcpAvailableSkill>[],
),
provider: SingleAgentProviderCopy.fromJsonValue(providerId),
remoteWorkingDirectoryHint: '',
resumeSession: resumeSession,
);
}

View File

@ -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>[
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>[
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>[
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>[
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(<int>[1, 2, 3, 4, 5]);
final result = await syncInlineArtifactsToLocalWorkspace(
root: root,
artifacts: <GoTaskServiceArtifact>[
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(), <int>[
1,
2,
3,
4,
5,
]);
});
});
}