Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
587 lines
19 KiB
Go
587 lines
19 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
stderrors "errors"
|
|
"io"
|
|
"mime/multipart"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/sandbox"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Test doubles
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// fakeSandboxSource is an in-memory stand-in for SessionBoundManager that
|
|
// records the calls the collector makes and returns pre-programmed
|
|
// responses. Concrete tests populate `entries` (one map per session) and
|
|
// `contents` (one map per absolute path).
|
|
type fakeSandboxSource struct {
|
|
entries map[string][]sandbox.RemoteDirEntry
|
|
contents map[string][]byte
|
|
|
|
listErr error
|
|
readErr error
|
|
|
|
listCalls int
|
|
readCalls []string
|
|
}
|
|
|
|
func (f *fakeSandboxSource) ListSessionFiles(_ context.Context, sessionID, _ string) ([]sandbox.RemoteDirEntry, error) {
|
|
f.listCalls++
|
|
if f.listErr != nil {
|
|
return nil, f.listErr
|
|
}
|
|
return f.entries[sessionID], nil
|
|
}
|
|
|
|
func (f *fakeSandboxSource) ReadSessionFile(_ context.Context, _ string, path string) ([]byte, error) {
|
|
f.readCalls = append(f.readCalls, path)
|
|
if f.readErr != nil {
|
|
return nil, f.readErr
|
|
}
|
|
if data, ok := f.contents[path]; ok {
|
|
return data, nil
|
|
}
|
|
return nil, stderrors.New("fake source: not found: " + path)
|
|
}
|
|
|
|
// fakeStore lets a test declare which (path, mtime) tuples the collector
|
|
// should treat as already recorded.
|
|
type fakeStore struct {
|
|
prev []types.MessageArtifact
|
|
err error
|
|
}
|
|
|
|
func (s *fakeStore) KnownArtifacts(_ context.Context, _ string) ([]types.MessageArtifact, error) {
|
|
return s.prev, s.err
|
|
}
|
|
|
|
// fakeFileService captures uploads and returns a deterministic provider URL.
|
|
// Only SaveBytes is exercised by the collector; the other methods are
|
|
// implemented to satisfy interfaces.FileService but panic on use so a
|
|
// regression that starts calling them doesn't fail silently.
|
|
type fakeFileService struct {
|
|
saved map[string][]byte
|
|
saveErr error
|
|
seq int
|
|
tenantIDs []uint64
|
|
}
|
|
|
|
func (f *fakeFileService) CheckConnectivity(_ context.Context) error { return nil }
|
|
|
|
func (f *fakeFileService) SaveFile(_ context.Context, _ *multipart.FileHeader, _ uint64, _ string) (string, error) {
|
|
panic("SaveFile should not be called by ArtifactCollector")
|
|
}
|
|
|
|
func (f *fakeFileService) SaveBytes(_ context.Context, data []byte, tenantID uint64, fileName string, _ bool) (string, error) {
|
|
if f.saveErr != nil {
|
|
return "", f.saveErr
|
|
}
|
|
if f.saved == nil {
|
|
f.saved = map[string][]byte{}
|
|
}
|
|
f.seq++
|
|
key := "fake://tenant-" + fileName
|
|
f.saved[key] = append([]byte(nil), data...)
|
|
f.tenantIDs = append(f.tenantIDs, tenantID)
|
|
return key, nil
|
|
}
|
|
|
|
func (f *fakeFileService) GetFile(_ context.Context, _ string) (io.ReadCloser, error) {
|
|
panic("GetFile should not be called by ArtifactCollector")
|
|
}
|
|
|
|
func (f *fakeFileService) GetFileURL(_ context.Context, _ string) (string, error) {
|
|
panic("GetFileURL should not be called by ArtifactCollector")
|
|
}
|
|
|
|
func (f *fakeFileService) DeleteFile(_ context.Context, _ string) error { return nil }
|
|
|
|
func (f *fakeFileService) CopyFile(_ context.Context, _ string, _ uint64, _ string) (string, error) {
|
|
panic("CopyFile should not be called by ArtifactCollector")
|
|
}
|
|
|
|
// resourceRefFileService is a fileService whose SaveBytes returns a valid
|
|
// resource:// reference, so the collector's binding path is exercised.
|
|
type resourceRefFileService struct {
|
|
fakeFileService
|
|
handle string
|
|
}
|
|
|
|
func (f *resourceRefFileService) SaveBytes(_ context.Context, data []byte, tenantID uint64, fileName string, _ bool) (string, error) {
|
|
if f.saved == nil {
|
|
f.saved = map[string][]byte{}
|
|
}
|
|
f.seq++
|
|
ref := types.BuildResourcePath(f.handle)
|
|
f.saved[ref] = append([]byte(nil), data...)
|
|
f.tenantIDs = append(f.tenantIDs, tenantID)
|
|
return ref, nil
|
|
}
|
|
|
|
// bindCall records one Bind invocation for assertions.
|
|
type bindCall struct {
|
|
ref string
|
|
ownerType string
|
|
ownerID string
|
|
relation string
|
|
}
|
|
|
|
// fakeCatalog captures Bind calls; every other ResourceCatalog method is a
|
|
// no-op stub because the collector only ever calls Bind.
|
|
type fakeCatalog struct {
|
|
binds []bindCall
|
|
bindErr error
|
|
// Release scripting, used by the deletion-guard tests. releaseRemaining
|
|
// maps a reference to the binding count left after the release; a missing
|
|
// entry answers -1 ("not a catalog handle").
|
|
releaseRemaining map[string]int64
|
|
releaseErr error
|
|
releases []string
|
|
}
|
|
|
|
func (c *fakeCatalog) Register(context.Context, uint64, string, interfaces.ResourceRegistration) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (c *fakeCatalog) Resolve(context.Context, string) (*types.StoredResource, error) {
|
|
return nil, nil
|
|
}
|
|
func (c *fakeCatalog) ResolvePath(_ context.Context, v string) (string, *types.StoredResource, error) {
|
|
return v, nil, nil
|
|
}
|
|
func (c *fakeCatalog) Bind(_ context.Context, ref, ownerType, ownerID, relation string) error {
|
|
c.binds = append(c.binds, bindCall{ref, ownerType, ownerID, relation})
|
|
return c.bindErr
|
|
}
|
|
func (c *fakeCatalog) MarkDeleted(context.Context, string) error { return nil }
|
|
|
|
func (c *fakeCatalog) Release(_ context.Context, ref, ownerType, ownerID string) (int64, error) {
|
|
c.releases = append(c.releases, ref+"|"+ownerType+"|"+ownerID)
|
|
if c.releaseErr != nil {
|
|
return -1, c.releaseErr
|
|
}
|
|
if remaining, ok := c.releaseRemaining[ref]; ok {
|
|
return remaining, nil
|
|
}
|
|
return -1, nil
|
|
}
|
|
func (c *fakeCatalog) CreateAccessGrant(context.Context, string, time.Duration) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (c *fakeCatalog) ResolveAccessGrant(context.Context, string) (*types.StoredResource, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Tests
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func newTestCollector(src *fakeSandboxSource, store *fakeStore, fs *fakeFileService, max int64) *ArtifactCollector {
|
|
// catalog is nil here: these tests use a fakeFileService that returns raw
|
|
// "fake://" paths, so no resource binding is attempted. Binding behaviour
|
|
// is covered separately in TestArtifactCollector_BindsResourceToMessage.
|
|
return NewArtifactCollector(src, fs, store, nil, ArtifactCollectorConfig{MaxFileBytes: max})
|
|
}
|
|
|
|
func TestArtifactCollector_CollectsNewFiles(t *testing.T) {
|
|
ctx := context.Background()
|
|
src := &fakeSandboxSource{
|
|
entries: map[string][]sandbox.RemoteDirEntry{
|
|
"sess-1": {
|
|
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
|
{Name: "summary.txt", Path: "/workspace/output/summary.txt", Type: sandbox.RemoteEntryFile, Size: 3, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
|
|
},
|
|
},
|
|
contents: map[string][]byte{
|
|
"/workspace/output/report.pptx": []byte("PPTX"),
|
|
"/workspace/output/summary.txt": []byte("hey"),
|
|
},
|
|
}
|
|
store := &fakeStore{}
|
|
fs := &fakeFileService{}
|
|
c := newTestCollector(src, store, fs, 1<<20)
|
|
|
|
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("Collect() len = %d, want 2 (%+v)", len(got), got)
|
|
}
|
|
// Files must be uploaded with the tenant ID passed by the caller.
|
|
for _, tID := range fs.tenantIDs {
|
|
if tID != 42 {
|
|
t.Fatalf("SaveBytes tenant = %d, want 42", tID)
|
|
}
|
|
}
|
|
// Both files must have populated URL + FileType + FileSize.
|
|
for _, art := range got {
|
|
if art.URL == "" {
|
|
t.Fatalf("artifact URL empty: %+v", art)
|
|
}
|
|
if art.FileType == "" {
|
|
t.Fatalf("artifact FileType empty: %+v", art)
|
|
}
|
|
if art.FileSize == 0 {
|
|
t.Fatalf("artifact FileSize zero: %+v", art)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_NotifyFiresBeforeUpload(t *testing.T) {
|
|
ctx := context.Background()
|
|
src := &fakeSandboxSource{
|
|
entries: map[string][]sandbox.RemoteDirEntry{
|
|
"sess-1": {
|
|
{
|
|
Name: "a.html", Path: "/workspace/output/a.html",
|
|
Type: sandbox.RemoteEntryFile, Size: 2,
|
|
ModTime: mustParseTime("2026-07-10T10:20:33Z"),
|
|
},
|
|
{
|
|
Name: "b.csv", Path: "/workspace/output/b.csv",
|
|
Type: sandbox.RemoteEntryFile, Size: 2,
|
|
ModTime: mustParseTime("2026-07-10T10:20:34Z"),
|
|
},
|
|
},
|
|
},
|
|
contents: map[string][]byte{
|
|
"/workspace/output/a.html": []byte("ok"),
|
|
"/workspace/output/b.csv": []byte("x,"),
|
|
},
|
|
}
|
|
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
|
|
|
|
var notified int
|
|
got, err := c.CollectWithNotify(ctx, "sess-1", "msg-1", 42, "/workspace/output", func(n int) {
|
|
if src.readCalls != nil {
|
|
t.Fatalf("notify ran after ReadSessionFile: %v", src.readCalls)
|
|
}
|
|
notified = n
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CollectWithNotify() error = %v", err)
|
|
}
|
|
if notified != 2 {
|
|
t.Fatalf("notify count = %d, want 2", notified)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("CollectWithNotify() len = %d, want 2", len(got))
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_SkipsAlreadyKnown(t *testing.T) {
|
|
ctx := context.Background()
|
|
mod, _ := time.Parse(time.RFC3339, "2026-07-10T10:20:33Z")
|
|
src := &fakeSandboxSource{
|
|
entries: map[string][]sandbox.RemoteDirEntry{
|
|
"sess-1": {
|
|
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
|
},
|
|
},
|
|
contents: map[string][]byte{
|
|
"/workspace/output/report.pptx": []byte("PPTX"),
|
|
},
|
|
}
|
|
store := &fakeStore{prev: []types.MessageArtifact{
|
|
{SourcePath: "/workspace/output/report.pptx", ModTime: mod},
|
|
}}
|
|
fs := &fakeFileService{}
|
|
c := newTestCollector(src, store, fs, 1<<20)
|
|
|
|
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("Collect() len = %d, want 0 (dedupe should have kicked in)", len(got))
|
|
}
|
|
if len(fs.saved) != 0 {
|
|
t.Fatalf("SaveBytes should not have been called; saved=%v", fs.saved)
|
|
}
|
|
if len(src.readCalls) != 0 {
|
|
t.Fatalf("ReadSessionFile should not have been called; readCalls=%v", src.readCalls)
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_ReattachesOnMtimeChange(t *testing.T) {
|
|
ctx := context.Background()
|
|
oldMod, _ := time.Parse(time.RFC3339, "2026-07-10T10:20:33Z")
|
|
src := &fakeSandboxSource{
|
|
entries: map[string][]sandbox.RemoteDirEntry{
|
|
"sess-1": {
|
|
// Same path as the known set, but a *newer* mtime — must be re-attached.
|
|
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:21:00Z")},
|
|
},
|
|
},
|
|
contents: map[string][]byte{
|
|
"/workspace/output/report.pptx": []byte("PPTX"),
|
|
},
|
|
}
|
|
store := &fakeStore{prev: []types.MessageArtifact{
|
|
{SourcePath: "/workspace/output/report.pptx", ModTime: oldMod},
|
|
}}
|
|
fs := &fakeFileService{}
|
|
c := newTestCollector(src, store, fs, 1<<20)
|
|
|
|
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("Collect() len = %d, want 1 (mtime-change should re-attach)", len(got))
|
|
}
|
|
if len(fs.saved) != 1 {
|
|
t.Fatalf("SaveBytes calls = %d, want 1", len(fs.saved))
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_SkipsOversize(t *testing.T) {
|
|
ctx := context.Background()
|
|
src := &fakeSandboxSource{
|
|
entries: map[string][]sandbox.RemoteDirEntry{
|
|
"sess-1": {
|
|
{Name: "huge.bin", Path: "/workspace/output/huge.bin", Type: sandbox.RemoteEntryFile, Size: 1024, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
|
{Name: "ok.txt", Path: "/workspace/output/ok.txt", Type: sandbox.RemoteEntryFile, Size: 3, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
|
|
},
|
|
},
|
|
contents: map[string][]byte{
|
|
"/workspace/output/huge.bin": bytesN(2048),
|
|
"/workspace/output/ok.txt": []byte("hey"),
|
|
},
|
|
}
|
|
fs := &fakeFileService{}
|
|
c := newTestCollector(src, &fakeStore{}, fs, 100)
|
|
|
|
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("Collect() len = %d, want 1 (oversize file must be skipped)", len(got))
|
|
}
|
|
if got[0].FileName != "ok.txt" {
|
|
t.Fatalf("Collect() kept the wrong file: %+v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_SkipsOversizeAfterRead(t *testing.T) {
|
|
// Envd can report a stale size while the file grows; the collector must
|
|
// enforce the cap against the actual bytes too.
|
|
ctx := context.Background()
|
|
src := &fakeSandboxSource{
|
|
entries: map[string][]sandbox.RemoteDirEntry{
|
|
"sess-1": {
|
|
{Name: "lying.bin", Path: "/workspace/output/lying.bin", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
|
},
|
|
},
|
|
contents: map[string][]byte{
|
|
"/workspace/output/lying.bin": bytesN(2048),
|
|
},
|
|
}
|
|
fs := &fakeFileService{}
|
|
c := newTestCollector(src, &fakeStore{}, fs, 100)
|
|
|
|
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("Collect() len = %d, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_EmptyWhenNoEntries(t *testing.T) {
|
|
ctx := context.Background()
|
|
src := &fakeSandboxSource{}
|
|
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
|
|
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("Collect() len = %d, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_EmptyWhenNoSessionID(t *testing.T) {
|
|
// The collector treats empty session ID as "nothing to do" — the design
|
|
// spec forbids sandbox lookups for chat-only sessions.
|
|
ctx := context.Background()
|
|
src := &fakeSandboxSource{}
|
|
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
|
|
got, err := c.Collect(ctx, "", "msg-1", 42, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("Collect() len = %d, want 0", len(got))
|
|
}
|
|
if src.listCalls != 0 {
|
|
t.Fatalf("expected 0 list calls for empty session id, got %d", src.listCalls)
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_ListErrorDegrades(t *testing.T) {
|
|
ctx := context.Background()
|
|
src := &fakeSandboxSource{listErr: stderrors.New("envd timeout")}
|
|
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
|
|
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v (should degrade gracefully)", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("Collect() len = %d, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_UploadFailureIsPerFile(t *testing.T) {
|
|
// A single failed upload must NOT abort the whole batch — the other
|
|
// files should still be persisted, matching the best-effort contract.
|
|
ctx := context.Background()
|
|
src := &fakeSandboxSource{
|
|
entries: map[string][]sandbox.RemoteDirEntry{
|
|
"sess-1": {
|
|
{Name: "a.txt", Path: "/workspace/output/a.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
|
{Name: "b.txt", Path: "/workspace/output/b.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
|
|
},
|
|
},
|
|
contents: map[string][]byte{
|
|
"/workspace/output/a.txt": []byte("a"),
|
|
"/workspace/output/b.txt": []byte("b"),
|
|
},
|
|
}
|
|
// FileService fails on every call — both files should be skipped without
|
|
// propagating an error.
|
|
fs := &fakeFileService{saveErr: stderrors.New("s3 dead")}
|
|
c := newTestCollector(src, &fakeStore{}, fs, 1<<20)
|
|
|
|
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v (want best-effort)", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("Collect() len = %d, want 0 (all uploads failed)", len(got))
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_FiltersDirectories(t *testing.T) {
|
|
ctx := context.Background()
|
|
src := &fakeSandboxSource{
|
|
entries: map[string][]sandbox.RemoteDirEntry{
|
|
"sess-1": {
|
|
// The production ListSessionFiles never yields dirs, but the
|
|
// collector must defensively skip anything with Type=="dir"
|
|
// so alternate SandboxArtifactSource impls stay safe.
|
|
{Name: "sub", Path: "/workspace/output/sub", Type: sandbox.RemoteEntryDir, Size: 0, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
|
{Name: "a.txt", Path: "/workspace/output/a.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
|
|
},
|
|
},
|
|
contents: map[string][]byte{
|
|
"/workspace/output/a.txt": []byte("a"),
|
|
},
|
|
}
|
|
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
|
|
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("Collect() len = %d, want 1 (dir must be skipped)", len(got))
|
|
}
|
|
if !strings.HasSuffix(got[0].SourcePath, "a.txt") {
|
|
t.Fatalf("wrong file kept: %+v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_BindsResourceToMessage(t *testing.T) {
|
|
ctx := context.Background()
|
|
src := &fakeSandboxSource{
|
|
entries: map[string][]sandbox.RemoteDirEntry{
|
|
"sess-1": {
|
|
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
|
},
|
|
},
|
|
contents: map[string][]byte{
|
|
"/workspace/output/report.pptx": []byte("PPTX"),
|
|
},
|
|
}
|
|
// 22-char handle so BuildResourcePath yields a valid resource:// ref.
|
|
fs := &resourceRefFileService{handle: "abcdefghijklmnopqrstuv"}
|
|
cat := &fakeCatalog{}
|
|
c := NewArtifactCollector(src, fs, &fakeStore{}, cat, ArtifactCollectorConfig{MaxFileBytes: 1 << 20})
|
|
|
|
got, err := c.Collect(ctx, "sess-1", "msg-42", 7, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("Collect() len = %d, want 1", len(got))
|
|
}
|
|
if len(cat.binds) != 1 {
|
|
t.Fatalf("Bind calls = %d, want 1 (%+v)", len(cat.binds), cat.binds)
|
|
}
|
|
b := cat.binds[0]
|
|
if b.ownerType != "message" || b.ownerID != "msg-42" || b.relation != "artifact" {
|
|
t.Fatalf("unexpected binding: %+v", b)
|
|
}
|
|
if _, ok := types.ParseResourcePath(b.ref); !ok {
|
|
t.Fatalf("binding ref is not a resource reference: %q", b.ref)
|
|
}
|
|
}
|
|
|
|
func TestArtifactCollector_BindFailureDoesNotDropArtifact(t *testing.T) {
|
|
ctx := context.Background()
|
|
src := &fakeSandboxSource{
|
|
entries: map[string][]sandbox.RemoteDirEntry{
|
|
"sess-1": {
|
|
{Name: "a.txt", Path: "/workspace/output/a.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
|
},
|
|
},
|
|
contents: map[string][]byte{"/workspace/output/a.txt": []byte("a")},
|
|
}
|
|
fs := &resourceRefFileService{handle: "abcdefghijklmnopqrstuv"}
|
|
cat := &fakeCatalog{bindErr: stderrors.New("db down")}
|
|
c := NewArtifactCollector(src, fs, &fakeStore{}, cat, ArtifactCollectorConfig{MaxFileBytes: 1 << 20})
|
|
|
|
got, err := c.Collect(ctx, "sess-1", "msg-1", 7, "/workspace/output")
|
|
if err != nil {
|
|
t.Fatalf("Collect() error = %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("Collect() len = %d, want 1 (bind failure must not drop the artifact)", len(got))
|
|
}
|
|
}
|
|
|
|
// mustParseTime parses an RFC3339 timestamp for test data. It fails the
|
|
// program on error because test fixtures should never contain malformed
|
|
// timestamps.
|
|
func mustParseTime(raw string) time.Time {
|
|
t, err := time.Parse(time.RFC3339Nano, raw)
|
|
if err != nil {
|
|
t, err = time.Parse(time.RFC3339, raw)
|
|
}
|
|
if err != nil {
|
|
panic("mustParseTime: " + err.Error())
|
|
}
|
|
return t
|
|
}
|
|
|
|
// bytesN returns a byte slice of length n filled with 'x'. Kept local to
|
|
// avoid pulling in a dependency; matches "generate N bytes" test helpers
|
|
// elsewhere in the codebase.
|
|
func bytesN(n int) []byte {
|
|
b := make([]byte, n)
|
|
for i := range b {
|
|
b[i] = 'x'
|
|
}
|
|
return b
|
|
}
|