diff --git a/core/http/react-ui/e2e/addon-model-actions.spec.js b/core/http/react-ui/e2e/addon-model-actions.spec.js index 30a096c92..cd1a7c83c 100644 --- a/core/http/react-ui/e2e/addon-model-actions.spec.js +++ b/core/http/react-ui/e2e/addon-model-actions.spec.js @@ -56,11 +56,13 @@ for (const surface of ['installed','gallery','detail']) { expect(new URL(page.url()).pathname).not.toContain('/app/addons/') }) } -test('removed addon addresses reach NotFound rather than addon or install promotion',async({page})=>{ - await setup(page,[]) - for (const path of ['studio','talk','image','video','3d','tts','sound','diarization','transform','face','voice','voice-library','fine-tune','quantize']) { +// One test per address: each loads the whole app, and a slow runner needs +// several seconds for every load, more than one test may spend on fourteen. +for (const path of ['studio','talk','image','video','3d','tts','sound','diarization','transform','face','voice','voice-library','fine-tune','quantize']) { + test(`removed addon address /app/${path} reaches NotFound rather than addon or install promotion`,async({page})=>{ + await setup(page,[]) await page.goto(`/app/${path}`) await expect(page.getByTestId('not-found')).toBeVisible() expect(new URL(page.url()).pathname).toBe(`/app/${path}`) - } -}) + }) +} diff --git a/core/http/react-ui/e2e/addons-gallery.spec.js b/core/http/react-ui/e2e/addons-gallery.spec.js index f32aa4882..ec045c920 100644 --- a/core/http/react-ui/e2e/addons-gallery.spec.js +++ b/core/http/react-ui/e2e/addons-gallery.spec.js @@ -253,7 +253,12 @@ test.describe('Add-ons: apps', () => { await expect(page.getByText('may be out of date')).toHaveCount(0) // Leave for a route without a mounted add-on consumer (Home keeps commands current). await page.evaluate(() => { window.history.pushState({}, '', '/app/polling-left'); window.dispatchEvent(new PopStateEvent('popstate')) }) + // The route loads lazily and the page clock is paused: let the transition + // finish, and the add-ons page unmount, before counting reads again. + await page.clock.runFor(500) + await settle(page) await expect(page.locator('[data-app="notes"]')).toHaveCount(0) + await expect(page.getByRole('heading', { name: 'This page does not exist' })).toBeVisible() const seen = state.log.appsList await page.clock.runFor(180_000) expect(state.log.appsList).toBe(seen) diff --git a/core/http/react-ui/e2e/home-console.spec.js b/core/http/react-ui/e2e/home-console.spec.js index f7496ab8b..79fe177ed 100644 --- a/core/http/react-ui/e2e/home-console.spec.js +++ b/core/http/react-ui/e2e/home-console.spec.js @@ -35,15 +35,15 @@ test.describe('Home slash menu', () => { const names = await page.locator('.dk-cmd-name').allTextContents() // Native Apps opens the generic installed list, never a promoted package. // Home's own commands first; the commands it shares with a chat follow: - // /attach, /skills and any declared skills, then /help. + // /attach, /skill, /skills and one per skill of the server, then /help. expect(names.slice(0, 15)).toEqual([ '/model', '/new', '/chat', '/assistant', '/models', '/gallery', '/installed', '/import', '/stop', // No installation advice for a particular provider. '/agents', '/mcp', '/memory', '/apps', '/settings', '/docs', ]) - expect(names.slice(15, 18)).toEqual(['/attach', '/skills', '/help']) - expect(names).not.toContain('/skill') // No skills are declared by this fixture. + // The test server always keeps built-in skills, so /skill is offered. + expect(names.slice(15, 18)).toEqual(['/attach', '/skill', '/skills']) expect(names[names.length - 1]).toBe('/help') // A chat that does not exist yet has nothing to compact or clear. expect(names).not.toContain('/compact') @@ -355,12 +355,17 @@ test.describe('Home memory strip', () => { test('shows a skeleton while the first answer is on its way', async ({ page }) => { await mockHome(page) + // Hold the answer until the skeleton was seen: a fixed delay can run out + // before a slow runner has painted the page. + let release + const held = new Promise(resolve => { release = resolve }) await page.route('**/v1/models', async route => { - await new Promise(resolve => setTimeout(resolve, 800)) + await held await route.fulfill({ json: { data: [{ id: 'a' }] } }) }) - await page.goto('/app') + await page.goto('/app', { waitUntil: 'commit' }) await expect(page.getByTestId('home-strip-skeleton')).toBeVisible() + release() await expect(page.locator('.home-strip__head')).toBeVisible() }) }) diff --git a/core/services/studio/addonskills_internal_test.go b/core/services/studio/addonskills_internal_test.go index 00b9c385d..314c78496 100644 --- a/core/services/studio/addonskills_internal_test.go +++ b/core/services/studio/addonskills_internal_test.go @@ -58,6 +58,14 @@ func md(name, desc string) []byte { // newSkillService is a chats service on a real workspace with a broker that // ships add-on skills. func newSkillService(b *skillBroker, pins ...map[string]string) (*Service, *files.Service) { + return newSkillServiceWith(b, nil, pins...) +} + +// newSkillServiceWith is newSkillService with the harness a chat runs on. Nil +// keeps the real nib harness, whose session setup costs about a second and +// grows with load; a spec that opens a chat only to see how its tools are +// built passes a stub. +func newSkillServiceWith(b *skillBroker, h session.Harness, pins ...map[string]string) (*Service, *files.Service) { dsn := "file:" + filepath.Join(GinkgoT().TempDir(), "ws.db") + "?_busy_timeout=5000&_txlock=immediate" db, err := sqlitedb.Open(dsn, &gorm.Config{}) Expect(err).NotTo(HaveOccurred()) @@ -66,7 +74,7 @@ func newSkillService(b *skillBroker, pins ...map[string]string) (*Service, *file Expect(err).NotTo(HaveOccurred()) f := files.New(ws) st, _ := store.NewJSON("") - d := Deps{Store: st, Provider: provider.New(), StateDir: GinkgoT().TempDir(), Files: f, AddonTools: b} + d := Deps{Store: st, Provider: provider.New(), StateDir: GinkgoT().TempDir(), Files: f, AddonTools: b, Harness: h} if len(pins) > 0 { d.ToolPins = func(context.Context) (map[string]string, error) { return pins[0], nil } } diff --git a/core/services/studio/documenteligibility_internal_test.go b/core/services/studio/documenteligibility_internal_test.go index 2ded71a56..97a0aa480 100644 --- a/core/services/studio/documenteligibility_internal_test.go +++ b/core/services/studio/documenteligibility_internal_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/studiotest" "github.com/mudler/LocalAI/core/services/studio/skills" . "github.com/onsi/ginkgo/v2" @@ -175,7 +176,11 @@ var _ = Describe("document provider matrix", func() { It("opens a scoped chat with load_skill without reentering session access resolution", func() { ctx := context.Background() - svc, f := newSkillService(&skillBroker{}) + // A stub harness: the spec checks that Open builds the chat tools + // without waiting on itself, not that nib can start a session. The + // real harness takes about a second under -race, and a loaded CI + // runner pushed it past the two second budget this spec once had. + svc, f := newSkillServiceWith(&skillBroker{}, studiotest.NewGateHarness()) store, err := agents.NewJSON("") Expect(err).NotTo(HaveOccurred()) svc.d.Agents = store @@ -200,7 +205,10 @@ var _ = Describe("document provider matrix", func() { select { case err := <-done: Expect(err).NotTo(HaveOccurred()) - case <-time.After(2 * time.Second): + case <-time.After(30 * time.Second): + // A re-entrant resolution blocks on the open lock for good, so + // any finite bound tells it from a slow runner; keep it far + // above the cost of an Open. // Fail without cleanup attempting to reacquire the stuck lock. // The test process timeout also bounds teardown on a regression. Fail("scoped chat setup reentered session access resolution") diff --git a/scripts/addons/suites.json b/scripts/addons/suites.json index 47fbeee29..83fa22eff 100644 --- a/scripts/addons/suites.json +++ b/scripts/addons/suites.json @@ -554,7 +554,12 @@ "accepts empty arguments with optional property constraints", "refuses protected or reserved plain names, whatever the add-on id", "keeps embedded names protected or reserved without requiring a slot fallback", - "does not promise a core fallback for document provider names" + "does not promise a core fallback for document provider names", + "packages all four document slots with the actual validator and no core fallback", + "proves every source block and local reference without Git or network", + "installs unchanged PDF helpers and their license with a build-time fixture check", + "declares and packages a loadable, receipt-verified skill without shadows", + "rejects a missing skill, mismatched frontmatter, and scripts" ], "report": ".cache/addons/logs/contracts.json", "reports": [ @@ -985,7 +990,12 @@ "lets a tag push prepare the immutable version and never move latest", "promotes latest only for a published, stable, non-draft release", "rejects unknown events and a malformed commit", - "prints the decision as key=value lines" + "prints the decision as key=value lines", + "packages all four document slots with the actual validator and no core fallback", + "proves every source block and local reference without Git or network", + "installs unchanged PDF helpers and their license with a build-time fixture check", + "declares and packages a loadable, receipt-verified skill without shadows", + "rejects a missing skill, mismatched frontmatter, and scripts" ] }, { @@ -4012,7 +4022,16 @@ "returns the SDK refusal text, the unverified-package text and the missing-release text to the administrator", "logs the cause of a failed install and types it for the client", "logs the reason behind a generic denial and leaves the response unchanged", - "names every typed cause of a failed install in the 403 of the dispatch route" + "names every typed cause of a failed install in the 403 of the dispatch route", + "installs upgrades and uninstalls the actual Sandbox document skills through verified receipts", + "finishes a real backup and commit after the submitting request is cancelled", + "polls and retries during a blocked backup, rejects conflicting IDs, and retains the receipt on restart", + "reconciles durable accepted work to interrupted without replay", + "Close cancels and joins an accepted owner worker before releasing storage", + "traces completion, retries, detach, interruption and late delivery without replay", + "keeps terminals immutable for every event and gives each active state a terminal exit", + "Close cancels a backup in flight and joins its cleanup before releasing ownership", + "serializes synchronous disable against an accepted upgrade without overwriting its journal" ], "report": ".cache/addons/logs/apps.json", "reports": [