Files
aego/asset/pipeline/source_canonical_test.go
sanspie a46347e2c6
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m19s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Failing after 23m57s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: failure
GitHub / darwin amd64 native GitHub-hosted native CI: failure
GitHub / linux amd64 native GitHub-hosted native CI: success
editor: add art studio and stage 12.5 workflows
2026-11-08 18:47:57 +03:00

428 lines
14 KiB
Go

package pipeline
import (
"bytes"
"errors"
"os"
"strings"
"testing"
"aego/core/ids"
"aego/format/aetext"
)
type canonicalRollbackImporter struct {
fakeImporter
fail bool
beforeResolve func() error
afterResolve func() error
}
func (imp *canonicalRollbackImporter) ResolveMetadata(ctx *ImportContext) (Settings, error) {
if string(ctx.Source) != "candidate" {
return nil, nil
}
if imp.beforeResolve != nil {
if err := imp.beforeResolve(); err != nil {
return nil, err
}
imp.beforeResolve = nil
}
settings := ctx.Meta.Settings.Clone()
settings["mode"] = aetext.Ident("resolved")
return settings, nil
}
func (imp *canonicalRollbackImporter) Import(ctx *ImportContext) (*Result, error) {
if string(ctx.Source) == "candidate" {
if imp.afterResolve != nil {
if err := imp.afterResolve(); err != nil {
return nil, err
}
imp.afterResolve = nil
}
if imp.fail {
return nil, errors.New("injected failure after metadata resolution")
}
}
return imp.fakeImporter.Import(ctx)
}
func TestApplySourcesPreservesExternalMetadataAcrossResolution(t *testing.T) {
for _, phase := range []string{"before-resolver-write", "after-resolver-write"} {
t.Run(phase, func(t *testing.T) {
p := newProject(t)
file := write(t, p, "assets/a.txt", "old")
imp := &canonicalRollbackImporter{fakeImporter: fakeImporter{version: 1}}
pl := openPipeline(t, p, imp)
if _, _, err := pl.Scan(); err != nil {
t.Fatal(err)
}
batch, err := pl.ImportAll()
if err != nil || batch == nil || len(batch.Failed) != 0 || len(batch.Retry) != 0 {
t.Fatalf("initial import: %+v, %v", batch, err)
}
original, err := os.ReadFile(file + MetaSuffix)
if err != nil {
t.Fatal(err)
}
meta, err := ParseMeta(original, "assets/a.txt.meta")
if err != nil {
t.Fatal(err)
}
meta.Settings["mode"] = aetext.Ident("manual")
manual := WriteMeta(meta)
meta.Settings["mode"] = aetext.Ident("outside")
outside := WriteMeta(meta)
mutate := func() error { return os.WriteFile(file+MetaSuffix, outside, 0644) }
if phase == "before-resolver-write" {
imp.beforeResolve = mutate
} else {
imp.afterResolve = mutate
}
edits := []SourceEdit{
{Path: "assets/a.txt", Before: []byte("old"), After: []byte("candidate")},
{Path: "assets/a.txt.meta", Before: original, After: manual},
}
if _, err = pl.ApplySources(edits, false); err == nil || !strings.Contains(err.Error(), "changed") {
t.Fatalf("expected external-change conflict, got %v", err)
}
if got, err := os.ReadFile(file + MetaSuffix); err != nil || !bytes.Equal(got, outside) {
t.Fatalf("external sidecar was overwritten: %q, %v", got, err)
}
if got, err := os.ReadFile(file); err != nil || string(got) != "old" {
t.Fatalf("uncontested source was not restored: %q, %v", got, err)
}
if id, ok := pl.Lookup("assets/a.txt"); !ok || pl.VerifyCurrent(id) == nil {
t.Fatal("conflicted source was not marked stale")
}
})
}
}
func TestApplySourcesCanonicalMetadataRollbackAndHistory(t *testing.T) {
for _, fail := range []bool{false, true} {
name := "history"
if fail {
name = "rollback"
}
t.Run(name, func(t *testing.T) {
p := newProject(t)
file := write(t, p, "assets/a.txt", "old")
imp := &canonicalRollbackImporter{fakeImporter: fakeImporter{version: 1}, fail: fail}
pl := openPipeline(t, p, imp)
if _, _, err := pl.Scan(); err != nil {
t.Fatal(err)
}
batch, err := pl.ImportAll()
if err != nil || batch == nil || len(batch.Failed) != 0 || len(batch.Retry) != 0 {
t.Fatalf("initial import: %+v, %v", batch, err)
}
meta, err := os.ReadFile(file + MetaSuffix)
if err != nil {
t.Fatal(err)
}
candidate, err := ParseMeta(meta, "assets/a.txt.meta")
if err != nil {
t.Fatal(err)
}
candidate.Settings["mode"] = aetext.Ident("manual")
edits := []SourceEdit{
{Path: "assets/a.txt", Before: []byte("old"), After: []byte("candidate")},
{Path: "assets/a.txt.meta", Before: meta, After: WriteMeta(candidate)},
}
batch, err = pl.ApplySources(edits, false)
if fail {
if err == nil {
t.Fatal("failed import was accepted")
}
got, readErr := os.ReadFile(file + MetaSuffix)
if readErr != nil || !bytes.Equal(got, meta) {
t.Fatalf("engine-canonicalized sidecar was not rolled back: %q, %v; err=%v", got, readErr, err)
}
got, readErr = os.ReadFile(file)
if readErr != nil || string(got) != "old" {
t.Fatalf("source was not rolled back: %q, %v", got, readErr)
}
id, ok := pl.Lookup("assets/a.txt")
if !ok || pl.VerifyCurrent(id) != nil {
t.Fatal("original import was not restored")
}
return
}
if err != nil || batch == nil || len(batch.Failed) != 0 || len(batch.Retry) != 0 {
t.Fatalf("publication: %+v, %v", batch, err)
}
got, err := os.ReadFile(file + MetaSuffix)
if err != nil || !bytes.Equal(got, edits[1].After) {
t.Fatalf("history did not adopt published metadata: %q, %v", got, err)
}
resolved, err := ParseMeta(got, "assets/a.txt.meta")
if err != nil || resolved.Settings.String("mode", "") != "resolved" {
t.Fatalf("metadata was not resolved: %+v, %v", resolved, err)
}
for _, back := range []bool{true, false, true, false} {
batch, err := pl.ApplySources(edits, back)
if err != nil || batch == nil || len(batch.Failed) != 0 || len(batch.Retry) != 0 {
t.Fatalf("history back=%v: %+v, %v", back, batch, err)
}
got, err := os.ReadFile(file + MetaSuffix)
want := edits[1].After
if back {
want = edits[1].Before
}
if err != nil || !bytes.Equal(got, want) {
t.Fatalf("history back=%v: sidecar=%q, %v", back, got, err)
}
}
})
}
}
func TestApplySourcesRollsBackCanonicalizedMetadataGuard(t *testing.T) {
p := newProject(t)
file := write(t, p, "assets/a.txt", "old")
imp := &canonicalRollbackImporter{fakeImporter: fakeImporter{version: 1}, fail: true}
pl := openPipeline(t, p, imp)
if _, _, err := pl.Scan(); err != nil {
t.Fatal(err)
}
batch, err := pl.ImportAll()
if err != nil || batch == nil || len(batch.Failed) != 0 || len(batch.Retry) != 0 {
t.Fatalf("initial import: %+v, %v", batch, err)
}
meta, err := os.ReadFile(file + MetaSuffix)
if err != nil {
t.Fatal(err)
}
edits := []SourceEdit{
{Path: "assets/a.txt", Before: []byte("old"), After: []byte("candidate")},
{Path: "assets/a.txt.meta", Before: meta, After: meta},
}
if _, err = pl.ApplySources(edits, false); err == nil || strings.Contains(err.Error(), "changed") {
t.Fatalf("expected import failure without a false conflict: %v", err)
}
if got, err := os.ReadFile(file + MetaSuffix); err != nil || !bytes.Equal(got, meta) {
t.Fatalf("canonicalized guard was not rolled back: %q, %v", got, err)
}
if got, err := os.ReadFile(file); err != nil || string(got) != "old" {
t.Fatalf("source was not rolled back: %q, %v", got, err)
}
if id, ok := pl.Lookup("assets/a.txt"); !ok || pl.VerifyCurrent(id) != nil {
t.Fatal("the original import was not restored")
}
}
func TestApplySourcesReadOnlyGuardPreventsMetadataResolution(t *testing.T) {
p := newProject(t)
file := write(t, p, "assets/a.txt", "old")
imp := &canonicalRollbackImporter{fakeImporter: fakeImporter{version: 1}}
pl := openPipeline(t, p, imp)
if _, _, err := pl.Scan(); err != nil {
t.Fatal(err)
}
batch, err := pl.ImportAll()
if err != nil || batch == nil || len(batch.Failed) != 0 || len(batch.Retry) != 0 {
t.Fatalf("initial import: %+v, %v", batch, err)
}
meta, err := os.ReadFile(file + MetaSuffix)
if err != nil {
t.Fatal(err)
}
edits := []SourceEdit{
{Path: "assets/a.txt", Before: []byte("old"), After: []byte("candidate")},
{Path: "assets/a.txt.meta", Before: meta, After: meta, ReadOnlyGuard: true},
}
if _, err = pl.ApplySources(edits, false); err == nil {
t.Fatal("metadata resolver modified a read-only guard")
}
if got, err := os.ReadFile(file + MetaSuffix); err != nil || !bytes.Equal(got, meta) {
t.Fatalf("guarded metadata was changed: %q, %v", got, err)
}
if got, err := os.ReadFile(file); err != nil || string(got) != "old" {
t.Fatalf("failed transaction did not restore source: %q, %v", got, err)
}
if id, ok := pl.Lookup("assets/a.txt"); !ok || pl.VerifyCurrent(id) != nil {
t.Fatal("original import was not restored")
}
if _, err := pl.ApplySources([]SourceEdit{{Path: "assets/a.txt", Before: []byte("old"), After: []byte("candidate"), ReadOnlyGuard: true}}, false); err == nil {
t.Fatal("a read-only guard accepted a requested write")
}
}
func TestApplySourcesProtectsImplicitSourceMetadata(t *testing.T) {
for _, scenario := range []string{"import-failure", "external-change"} {
t.Run(scenario, func(t *testing.T) {
p := newProject(t)
file := write(t, p, "assets/a.txt", "old")
imp := &canonicalRollbackImporter{fakeImporter: fakeImporter{version: 1}}
pl := openPipeline(t, p, imp)
if _, _, err := pl.Scan(); err != nil {
t.Fatal(err)
}
batch, err := pl.ImportAll()
if err != nil || batch == nil || len(batch.Failed) != 0 || len(batch.Retry) != 0 {
t.Fatalf("initial import: %+v, %v", batch, err)
}
original, err := os.ReadFile(file + MetaSuffix)
if err != nil {
t.Fatal(err)
}
want := original
if scenario == "import-failure" {
imp.fail = true
} else {
meta, err := ParseMeta(original, "assets/a.txt.meta")
if err != nil {
t.Fatal(err)
}
meta.Settings["mode"] = aetext.Ident("outside")
want = WriteMeta(meta)
imp.afterResolve = func() error { return os.WriteFile(file+MetaSuffix, want, 0644) }
}
// The caller supplies only the source, not an explicit .meta edit.
batch, err = pl.ApplySources([]SourceEdit{{Path: "assets/a.txt", Before: []byte("old"), After: []byte("candidate")}}, false)
if err == nil {
t.Fatalf("%s was accepted: %+v", scenario, batch)
}
if got, err := os.ReadFile(file); err != nil || string(got) != "old" {
t.Fatalf("source was not rolled back: %q, %v", got, err)
}
if got, err := os.ReadFile(file + MetaSuffix); err != nil || !bytes.Equal(got, want) {
t.Fatalf("sidecar was not preserved: %q, %v", got, err)
}
if scenario == "external-change" {
id, ok := pl.Lookup("assets/a.txt")
if !ok || pl.VerifyCurrent(id) == nil {
t.Fatal("externally changed metadata was not marked as a conflict")
}
}
})
}
}
// The dependent is reimported when a.txt changes. Its reference resolver can
// update the dependent sidecar even though the caller did not edit b.txt.
type dependentMetadataImporter struct {
fakeImporter
failSource, noMetadataChange, resolved bool
}
func (imp *dependentMetadataImporter) ResolveMetadata(ctx *ImportContext) (Settings, error) {
if ctx.Path != "assets/b.txt" {
return nil, nil
}
source, err := ctx.Read("assets/a.txt")
if err != nil || string(source) != "candidate" {
return nil, err
}
imp.resolved = true
settings := ctx.Meta.Settings.Clone()
if !imp.noMetadataChange {
settings["mode"] = aetext.Ident("resolved")
}
return settings, nil
}
func (imp *dependentMetadataImporter) Import(ctx *ImportContext) (*Result, error) {
if imp.failSource && ctx.Path == "assets/a.txt" && string(ctx.Source) == "candidate" {
return nil, errors.New("injected source import failure")
}
result, err := imp.fakeImporter.Import(ctx)
if err == nil && ctx.Path == "assets/b.txt" {
id, ok := ctx.Lookup("assets/a.txt")
if !ok {
return nil, errors.New("missing dependency")
}
result.Deps = []ids.AssetID{id}
}
return result, err
}
func TestApplySourcesGuardsDependentMetadataResolution(t *testing.T) {
for _, tc := range []struct {
name string
guard bool
readOnly bool
failSource bool
noMetadataChange bool
}{
{name: "unguarded-change"},
{name: "unguarded-change-with-import-failure", failSource: true},
{name: "unguarded-unchanged", noMetadataChange: true},
{name: "guarded-change", guard: true},
{name: "guarded-read-only", guard: true, readOnly: true},
{name: "guarded-change-with-import-failure", guard: true, failSource: true},
} {
t.Run(tc.name, func(t *testing.T) {
p := newProject(t)
a := write(t, p, "assets/a.txt", "old")
b := write(t, p, "assets/b.txt", "dependent")
imp := &dependentMetadataImporter{fakeImporter: fakeImporter{version: 1}, failSource: tc.failSource, noMetadataChange: tc.noMetadataChange}
pl := openPipeline(t, p, imp)
if _, _, err := pl.Scan(); err != nil {
t.Fatal(err)
}
batch, err := pl.ImportAll()
if err != nil || batch == nil || len(batch.Failed) != 0 || len(batch.Retry) != 0 {
t.Fatalf("initial import: %+v, %v", batch, err)
}
before, err := os.ReadFile(b + MetaSuffix)
if err != nil {
t.Fatal(err)
}
edits := []SourceEdit{{Path: "assets/a.txt", Before: []byte("old"), After: []byte("candidate")}}
if tc.guard {
edits = append(edits, SourceEdit{Path: "assets/b.txt.meta", Before: before, After: before, ReadOnlyGuard: tc.readOnly})
}
batch, err = pl.ApplySources(edits, false)
if !imp.resolved {
t.Fatal("dependent resolver did not run")
}
success := (tc.guard && !tc.readOnly || tc.noMetadataChange) && !tc.failSource
if success {
if err != nil || batch == nil || len(batch.Failed) != 0 || len(batch.Retry) != 0 {
t.Fatalf("guarded dependent import: %+v, %v", batch, err)
}
} else if err == nil {
t.Error("transaction accepted an unguarded or failed dependent import")
} else if !tc.guard && !strings.Contains(err.Error(), "dependent metadata") {
t.Errorf("missing explicit dependent metadata diagnostic: %v", err)
}
got, err := os.ReadFile(b + MetaSuffix)
if err != nil {
t.Fatal(err)
}
if success && !tc.noMetadataChange {
if bytes.Equal(got, before) || !bytes.Equal(got, edits[1].After) {
t.Fatal("guarded sidecar was not updated in history")
}
} else if !bytes.Equal(got, before) {
t.Fatal("dependent metadata changed despite failed transaction")
}
wantSource := "old"
if success {
wantSource = "candidate"
}
if got, err := os.ReadFile(a); err != nil || string(got) != wantSource {
t.Fatalf("source after transaction = %q, %v; want %q", got, err, wantSource)
}
if success && tc.guard {
for _, back := range []bool{true, false} {
batch, err := pl.ApplySources(edits, back)
if err != nil || batch == nil || len(batch.Failed) != 0 || len(batch.Retry) != 0 {
t.Fatalf("history back=%v: %+v, %v", back, batch, err)
}
want := edits[1].After
if back {
want = before
}
if got, err := os.ReadFile(b + MetaSuffix); err != nil || !bytes.Equal(got, want) {
t.Fatalf("dependent metadata after history back=%v: %v", back, err)
}
}
}
})
}
}