Files
LocalAI/tests/e2e-ui/addons_fixture.go
T
mudler-agentandEttore Di Giacinto 16acc8926b feat(addons): simplify Office and discover Tables (#13029)
* feat(tables): list accessible databases

Let agents discover existing databases through the host gateway.
Keep listing actor-scoped and separate from executable tools.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(tables): verify database listing authority

Exercise model discovery through the real owner and carrier.
Check shared access, revocation, denial and resource limits.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(tables): bundle database search guidance

Teach agents to discover existing databases before querying records.
Package and verify the skill with the Tables add-on.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(tables): cover packaged chat skill gates

Load the real receipt-verified Tables instructions through the temporary
Studio workspace. Check the index and load refusal after runtime, chat
switch and saved-agent grant changes.

This fixture starts at the receipt and broker boundary; packaging tests
cover artifact construction and integrity separately.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(office): parse Markdown and CSV inputs

Convert bounded text inputs into document and workbook structures.
Render supported Markdown formatting without fetching external content.
Preserve spreadsheet fields as strings rather than inferring formulas.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(office): preserve list markers, breaks and PDF page margins

Cover actual DOCX breaks and tabs, explicit zero list starts, empty list rejection, and PDF margin/pagination regressions.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(office): guard Markdown parsing work

Check cancellation and nesting during parser callbacks.
Validate table dimensions before padded table allocation.
Keep parser state isolated to each conversion.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(office): recognize tables before applying limits

Leave mismatched table-like paragraphs as ordinary Markdown.
Keep oversized real tables bounded before allocation.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(office): simplify document creation inputs

Accept Markdown for DOCX and PDF, and CSV for single-sheet XLSX files.
Replace nested creation schemas to reduce model tool grammar complexity.
Keep existing read, edit and PowerPoint behavior and internal office IDs.

Present the add-on as Simple Office and document the compatibility change.
Keep same-style Markdown fragments editable in generated DOCX files.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ci): align add-on fixtures and test inventories

Tables fixtures must carry the skill their manifest declares. Preserve
its bytes in package tests and mark only binaries executable in UI
fixtures. Assert that installation keeps the shipped skill intact.

Register the new Tables tests and the connector case from master in
exact suite inventories. Include the new database read in tool counts.

Run Office regressions through Ginkgo and check reader close errors.
Keep all document assertions and the existing CI gates intact.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: nib:gpt-6.1-sol

* fix(ci): restore Office and packaging checks

Keep the generated Office label consistent with Simple Office. Install
Poppler for the PDF layout regressions on Linux and macOS.

Separate successful runtime setup from the 300 ms deadline under test.
Keep the timeout, cancellation, stop and admission assertions intact.

Refresh the packaging inventory from all 209 executed cases, retaining
repeated platform checks and the Office and Tables skill checks.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-02 12:48:08 +01:00

475 lines
17 KiB
Go

