929 lines
32 KiB
Go
929 lines
32 KiB
Go
package definition_test
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/definition"
|
|
)
|
|
|
|
// Phase 4D — JS/Go parser conformance.
|
|
//
|
|
// The fixture these tests read (testdata/oracle.json) is not written by hand.
|
|
// It is captured by scripts/oracle.mjs, which loads the REAL frontend module
|
|
// graph through Vite — import.meta.glob, the `@/` alias and raw Markdown
|
|
// loading all behave exactly as they do in the app — and records what the
|
|
// JavaScript parser did with every shipped definition and every adversarial
|
|
// case. So the assertion below is not "Go agrees with a description of the
|
|
// frontend"; it is "Go agrees with the frontend", replayed.
|
|
//
|
|
// Regenerate after any change to src/lib/skills or src/lib/agents:
|
|
//
|
|
// node scripts/oracle.mjs go-api/internal/definition/testdata/oracle.json
|
|
//
|
|
// A frontend change that alters parsing therefore fails these tests, which is
|
|
// the point: the contract cannot drift silently in either direction.
|
|
|
|
type oracle struct {
|
|
Vocabulary struct {
|
|
Pages []struct {
|
|
ID string `json:"id"`
|
|
Aliases []string `json:"aliases"`
|
|
} `json:"pages"`
|
|
AgentStatuses []string `json:"agentStatuses"`
|
|
Reasoning []string `json:"reasoning"`
|
|
Icons []string `json:"icons"`
|
|
KnowledgeKinds []string `json:"knowledgeKinds"`
|
|
Access []string `json:"access"`
|
|
Roles []string `json:"roles"`
|
|
} `json:"vocabulary"`
|
|
Corpus []observation `json:"corpus"`
|
|
Cases []observation `json:"cases"`
|
|
}
|
|
|
|
// observation is one definition as the JavaScript saw it, end to end.
|
|
type observation struct {
|
|
// Exactly one of these identifies the row.
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
|
|
Kind string `json:"kind"` // agent | skill
|
|
RawBase64 string `json:"rawBase64"`
|
|
|
|
HasFrontmatter bool `json:"hasFrontmatter"`
|
|
|
|
Frontmatter struct {
|
|
OK bool `json:"ok"`
|
|
Data map[string]any `json:"data"`
|
|
Body string `json:"body"`
|
|
Error string `json:"error"`
|
|
} `json:"frontmatter"`
|
|
|
|
Parse struct {
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error"`
|
|
} `json:"parse"`
|
|
|
|
Normalized map[string]any `json:"normalized"`
|
|
|
|
Accepted bool `json:"accepted"`
|
|
Rejection *string `json:"rejection"`
|
|
}
|
|
|
|
func (o observation) id() string {
|
|
if o.Path != "" {
|
|
return o.Path
|
|
}
|
|
return o.Name
|
|
}
|
|
|
|
func (o observation) raw(t *testing.T) string {
|
|
t.Helper()
|
|
b, err := base64.StdEncoding.DecodeString(o.RawBase64)
|
|
if err != nil {
|
|
t.Fatalf("%s: undecodable fixture: %v", o.id(), err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func load(t *testing.T) *oracle {
|
|
t.Helper()
|
|
b, err := os.ReadFile("testdata/oracle.json")
|
|
if err != nil {
|
|
t.Fatalf("read fixture: %v", err)
|
|
}
|
|
var o oracle
|
|
if err := json.Unmarshal(b, &o); err != nil {
|
|
t.Fatalf("parse fixture: %v", err)
|
|
}
|
|
if len(o.Corpus) == 0 || len(o.Cases) == 0 {
|
|
t.Fatal("fixture is empty; regenerate with scripts/oracle.mjs")
|
|
}
|
|
return &o
|
|
}
|
|
|
|
func all(o *oracle) []observation { return append(append([]observation{}, o.Corpus...), o.Cases...) }
|
|
|
|
/* ── 1. The corpus is the corpus ──────────────────────────────────────────── */
|
|
|
|
// The shipped definition count, asserted rather than assumed. A definition
|
|
// added to or removed from the product without regenerating the fixture leaves
|
|
// these tests passing against a corpus that no longer exists, which is the one
|
|
// way this suite could quietly stop meaning anything.
|
|
func TestCorpusShape(t *testing.T) {
|
|
o := load(t)
|
|
|
|
counts := map[string]int{}
|
|
for _, c := range o.Corpus {
|
|
counts[c.Type]++
|
|
}
|
|
|
|
for _, want := range []struct {
|
|
kind string
|
|
n int
|
|
}{{"agent", 9}, {"skill", 23}, {"example", 5}} {
|
|
if counts[want.kind] != want.n {
|
|
t.Errorf("%s definitions: got %d, want %d", want.kind, counts[want.kind], want.n)
|
|
}
|
|
}
|
|
if len(o.Corpus) != 37 {
|
|
t.Errorf("shipped definitions: got %d, want 37", len(o.Corpus))
|
|
}
|
|
}
|
|
|
|
/* ── 2. The vocabulary has not drifted ────────────────────────────────────── */
|
|
|
|
// Every closed table in vocabulary.go, checked against the table the frontend
|
|
// actually exports. A page added to surfaces.js fails here rather than becoming
|
|
// a definition the editor accepts and the API rejects.
|
|
func TestVocabularyMatchesFrontend(t *testing.T) {
|
|
o := load(t)
|
|
|
|
wantPages := make([]string, len(o.Vocabulary.Pages))
|
|
for i, p := range o.Vocabulary.Pages {
|
|
wantPages[i] = p.ID
|
|
}
|
|
if !reflect.DeepEqual(definition.SupportedPages, wantPages) {
|
|
t.Errorf("supported pages differ\n go %v\n js %v", definition.SupportedPages, wantPages)
|
|
}
|
|
|
|
// Aliases resolve, and resolve to the same canonical id.
|
|
for _, p := range o.Vocabulary.Pages {
|
|
for _, alias := range append([]string{p.ID}, p.Aliases...) {
|
|
if got := definition.CanonicalPage(alias); got != p.ID {
|
|
t.Errorf("CanonicalPage(%q) = %q, want %q", alias, got, p.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, table := range []struct {
|
|
name string
|
|
got []string
|
|
wanted []string
|
|
}{
|
|
{"agent statuses", definition.AgentStatuses, o.Vocabulary.AgentStatuses},
|
|
{"reasoning modes", definition.ReasoningModes, o.Vocabulary.Reasoning},
|
|
{"icons", definition.AgentIcons, o.Vocabulary.Icons},
|
|
{"knowledge kinds", definition.KnowledgeKinds, o.Vocabulary.KnowledgeKinds},
|
|
{"access modes", definition.AgentAccess, o.Vocabulary.Access},
|
|
{"permission roles", definition.PermissionRole, o.Vocabulary.Roles},
|
|
} {
|
|
if !reflect.DeepEqual(table.got, table.wanted) {
|
|
t.Errorf("%s differ\n go %v\n js %v", table.name, table.got, table.wanted)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── 3. The frontmatter tree ──────────────────────────────────────────────── */
|
|
|
|
// The deepest parity check available: the YAML subset must produce the same
|
|
// data structure the JavaScript produced, for every definition and every
|
|
// adversarial case. Not a projection of it — the whole tree.
|
|
func TestFrontmatterTreeParity(t *testing.T) {
|
|
for _, c := range all(load(t)) {
|
|
t.Run(c.id(), func(t *testing.T) {
|
|
raw := c.raw(t)
|
|
doc, err := definition.ParseFrontmatter(raw)
|
|
|
|
if !c.Frontmatter.OK {
|
|
if err == nil {
|
|
t.Fatalf("JS refused this frontmatter (%s); Go accepted it", c.Frontmatter.Error)
|
|
}
|
|
if err.Error() != c.Frontmatter.Error {
|
|
t.Errorf("error text differs\n go %q\n js %q", err.Error(), c.Frontmatter.Error)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("JS read this frontmatter; Go refused it: %v", err)
|
|
}
|
|
|
|
if got, want := normalizeTree(doc.Data), normalizeTree(c.Frontmatter.Data); !reflect.DeepEqual(got, want) {
|
|
t.Errorf("frontmatter differs\n go %s\n js %s", show(got), show(want))
|
|
}
|
|
if doc.Body != c.Frontmatter.Body {
|
|
t.Errorf("body differs\n go %q\n js %q", doc.Body, c.Frontmatter.Body)
|
|
}
|
|
if got := definition.HasFrontmatter(raw); got != c.HasFrontmatter {
|
|
t.Errorf("HasFrontmatter = %v, JS said %v", got, c.HasFrontmatter)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// normalizeTree puts a parsed tree into the shape `encoding/json` would have
|
|
// produced, so the Go value and the value round-tripped through the fixture's
|
|
// JSON are comparable. Numbers become float64 on both sides, which is what
|
|
// JavaScript had in the first place.
|
|
func normalizeTree(v any) any {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Sprintf("unmarshalable: %v", err)
|
|
}
|
|
var out any
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return fmt.Sprintf("unmarshalable: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func show(v any) string {
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
}
|
|
|
|
/* ── 4. Accept / reject parity ────────────────────────────────────────────── */
|
|
|
|
// knownDivergence is the complete list of definitions where the two parsers
|
|
// disagree, each with the reason. It is a CLOSED list: anything not on it that
|
|
// disagrees fails, and anything on it that stops disagreeing fails too, so the
|
|
// list cannot quietly grow and cannot quietly go stale.
|
|
//
|
|
// Every entry is a case where Go is stricter, except the first — and the first
|
|
// is the one asymmetry this package documents as deferred.
|
|
var knownDivergence = map[string]string{
|
|
"skill-examples/board-invalid-context.md": "" +
|
|
"JS rejects on `ui:` placement/source semantics, which this package defers " +
|
|
"to the frontend. Go accepts and reports Deferred: [ui].",
|
|
|
|
"oversized-markdown": "" +
|
|
"JS accepts; the database refuses it (markdown_size CHECK). Go refuses it " +
|
|
"first, so an author gets a message instead of a constraint violation.",
|
|
"oversized-agent": "" +
|
|
"JS accepts; the database refuses it (markdown_size CHECK). Go refuses it " +
|
|
"first, so an author gets a message instead of a constraint violation.",
|
|
|
|
"version-above-int32-agent": "" +
|
|
"JS accepts any whole number of 1 or more; agent_definitions.version is a " +
|
|
"PostgreSQL `integer`, so the database refuses this one. Go refuses it " +
|
|
"first, for the same reason as the size bound.",
|
|
}
|
|
|
|
func TestAcceptanceParity(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
|
|
for _, c := range all(load(t)) {
|
|
t.Run(c.id(), func(t *testing.T) {
|
|
raw := c.raw(t)
|
|
|
|
var err error
|
|
if c.Kind == "agent" {
|
|
err = definition.ValidateAgent(raw)
|
|
} else {
|
|
err = definition.ValidateSkill(raw)
|
|
}
|
|
accepted := err == nil
|
|
|
|
if reason, expected := knownDivergence[c.id()]; expected {
|
|
seen[c.id()] = true
|
|
if accepted == c.Accepted {
|
|
t.Errorf("listed as a known divergence but the two now agree (%v).\n"+
|
|
"Remove it from knownDivergence.\n reason on file: %s", accepted, reason)
|
|
}
|
|
return
|
|
}
|
|
|
|
if accepted != c.Accepted {
|
|
t.Fatalf("acceptance differs: go=%v js=%v\n go said: %v\n js said: %v",
|
|
accepted, c.Accepted, err, deref(c.Rejection))
|
|
}
|
|
})
|
|
}
|
|
|
|
for id := range knownDivergence {
|
|
if !seen[id] {
|
|
t.Errorf("knownDivergence names %q, which is not in the fixture", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Where both refuse a definition, they must refuse it for the same stated
|
|
// reason. A parser that rejects the right definitions with the wrong messages
|
|
// sends an author to the wrong line.
|
|
func TestRejectionMessageParity(t *testing.T) {
|
|
for _, c := range all(load(t)) {
|
|
if c.Accepted || c.Rejection == nil {
|
|
continue
|
|
}
|
|
if _, skip := knownDivergence[c.id()]; skip {
|
|
continue
|
|
}
|
|
t.Run(c.id(), func(t *testing.T) {
|
|
raw := c.raw(t)
|
|
var err error
|
|
if c.Kind == "agent" {
|
|
err = definition.ValidateAgent(raw)
|
|
} else {
|
|
err = definition.ValidateSkill(raw)
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("JS rejected this; Go accepted it")
|
|
}
|
|
if r, ok := err.(*definition.Rejection); ok && r.BackendOnly {
|
|
t.Fatalf("refused by a backend-only rule where JS refused it too: %q", r.Message)
|
|
}
|
|
if err.Error() != *c.Rejection {
|
|
t.Errorf("rejection differs\n go %q\n js %q", err.Error(), *c.Rejection)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func deref(s *string) string {
|
|
if s == nil {
|
|
return "<accepted>"
|
|
}
|
|
return *s
|
|
}
|
|
|
|
/* ── 5. Normalized projection parity ──────────────────────────────────────── */
|
|
|
|
// textCoercion is the complete list of fixture cases where the JavaScript
|
|
// record holds a value that is NOT a string in a field migration 000005
|
|
// projects into a `text` or `text[]` column, named field by field.
|
|
//
|
|
// The frontend can afford this and the backend cannot. `name: [a, b]` leaves a
|
|
// JavaScript ARRAY on skill.name, and every consumer stringifies it at the
|
|
// point of use — the trigger list on that very record reads "a,b", which is
|
|
// String(["a","b"]). A text column has no such option: something must be
|
|
// written, once, at the boundary. This package writes String(x), which is the
|
|
// string the frontend's own consumers produce.
|
|
//
|
|
// Listing them rather than coercing everywhere is the point. Coercion is
|
|
// applied ONLY to the fields named here, so a genuine difference between two
|
|
// strings still fails; each entry is checked to be still necessary, so the list
|
|
// cannot go stale; and an unlisted case that needs coercion fails outright, so
|
|
// the list cannot quietly grow. It is the same closed-list discipline
|
|
// knownDivergence has, for the same reason.
|
|
var textCoercion = map[string][]string{
|
|
"invalid-field-type-name-list": {"name"},
|
|
"name-list-agent": {"name"},
|
|
"name-numeric": {"name"},
|
|
"name-boolean": {"name"},
|
|
"description-list": {"description"},
|
|
"description-numeric": {"description"},
|
|
"pages-numeric-entry": {"pages"},
|
|
"pages-mapping-entry": {"pages"},
|
|
}
|
|
|
|
// The two shapes a text projection can have, named rather than inferred.
|
|
//
|
|
// Which one applies is a property of the COLUMN, not of what the author
|
|
// happened to write. `name` is `text`, so a sequence written there becomes one
|
|
// string — String(["a","b"]) is "a,b". `pages` is `text[]`, so a sequence stays
|
|
// a sequence and each entry becomes a string of its own. Inferring the shape
|
|
// from whatever Go produced would make the test agree with the parser by
|
|
// construction, which is the one thing it must not do.
|
|
var textScalarFields = map[string]bool{
|
|
"name": true, "description": true, "category": true, "trigger": true, "prompt": true,
|
|
}
|
|
|
|
var textListFields = map[string]bool{
|
|
"pages": true, "actions": true, "triggers": true, "skills": true, "subagents": true,
|
|
}
|
|
|
|
// jsText is String(x) for a value decoded from the fixture's JSON — the same
|
|
// conversion jsvalue.go performs inside the parser, restated here so the test
|
|
// does not have to reach into the package it is testing to check it.
|
|
func jsText(t *testing.T, field string, v any) any {
|
|
t.Helper()
|
|
|
|
switch {
|
|
case textScalarFields[field]:
|
|
return jsScalarText(v)
|
|
case textListFields[field]:
|
|
list, ok := v.([]any)
|
|
if !ok {
|
|
return jsScalarText(v)
|
|
}
|
|
out := make([]any, len(list))
|
|
for i, item := range list {
|
|
out[i] = jsScalarText(item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
t.Fatalf("textCoercion names %q, which is not a text-projected field", field)
|
|
return nil
|
|
}
|
|
|
|
func jsScalarText(v any) string {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return "null"
|
|
case bool:
|
|
if x {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
case float64:
|
|
if x == float64(int64(x)) {
|
|
return strconv.FormatInt(int64(x), 10)
|
|
}
|
|
return strconv.FormatFloat(x, 'g', -1, 64)
|
|
case string:
|
|
return x
|
|
case []any:
|
|
// Array.prototype.toString: nil renders as the empty string, not
|
|
// "null", which is the one place the two differ.
|
|
parts := make([]string, len(x))
|
|
for i, item := range x {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
parts[i] = jsScalarText(item)
|
|
}
|
|
return strings.Join(parts, ",")
|
|
case map[string]any:
|
|
return "[object Object]"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// The fields migration 000005 projects into columns, compared for every
|
|
// definition both parsers accept. These are the values that reach the
|
|
// database, so a difference here is a row the frontend would render wrongly.
|
|
func TestProjectionParity(t *testing.T) {
|
|
fixture := map[string]bool{}
|
|
|
|
for _, c := range all(load(t)) {
|
|
fixture[c.id()] = true
|
|
if c.Normalized == nil {
|
|
continue // JS could not parse it; covered by the tree test
|
|
}
|
|
coerce := map[string]bool{}
|
|
for _, field := range textCoercion[c.id()] {
|
|
coerce[field] = true
|
|
}
|
|
|
|
t.Run(c.id(), func(t *testing.T) {
|
|
raw := c.raw(t)
|
|
|
|
if c.Kind == "agent" {
|
|
agent, err := definition.ParseAgent(raw, definition.Options{})
|
|
if err != nil {
|
|
t.Fatalf("JS parsed this; Go refused it: %v", err)
|
|
}
|
|
compare(t, map[string]any{
|
|
"id": agent.ID,
|
|
"name": agent.Name,
|
|
"description": agent.Description,
|
|
"status": agent.Status,
|
|
"version": agent.Version,
|
|
"pages": agent.Pages,
|
|
"icon": agent.Icon,
|
|
"reasoning": agent.Reasoning,
|
|
"trigger": agent.Trigger,
|
|
"webSearch": agent.WebSearch,
|
|
"skills": agent.Skills,
|
|
"subagents": agent.Subagents,
|
|
"starters": agent.Starters,
|
|
"permissions": agent.Permissions,
|
|
"errors": agent.Errors,
|
|
}, c.Normalized, coerce)
|
|
return
|
|
}
|
|
|
|
skill, err := definition.ParseSkill(raw, definition.Options{})
|
|
if err != nil {
|
|
t.Fatalf("JS parsed this; Go refused it: %v", err)
|
|
}
|
|
compare(t, map[string]any{
|
|
"id": skill.ID,
|
|
"name": skill.Name,
|
|
"description": skill.Description,
|
|
"status": skill.Status,
|
|
"pages": skill.Pages,
|
|
"kind": skill.Kind,
|
|
"category": skill.Category,
|
|
"actions": skill.Actions,
|
|
"triggers": skill.Triggers,
|
|
"declaredTriggers": skill.DeclaredTriggers,
|
|
"prompt": skill.Prompt,
|
|
"skillId": skill.SkillID,
|
|
}, c.Normalized, coerce)
|
|
})
|
|
}
|
|
|
|
for id := range textCoercion {
|
|
if !fixture[id] {
|
|
t.Errorf("textCoercion names %q, which is not in the fixture", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// compare checks every field Go produced against the JS record, field by field
|
|
// so a failure names the field rather than dumping two objects.
|
|
func compare(t *testing.T, got map[string]any, want map[string]any, coerce map[string]bool) {
|
|
t.Helper()
|
|
|
|
keys := make([]string, 0, len(got))
|
|
for k := range got {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, k := range keys {
|
|
wantValue, present := want[k]
|
|
if !present {
|
|
t.Errorf("%s: absent from the JS record", k)
|
|
continue
|
|
}
|
|
if coerce[k] {
|
|
// Listed in textCoercion. Check the entry is still earning its
|
|
// place before honouring it: if the JS value is already the string
|
|
// Go produced, the coercion is doing nothing and the list has gone
|
|
// stale.
|
|
coerced := jsText(t, k, normalizeTree(wantValue))
|
|
if reflect.DeepEqual(normalizeTree(wantValue), normalizeTree(coerced)) {
|
|
t.Errorf("%s: listed in textCoercion, but the JS value is already "+
|
|
"a string. Remove the entry.", k)
|
|
}
|
|
wantValue = coerced
|
|
}
|
|
|
|
g, w := normalizeTree(got[k]), normalizeTree(wantValue)
|
|
// An empty list and a missing one are the same thing to both parsers.
|
|
if isEmptyList(g) && isEmptyList(w) {
|
|
continue
|
|
}
|
|
if !reflect.DeepEqual(g, w) {
|
|
t.Errorf("%s differs\n go %s\n js %s", k, show(g), show(w))
|
|
}
|
|
}
|
|
}
|
|
|
|
func isEmptyList(v any) bool {
|
|
if v == nil {
|
|
return true
|
|
}
|
|
l, ok := v.([]any)
|
|
return ok && len(l) == 0
|
|
}
|
|
|
|
/* ── 6. The parser never rewrites what is stored ──────────────────────────── */
|
|
|
|
// Migration 000005 keeps `markdown` verbatim and derives every other column
|
|
// from it. Parsing must therefore be a read: normalization exists to
|
|
// INTERPRET a definition, never to rewrite it.
|
|
func TestParsingDoesNotMutateSource(t *testing.T) {
|
|
for _, c := range all(load(t)) {
|
|
raw := c.raw(t)
|
|
before := string(append([]byte{}, raw...))
|
|
|
|
_, _ = definition.ParseSkill(raw, definition.Options{})
|
|
_, _ = definition.ParseAgent(raw, definition.Options{})
|
|
_ = definition.ValidateSkill(raw)
|
|
_ = definition.ValidateAgent(raw)
|
|
|
|
if raw != before {
|
|
t.Fatalf("%s: the source changed under the parser", c.id())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Normalize is what the parser reads THROUGH; what it returns must never be
|
|
// what gets stored. Asserted directly, because the whole separation rests on
|
|
// it: the corpus contains definitions whose normalized form differs from their
|
|
// stored form, and storing the normalized one would silently rewrite an
|
|
// author's file.
|
|
func TestNormalizationIsNotStorage(t *testing.T) {
|
|
rewritten := 0
|
|
for _, c := range all(load(t)) {
|
|
raw := c.raw(t)
|
|
if definition.Normalize(raw) != raw {
|
|
rewritten++
|
|
}
|
|
}
|
|
if rewritten == 0 {
|
|
t.Fatal("no case in the corpus is changed by Normalize; " +
|
|
"this test can no longer tell storage and interpretation apart")
|
|
}
|
|
t.Logf("%d of %d definitions normalize to something other than their stored bytes", rewritten, len(all(load(t))))
|
|
}
|
|
|
|
/* ── 7. Adversarial coverage is real ──────────────────────────────────────── */
|
|
|
|
// The adversarial cases Phase 4D requires, each mapped to the fixture rows that
|
|
// exercise it. A case list that drifts away from the requirement is a suite
|
|
// that looks thorough and tests something else.
|
|
func TestAdversarialCoverage(t *testing.T) {
|
|
required := map[string][]string{
|
|
"UTF-8 BOM": {"utf8-bom", "utf8-bom-agent", "bom-crlf-blankline"},
|
|
"CRLF": {"crlf", "crlf-agent", "crlf-inside-frontmatter-only"},
|
|
"CR": {"cr-only"},
|
|
"leading blank line": {"leading-blank-line"},
|
|
"multiple leading blank lines": {"multiple-leading-blank-lines", "leading-spaces-then-blank-lines"},
|
|
"trailing spaces": {"trailing-spaces-on-values"},
|
|
"trailing newline": {"many-trailing-newlines", "no-trailing-newline"},
|
|
"trailing ws after fence": {"trailing-ws-after-open-fence", "trailing-tab-after-close-fence"},
|
|
"quoted scalar": {"double-quoted-scalar", "doubled-quote-escape"},
|
|
"single-quoted scalar": {"single-quoted-scalar"},
|
|
"colon inside quoted string": {"colon-in-quoted-string", "colon-in-unquoted-string"},
|
|
"hash inside quoted string": {"hash-in-quoted-string", "hash-unquoted-trailing-comment", "hash-unquoted-midword"},
|
|
"empty scalar": {"empty-scalar", "tilde-scalar", "null-scalar"},
|
|
"empty array": {"empty-array"},
|
|
"inline array": {"inline-flow-array", "inline-flow-map"},
|
|
"multiline scalar": {"block-scalar-literal", "block-scalar-folded"},
|
|
"duplicate key": {"duplicate-key", "duplicate-key-array"},
|
|
"malformed YAML": {"malformed-yaml-bare-line", "key-with-space", "ragged-indent"},
|
|
"malformed opening fence": {"malformed-open-fence-two-dashes", "malformed-open-fence-four-dashes",
|
|
"malformed-open-fence-indented", "malformed-open-fence-text-after"},
|
|
"malformed closing fence": {"malformed-close-fence-two-dashes", "malformed-close-fence-missing",
|
|
"malformed-close-fence-four-dashes"},
|
|
"missing frontmatter": {"missing-frontmatter", "empty-fence-pair", "frontmatter-is-a-sequence"},
|
|
"unsupported frontmatter field": {"unsupported-frontmatter-field", "unsupported-field-agent", "uppercase-key"},
|
|
"invalid field type": {"invalid-field-type-pages-scalar", "invalid-field-type-pages-scalar-agent",
|
|
"invalid-field-type-name-list"},
|
|
"invalid definition id": {"invalid-definition-id-uppercase", "invalid-definition-id-leading-dash",
|
|
"invalid-definition-id-underscore"},
|
|
"invalid status": {"invalid-status-skill", "invalid-status-agent", "inactive-status-skill"},
|
|
"invalid visibility": {"visibility-field-personal", "visibility-field-invalid"},
|
|
"oversized markdown": {"oversized-markdown", "oversized-agent", "at-size-bound"},
|
|
"empty markdown": {"empty-markdown", "whitespace-only-markdown"},
|
|
}
|
|
|
|
present := map[string]bool{}
|
|
for _, c := range load(t).Cases {
|
|
present[c.Name] = true
|
|
}
|
|
|
|
for requirement, names := range required {
|
|
for _, n := range names {
|
|
if !present[n] {
|
|
t.Errorf("%q: the fixture has no case named %q", requirement, n)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── 8. Deferred blocks are reported, not assumed ─────────────────────────── */
|
|
|
|
// Every skill carrying a `ui:` or `owliver:` block must say so, because that is
|
|
// the one part of validation this package does not do. A block that stopped
|
|
// being reported would be a gap nobody could see.
|
|
func TestDeferredBlocksAreReported(t *testing.T) {
|
|
o := load(t)
|
|
found := 0
|
|
|
|
for _, c := range append(append([]observation{}, o.Corpus...), o.Cases...) {
|
|
if c.Kind != "skill" || !c.Frontmatter.OK {
|
|
continue
|
|
}
|
|
want := []string{}
|
|
for _, key := range []string{"ui", "owliver"} {
|
|
if _, present := c.Frontmatter.Data[key]; present {
|
|
want = append(want, key)
|
|
}
|
|
}
|
|
|
|
skill, err := definition.ParseSkill(c.raw(t), definition.Options{})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if len(want) == 0 {
|
|
if len(skill.Deferred) != 0 {
|
|
t.Errorf("%s: reported Deferred %v with no such block", c.id(), skill.Deferred)
|
|
}
|
|
continue
|
|
}
|
|
found++
|
|
if !reflect.DeepEqual(skill.Deferred, want) {
|
|
t.Errorf("%s: Deferred = %v, want %v", c.id(), skill.Deferred, want)
|
|
}
|
|
}
|
|
|
|
if found != 19 {
|
|
t.Errorf("definitions carrying a deferred block: got %d, want 19", found)
|
|
}
|
|
}
|
|
|
|
/* ── 9. Mutation checks ───────────────────────────────────────────────────── */
|
|
|
|
// Tests that pass against a broken parser are not tests. Each mutation below
|
|
// is a plausible mistake in this package; every one must be caught by a real
|
|
// definition changing its meaning, not by an assertion written to notice it.
|
|
func TestMutationsWouldBeCaught(t *testing.T) {
|
|
base := strings.Join([]string{
|
|
"---",
|
|
"id: sample-skill",
|
|
"name: Sample Skill",
|
|
"description: A sample.",
|
|
"pages:",
|
|
" - candidates",
|
|
"---",
|
|
"",
|
|
"# Sample Skill",
|
|
}, "\n")
|
|
|
|
mutations := []struct {
|
|
name string
|
|
raw string
|
|
check func(t *testing.T, s *definition.Skill, err error)
|
|
}{
|
|
{
|
|
// Dropping the BOM strip: the fence stops matching and every field
|
|
// empties out.
|
|
name: "BOM before the fence still fences",
|
|
raw: "\uFEFF" + base,
|
|
check: func(t *testing.T, s *definition.Skill, err error) {
|
|
if err != nil || s.ID != "sample-skill" || len(s.Pages) != 1 {
|
|
t.Errorf("got id=%q pages=%v err=%v", s.ID, s.Pages, err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Dropping CR normalization: `candidates\r` is not a page.
|
|
name: "CRLF endings do not leak into values",
|
|
raw: strings.ReplaceAll(base, "\n", "\r\n"),
|
|
check: func(t *testing.T, s *definition.Skill, err error) {
|
|
if err != nil || len(s.Pages) != 1 || s.Pages[0] != "candidates" {
|
|
t.Errorf("got pages=%v err=%v", s.Pages, err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Trimming the closing fence too eagerly, or not at all.
|
|
name: "trailing tab after the closing fence still closes it",
|
|
raw: strings.Replace(base, "\n---\n", "\n---\t\n", 1),
|
|
check: func(t *testing.T, s *definition.Skill, err error) {
|
|
if err != nil || s.Name != "Sample Skill" {
|
|
t.Errorf("got name=%q err=%v", s.Name, err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// A greedy fence would swallow the second document and lose the id.
|
|
name: "a second --- document is body, not frontmatter",
|
|
raw: base + "\n\n---\nid: second\n---\n",
|
|
check: func(t *testing.T, s *definition.Skill, err error) {
|
|
if err != nil || s.ID != "sample-skill" {
|
|
t.Errorf("got id=%q err=%v", s.ID, err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Treating `#` as always starting a comment.
|
|
name: "a hash inside a word is part of the word",
|
|
raw: strings.Replace(base, "description: A sample.", "category: ops#1", 1),
|
|
check: func(t *testing.T, s *definition.Skill, err error) {
|
|
if err != nil || s.Category != "ops#1" {
|
|
t.Errorf("got category=%q err=%v", s.Category, err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Treating a spaced `#` as part of the value.
|
|
name: "a spaced hash starts a comment",
|
|
raw: strings.Replace(base, "description: A sample.", "category: ops # note", 1),
|
|
check: func(t *testing.T, s *definition.Skill, err error) {
|
|
if err != nil || s.Category != "ops" {
|
|
t.Errorf("got category=%q err=%v", s.Category, err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Splitting a quoted value on its colon.
|
|
name: "a colon inside quotes stays in the value",
|
|
raw: strings.Replace(base, "name: Sample Skill", `name: "Sample: Skill"`, 1),
|
|
check: func(t *testing.T, s *definition.Skill, err error) {
|
|
if err != nil || s.Name != "Sample: Skill" {
|
|
t.Errorf("got name=%q err=%v", s.Name, err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Keeping the first duplicate rather than the last.
|
|
name: "a duplicate key takes the last value",
|
|
raw: strings.Replace(base, "name: Sample Skill", "name: First\nname: Second", 1),
|
|
check: func(t *testing.T, s *definition.Skill, err error) {
|
|
if err != nil || s.Name != "Second" {
|
|
t.Errorf("got name=%q err=%v", s.Name, err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Accepting ragged indentation instead of refusing it.
|
|
name: "ragged indentation is refused with its line",
|
|
raw: strings.Replace(base, " - candidates", " - candidates\n - positions", 1),
|
|
check: func(t *testing.T, s *definition.Skill, err error) {
|
|
var pe *definition.Error
|
|
if err == nil {
|
|
t.Fatalf("accepted ragged indentation: %+v", s)
|
|
}
|
|
if !asError(err, &pe) || pe.Line != 6 {
|
|
t.Errorf("got %v, want an *Error on line 6", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Canonicalising a skill's pages, which the frontend does not do.
|
|
name: "a skill keeps the page name as written",
|
|
raw: strings.Replace(base, " - candidates", " - Talent Pool", 1),
|
|
check: func(t *testing.T, s *definition.Skill, err error) {
|
|
if err != nil || len(s.Pages) != 1 || s.Pages[0] != "Talent Pool" {
|
|
t.Errorf("got pages=%v err=%v", s.Pages, err)
|
|
}
|
|
if err := definition.ValidateSkill(strings.Replace(base, " - candidates", " - Talent Pool", 1)); err != nil {
|
|
t.Errorf("an aliased page should still validate: %v", err)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, m := range mutations {
|
|
t.Run(m.name, func(t *testing.T) {
|
|
skill, err := definition.ParseSkill(m.raw, definition.Options{})
|
|
if skill == nil {
|
|
skill = &definition.Skill{}
|
|
}
|
|
m.check(t, skill, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// asError is errors.As, spelled out for the one concrete type this package
|
|
// returns.
|
|
func asError(err error, target **definition.Error) bool {
|
|
e, ok := err.(*definition.Error)
|
|
if ok {
|
|
*target = e
|
|
}
|
|
return ok
|
|
}
|
|
|
|
/* ── 10. Bounds ───────────────────────────────────────────────────────────── */
|
|
|
|
// The size bound is the backend's, and both directions of it matter: a
|
|
// definition at the limit must be storable and one character more must not.
|
|
// The companion to TestSizeBound, for the other backend-only bound. The
|
|
// fixture pins a version well above the bound and one exactly at it, which
|
|
// leaves the step between them untested — an off-by-one there would refuse a
|
|
// version PostgreSQL can store, or accept one it cannot. Both sides of the
|
|
// step are named here so that cannot happen.
|
|
func TestVersionBound(t *testing.T) {
|
|
agent := func(version string) string {
|
|
return "---\nid: sample-agent\nname: Sample Agent\npages:\n - candidates\n" +
|
|
"version: " + version + "\n---\n\n# Sample Agent\n"
|
|
}
|
|
|
|
at := strconv.Itoa(definition.MaxVersion)
|
|
if err := definition.ValidateAgent(agent(at)); err != nil {
|
|
t.Errorf("version %s, exactly at the bound, was refused: %v", at, err)
|
|
}
|
|
|
|
over := strconv.FormatInt(int64(definition.MaxVersion)+1, 10)
|
|
err := definition.ValidateAgent(agent(over))
|
|
if err == nil {
|
|
t.Fatalf("version %s, one past the bound, was accepted", over)
|
|
}
|
|
r, ok := err.(*definition.Rejection)
|
|
if !ok || !r.BackendOnly {
|
|
t.Errorf("the version bound should be reported as a backend-only rule, got %v", err)
|
|
}
|
|
|
|
// The bound belongs to validation, not to parsing: a version the database
|
|
// cannot store must still normalize to the number the author wrote, or the
|
|
// record the editor shows and the record Go builds would disagree.
|
|
parsed, err := definition.ParseAgent(agent(over), definition.Options{})
|
|
if err != nil {
|
|
t.Fatalf("parsing a too-large version failed: %v", err)
|
|
}
|
|
if got := strconv.Itoa(parsed.Version); got != over {
|
|
t.Errorf("parse clamped the version to %s; it should carry %s", got, over)
|
|
}
|
|
if len(parsed.Errors) != 0 {
|
|
t.Errorf("parse reported a backend-only bound as an authoring error: %v", parsed.Errors)
|
|
}
|
|
}
|
|
|
|
func TestSizeBound(t *testing.T) {
|
|
head := "---\nid: sample-skill\nname: Sample Skill\npages:\n - candidates\n---\n\n"
|
|
|
|
at := head + strings.Repeat("y", definition.MaxMarkdownLength-len(head))
|
|
if n := len([]rune(at)); n != definition.MaxMarkdownLength {
|
|
t.Fatalf("fixture is %d characters, wanted exactly %d", n, definition.MaxMarkdownLength)
|
|
}
|
|
if err := definition.ValidateSkill(at); err != nil {
|
|
t.Errorf("a definition exactly at the bound was refused: %v", err)
|
|
}
|
|
|
|
over := at + "y"
|
|
err := definition.ValidateSkill(over)
|
|
if err == nil {
|
|
t.Fatal("a definition one character over the bound was accepted")
|
|
}
|
|
r, ok := err.(*definition.Rejection)
|
|
if !ok || !r.BackendOnly {
|
|
t.Errorf("the size bound should be reported as a backend-only rule, got %v", err)
|
|
}
|
|
}
|