package main
// The add-ons UI fixture: with --addons-fixture the server runs with
// authentication on and a real add-on owner (lifecycle manager, supervisor,
// gateway and mTLS carrier), and installs the packages of the fixture
// directory through a real catalog served on loopback. A spec then drives the
// shell against real authorization: sessions and API keys are stored keys,
// every asset and call is decided by the owner, and enable, disable and
// upgrade are the real lifecycle routes.
import (
"archive/tar"
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io/fs"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
addonsvc "github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
const fixtureSecret = "addons-ui-fixture-secret" // #nosec G101 -- a fixed secret of the local test fixture, not a credential
type addonsFixture struct {
dataDir string
listener net.Listener
catalog *http.Server
library string
releases []catalog.Release
archives map[string][]byte
owner config.AddonConfig
// localOwner runs the owner without authentication, in local-owner mode.
localOwner bool
// mu guards what a spec moves through the control routes: the step of
// the moving channels and whether the library answers at all.
mu sync.Mutex
step int
offline bool
moving []movingRelease
}
// movingRelease is a release of a package directory that carries
// .fixture-generation: of the releases of one add-on and channel, only the
// one with the highest generation not above the current step is listed. A spec
// moves the channel by raising the step, as a publisher moves a tag.
type movingRelease struct {
catalog.Release
generation int
}
// Credentials is what the spec reads from --fixture-out.
type fixtureCredentials struct {
Users map[string]fixtureUser `json:"users"`
// Packages lists every installable release as "id@version".
Packages []string `json:"packages"`
// Control is the base URL of the fixture library's control routes: POST
// /step moves every moving channel on by one generation, POST /offline and
// /online make the library answer or not. Empty without moving channels.
Control string `json:"control,omitempty"`
}
type fixtureUser struct {
ID string `json:"id"`
Role string `json:"role"`
Token string `json:"token"`
Session string `json:"session"`
}
func newAddonsFixture(dir, executable, dataDir string, localOwner bool) (*addonsFixture, error) {
f := &addonsFixture{dataDir: dataDir, archives: map[string][]byte{}, localOwner: localOwner, step: 1}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
exe, err := os.ReadFile(executable) // #nosec G304 -- test harness reads files it created in its own work directory
if err != nil {
return nil, fmt.Errorf("fixture executable: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pkg := filepath.Join(dir, entry.Name())
raw, err := os.ReadFile(filepath.Join(pkg, "manifest.json")) // #nosec G304 -- test harness reads files it created in its own work directory
if err != nil {
continue
}
var manifest map[string]any
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, fmt.Errorf("%s: %w", entry.Name(), err)
}
// The package is for this machine: the fixture sources are portable.
manifest["os"], manifest["arch"] = runtime.GOOS, runtime.GOARCH
patched, _ := json.Marshal(manifest)
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
add := func(name string, data []byte) error {
mode := int64(0o644)
if name == "bin/fixture" || name == manifest["executable"] {
mode = 0o755
}
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: mode, Size: int64(len(data))}); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
if err := add("manifest.json", patched); err != nil {
return nil, err
}
// The executable goes where the manifest says it is. The protocol
// fixture packages all say bin/fixture; a package that names another
// path (the Tables package) gets the supplied executable there.
exePath := "bin/fixture"
if declared, _ := manifest["executable"].(string); declared != "" && !strings.HasPrefix(declared, "/") && !strings.Contains(declared, "..") {
exePath = declared
}
if err := add(exePath, exe); err != nil {
return nil, err
}
var files []string
_ = filepath.WalkDir(pkg, func(p string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() {
rel, _ := filepath.Rel(pkg, p)
// The .fixture-* files describe the release to the library and
// are not part of the package.
if rel != "manifest.json" && !strings.HasPrefix(rel, ".fixture-") {
files = append(files, rel)
}
}
return nil
})
sort.Strings(files)
for _, rel := range files {
data, err := os.ReadFile(filepath.Join(pkg, rel)) // #nosec G304 -- test harness reads files it created in its own work directory
if err != nil {
return nil, err
}
if err := add(filepath.ToSlash(rel), data); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
id, _ := manifest["id"].(string)
version, _ := manifest["version"].(string)
f.archives[entry.Name()+".tar"] = buf.Bytes()
release := catalog.Release{
ID: id, Name: id, Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH,
Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(buf.Bytes())), Artifact: "/" + entry.Name() + ".tar",
}
if channel, err := os.ReadFile(filepath.Join(pkg, ".fixture-channel")); err == nil { // #nosec G304 -- test harness reads files it created in its own work directory
release.Channel = strings.TrimSpace(string(channel))
}
if raw, err := os.ReadFile(filepath.Join(pkg, ".fixture-requires")); err == nil { // #nosec G304 -- test harness reads files it created in its own work directory
if err := json.Unmarshal(raw, &release.Requires); err != nil {
return nil, fmt.Errorf("%s: .fixture-requires: %w", entry.Name(), err)
}
}
if raw, err := os.ReadFile(filepath.Join(pkg, ".fixture-generation")); err == nil { // #nosec G304 -- test harness reads files it created in its own work directory
n, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil || n < 1 {
return nil, fmt.Errorf("%s: .fixture-generation must be a positive number", entry.Name())
}
f.moving = append(f.moving, movingRelease{Release: release, generation: n})
continue
}
f.releases = append(f.releases, release)
}
if len(f.releases) == 0 && len(f.moving) == 0 {
return nil, fmt.Errorf("no package under %s", dir)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
f.listener = listener
base := "http://" + listener.Addr().String()
for i := range f.releases {
f.releases[i].Artifact = base + f.releases[i].Artifact
}
for i := range f.moving {
f.moving[i].Artifact = base + f.moving[i].Artifact
}
f.library = base + "/library.yaml"
mux := http.NewServeMux()
mux.HandleFunc("/library.yaml", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
offline, listed := f.offline, f.listed()
f.mu.Unlock()
if offline {
http.Error(w, "library offline", http.StatusServiceUnavailable)
return
}
_ = catalog.WriteIndex(w, listed)
})
// The control routes of a spec: move the channels on and take the library
// away. They exist only in this test server.
mux.HandleFunc("/_fixture/step", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.step++
n := f.step
f.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]int{"step": n})
})
for path, offline := range map[string]bool{"/_fixture/offline": true, "/_fixture/online": false} {
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.offline = offline
f.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]bool{"offline": offline})
})
}
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
data, ok := f.archives[strings.TrimPrefix(r.URL.Path, "/")]
if !ok {
http.NotFound(w, r)
return
}
_, _ = w.Write(data)
})
f.catalog = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() { _ = f.catalog.Serve(listener) }()
transport, address, err := fixtureTransport()
if err != nil {
return nil, err
}
f.owner = config.AddonConfig{
Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://" + address, StorageID: "disk",
Root: filepath.Join(dataDir, "addons"), ListenAddress: address, Transport: transport,
Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: runtime.GOOS, Arch: runtime.GOARCH},
// A spec checks again within seconds: the interval of the refresh
// limit is short here and the limit itself stays on.
UpdateRefreshInterval: time.Second,
}
if localOwner {
f.owner.OwnershipMode = auth.AddonModeLocalOwner
}
return f, nil
}
// localOwnerAddons is the add-on owner of a server that has no fixture packages:
// add-ons are on, in local-owner mode, with a loopback carrier. The library
// sources spec needs the Add-ons page, which is only there with add-ons on.
func localOwnerAddons(dataDir string) (config.AddonConfig, error) {
transport, address, err := fixtureTransport()
if err != nil {
return config.AddonConfig{}, err
}
return config.AddonConfig{
Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://" + address, StorageID: "disk",
Root: filepath.Join(dataDir, "addons"), ListenAddress: address, Transport: transport,
Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: runtime.GOOS, Arch: runtime.GOARCH},
OwnershipMode: auth.AddonModeLocalOwner,
}, nil
}
// listed is the library document at the current step: the releases that never
// move and, of each moving add-on and channel, the one the step has reached.
func (f *addonsFixture) listed() []catalog.Release {
out := append([]catalog.Release(nil), f.releases...)
best := map[string]movingRelease{}
for _, m := range f.moving {
if m.generation > f.step {
continue
}
key := m.ID + "/" + m.Channel + "/" + m.Version
if cur, ok := best[key]; !ok || m.generation > cur.generation {
best[key] = m
}
}
keys := make([]string, 0, len(best))
for k := range best {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
out = append(out, best[k].Release)
}
return out
}
// fixtureTransport builds the owner's mTLS carrier identity: one pinned
// certificate and one signing key, as a single-instance deployment has.
func fixtureTransport() (*gateway.CarrierConfig, string, error) {
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, "", err
}
address := probe.Addr().String()
_ = probe.Close()
pub, key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, "", err
}
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "fixture CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
der, err := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
if err != nil {
return nil, "", err
}
if ca, err = x509.ParseCertificate(der); err != nil {
return nil, "", err
}
pool := x509.NewCertPool()
pool.AddCert(ca)
lp, lk, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, "", err
}
leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "owner"}, NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}
ld, err := x509.CreateCertificate(rand.Reader, leaf, ca, lp, key)
if err != nil {
return nil, "", err
}
kd, err := x509.MarshalPKCS8PrivateKey(lk)
if err != nil {
return nil, "", err
}
pair, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ld}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}))
if err != nil {
return nil, "", err
}
signPub, signKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, "", err
}
return &gateway.CarrierConfig{
InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://" + address,
Certificate: pair, Roots: pool,
Pins: map[string][32]byte{"owner": sha256.Sum256(ld)},
SigningKey: signKey,
VerificationKeys: map[string]ed25519.PublicKey{"owner": signPub},
}, address, nil
}
func (f *addonsFixture) options(dataDir string) []config.AppOption {
if f.localOwner {
// One local owner and no accounts: sharing has nobody to share with.
return []config.AppOption{config.WithAddons(f.owner)}
}
return []config.AppOption{
config.WithAuthEnabled(true),
config.WithAuthDatabaseURL(filepath.Join(dataDir, "auth.db")),
config.WithAuthAPIKeyHMACSecret(fixtureSecret),
config.WithAddons(f.owner),
}
}
// seed creates the accounts, saves the library and installs and enables the
// packages named in `enable` ("id@version").
func (f *addonsFixture) seed(ctx context.Context, app *application.Application, out string, enable []string) error {
db := app.AuthDB()
if db == nil && !f.localOwner {
return fmt.Errorf("authentication is not enabled")
}
creds := fixtureCredentials{Users: map[string]fixtureUser{}}
var admin contract.Actor
if f.localOwner {
admin = contract.Actor{UserID: auth.AddonLocalOwnerID, CredentialID: auth.AddonLocalOwnerID, Mode: auth.AddonModeLocalOwner}
}
accounts := []struct {
name, role string
feature bool
}{{"admin", auth.RoleAdmin, true}, {"member", auth.RoleUser, true}, {"outsider", auth.RoleUser, false},
// Two more people with the feature, for the roles of a shared database.
{"editor", auth.RoleUser, true}, {"viewer", auth.RoleUser, true}}
if f.localOwner {
accounts = nil
}
for _, u := range accounts {
user := auth.User{ID: "fixture-" + u.name, Email: u.name + "@fixture.test", Name: u.name, Provider: auth.ProviderLocal, Role: u.role, Status: auth.StatusActive}
if err := db.Create(&user).Error; err != nil {
return err
}
if u.feature && u.role != auth.RoleAdmin {
if err := db.Create(&auth.UserPermission{ID: uuid.NewString(), UserID: user.ID, Permissions: auth.PermissionMap{auth.FeatureAddons: true}}).Error; err != nil {
return err
}
}
token, key, err := auth.CreateAPIKey(db, user.ID, "fixture", u.role, fixtureSecret, nil)
if err != nil {
return err
}
session, err := auth.CreateSession(db, user.ID, fixtureSecret)
if err != nil {
return err
}
creds.Users[u.name] = fixtureUser{ID: user.ID, Role: u.role, Token: token, Session: session}
if u.name == "admin" {
admin = contract.Actor{UserID: user.ID, CredentialID: "key:" + key.ID, Mode: auth.AddonModeNamedUser}
}
}
sources, manager := app.AddonSources(), app.AddonManager()
if sources == nil || manager == nil {
return fmt.Errorf("the add-on owner did not start")
}
if err := sources.Save(ctx, admin, catalog.Source{ID: "fixture", Name: "Fixture", URL: f.library, Enabled: true, AllowLoopbackHTTP: true}); err != nil {
return err
}
if _, err := sources.Refresh(ctx, "fixture"); err != nil {
return err
}
for _, r := range f.listed() {
creds.Packages = append(creds.Packages, r.ID+"@"+r.Version)
}
if len(f.moving) > 0 {
creds.Control = "http://" + f.listener.Addr().String() + "/_fixture"
}
for i, spec := range enable {
id, version, _ := strings.Cut(spec, "@")
if _, err := manager.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: fmt.Sprintf("seed-install-%d", i), AddonID: id, Action: "install", SourceID: "fixture", Version: version}); err != nil {
return fmt.Errorf("install %s: %w", spec, err)
}
if _, err := manager.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: fmt.Sprintf("seed-enable-%d", i), AddonID: id, Action: "enable", Generation: 1}); err != nil {
return fmt.Errorf("enable %s: %w", spec, err)
}
}
// Wait for every enabled process to be observed running, so a spec starts
// from a settled owner.
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
list, err := manager.Apps(ctx, admin)
if err == nil {
running := 0
for _, a := range list {
if a.State == "running" {
running++
}
}
if running >= len(enable) {
break
}
}
time.Sleep(200 * time.Millisecond)
}
b, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return err
}
return os.WriteFile(out, b, 0o600)
}
func (f *addonsFixture) close() {
if f.catalog != nil {
_ = f.catalog.Close()
}
}