Files
open-design/apps/web/tests/components/HomeView.first-run-guide.test.tsx
T
Ray XiandGitHub 2e4c1a753b feat(web): refresh the Home hero and composer (#7731)
* feat(web): add the SiriOrb run indicator

Ports the Siri Orb (SmoothUI, MIT) from #7635 as a standalone component: a rotating neon sphere with idle / thinking / streaming speeds, drawn entirely in its CSS Module. Unused by this PR; the rail run-status icon (PR-A of OPEND-2553) consumes it.

* feat(web): vendor the composer running orb

Adds the thinking-orbs 0.3.1 'solving' state at its 20px preset (MIT, attributed in the file) as a local component instead of a new runtime dependency: the composer needs one of the package's nine animations, pinned to the dark palette, and the transcription keeps the geometry, preset scaling, painter and frame loop of the published build so the canvas output is identical. Refs OPEND-2553 / #7635.

* feat(web): add the rotating Home headline i18n keys

homeHero.title now carries the {word} slot in every locale together with homeHero.titleWords (the comma-separated nouns the headline cycles), homeHero.subtitle and the attachment band's paging labels. Every locale owns its own sentence and noun list so the placeholder-parity check stays green (#7635 only rewrote en / zh-CN / zh-TW). Refs OPEND-2553.

* test(web): re-pin the Home hero and composer specs to the refreshed surfaces

Follows #7635's hero/composer contract: type row instead of the template dropdown, display+clear type pill, no Home mode picker, no resource submenus in the '+' menu, references in the working-directory row, lead-chip truncation, the 116px hero offset, the 220px execution-chip cap, the flat app wash and the stable send arrow. Carries over the Demo's new specs (dock collapse, Backspace-at-start, modelVersionLabel, icon-only design-system pill) and drops the ones for removed surfaces. Refs OPEND-2553.

* test(e2e): follow the Home type row and composer menu

Ports #7635's Playwright helper (homeTypeRow / clearHomeTemplate / pickHomeTemplate) and the home-hero-rail, entry-chrome-flows, project-management-flows, visual-navigation and entry-topbar changes that reach the composer through the type row, the working-directory group and the trimmed '+' menu. Refs OPEND-2553.

* feat(web): refresh the Home hero and composer

Replicates the Home hero + composer subset of #7635 on current main (OPEND-2553, PR-B): plain headline with the rotating noun and subtitle, a typeless cold Home whose type row (3 + 2 behind More) retires into the composer's display+clear pill, upload disc + design-system palette + type pill in the foot row, the supplied send mark and border beam, two-line staged-file chips in a paging band, the lead template chip with Backspace-at-start, references and linked checkouts in the working-directory row (one slot), the trimmed '+' menu without resource submenus or the mode picker, poster rows for examples, the model chip's brand mark and version-only label, the running orb + stop square in the project composer, and the flat app wash. HomeView gains the page/dock variant plumbing and drops the default type seed and the AppWashKineticGrid mount; the recent-projects grid stays for PR-C. Adds task_chip_clear to the composer click contract.

* test(e2e): follow the Home composer and type-row refresh

Migrates the Playwright specs that named surfaces #7635 removed or moved: the composer session-mode picker (every turn now carries the conversation's stored design mode, and design turns carry no message chip), the Home examples loading shell (a cold Home is typeless and shows only the type row while the catalogue loads), the fixed type row (three inline pills plus two behind More; every other create type reaches the hero through the cross-surface hand-off, which the shared pickHomeTemplate helper now drives), the working-directory row's single slot for references and linked checkouts, the '+' menu's working-dir group, the lead chip's eight-character title, the 32px send disc, and the flat app wash. The Home recent-projects specs keep main's shape (PR-C owns that move). Refs OPEND-2553.

* test(e2e): repair the visual captures the composer refresh broke

The projects captures clicked the rail's drafts item, which only renders
with a workspace context the visual fixture never has; Home already shows
the recent-projects strip, so the click is dropped. The integrations
capture reached its page by direct load, whose default tab is MCP and
whose cold mount outlasts a bare expect; it now clicks the connectors tab
like the MCP capture does. The BYOK switcher capture expected the link
glyph, but the chip leads with the selected model's brand mark and only
falls back to the glyph for a vendor without one. OPEND-2553 (PR-B).
2026-09-04 07:23:41 +00:00

215 lines
7.2 KiB
TypeScript

// @vitest-environment jsdom
// First-run guidance trail (home-hero/firstRunGuide.ts).
//
// A brand-new user (no projects, fresh storage) gets a sheen pulse on the
// Prototype type chip when no type can be selected automatically; a default
// type skips that redundant beat so the first example card can pulse next,
// and the trail never replays.
// Users with existing projects have the trail completed silently.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
vi.mock('../../src/components/home-hero/PlaceholderCarousel', () => ({
PlaceholderCarousel: () => null,
}));
import { HomeView } from '../../src/components/HomeView';
import { I18nProvider } from '../../src/i18n';
import {
readHomeGuideStage,
writeHomeGuideStage,
} from '../../src/components/home-hero/firstRunGuide';
const SAMPLE_PROJECT = {
id: 'p1',
name: 'existing project',
createdAt: 0,
updatedAt: 0,
};
function stubPluginsFetch() {
vi.stubGlobal('fetch', vi.fn(async (url: RequestInfo | URL) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
}));
}
function renderHome(projects: unknown[] = []) {
return render(
<I18nProvider initial="en">
<HomeView
projects={projects as never}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>
</I18nProvider>,
);
}
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
window.localStorage.clear();
});
// #5517 removed the inline template rail from Home, so beat 1 of the guide no
// longer has a chip card to sheen; the stage still arms on mount and advances
// when a template is picked from the composer footer's radial picker.
// Home starts with no creation type: the type row under the composer is the
// empty state's only control, and it retires once something is picked.
async function pickHomeTemplate(id: string) {
// A type already picked retires the row, and the pill has no menu — so
// switching means clearing back to the empty state first.
const clear = screen.queryByTestId('home-hero-template-clear');
if (clear) fireEvent.click(clear);
const rowPill = await screen.findByTestId(`home-hero-type-pill-${id}`);
await waitFor(() => expect((rowPill as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(rowPill);
}
describe('Home first-run guide trail', () => {
it('arms beat 1 for a fresh user and advances when a template is picked', async () => {
stubPluginsFetch();
renderHome([]);
expect(readHomeGuideStage()).toBe('chip');
await pickHomeTemplate('prototype');
expect(readHomeGuideStage()).not.toBe('chip');
});
it('completes the trail silently for users who already have projects', async () => {
stubPluginsFetch();
renderHome([SAMPLE_PROJECT]);
await screen.findByTestId('home-hero-input');
await waitFor(() => {
expect(readHomeGuideStage()).toBe('done');
});
expect(document.querySelector('.home-hero__attention-sheen')).toBeNull();
});
it('stays inert while projects are still loading', async () => {
stubPluginsFetch();
render(
<I18nProvider initial="en">
<HomeView
projects={[] as never}
projectsLoading
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>
</I18nProvider>,
);
await screen.findByTestId('home-hero-input');
await new Promise((resolve) => setTimeout(resolve, 1200));
// Unknown projects state: the stage is NOT silently completed — a
// brand-new user still gets the trail once loading resolves.
expect(readHomeGuideStage()).toBe('chip');
});
it('closes an in-flight stage when projects resolve to an existing user', async () => {
stubPluginsFetch();
const { rerender } = render(
<I18nProvider initial="en">
<HomeView
projects={[] as never}
projectsLoading
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>
</I18nProvider>,
);
// The user clicks a chip while projects are still loading — the stage
// moves to 'card' before we know whether they are new.
await pickHomeTemplate('prototype');
expect(readHomeGuideStage()).toBe('card');
// Loading resolves: existing user. The stage must close so no chip's
// example cards ever show the first-preset sheen.
rerender(
<I18nProvider initial="en">
<HomeView
projects={[SAMPLE_PROJECT] as never}
projectsLoading={false}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>
</I18nProvider>,
);
await waitFor(() => {
expect(readHomeGuideStage()).toBe('done');
});
});
it('carries a default prototype straight to beat 2 through the static prompt-example fallback', async () => {
// The chip's default plugin exists (so the chip binds) but nothing
// matches the example filter — the chip renders static prompt-example
// cards, and the guide's beat 2 must land on the first of those.
const WEB_PROTOTYPE_PLUGIN = {
id: 'example-web-prototype',
title: 'Web Prototype',
version: '0.1.0',
trust: 'bundled' as const,
sourceKind: 'bundled' as const,
source: '/tmp/web-prototype',
capabilitiesGranted: ['prompt:inject'],
fsPath: '/tmp/web-prototype',
installedAt: 0,
updatedAt: 0,
manifest: {
name: 'example-web-prototype',
title: 'Web Prototype',
version: '0.1.0',
description: 'General-purpose desktop web prototype.',
od: { kind: 'scenario', taskKind: 'new-generation' },
},
};
vi.stubGlobal('fetch', vi.fn(async (url: RequestInfo | URL) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
}));
renderHome([]);
// Home no longer seeds a default type; beat 1 is the pick itself, and beat
// 2 then lands on the first static prompt-example card under it.
await pickHomeTemplate('prototype');
const exampleCards = await screen.findAllByTestId('home-hero-prompt-example');
await waitFor(
() => {
expect(exampleCards[0]?.className).toContain('home-hero__attention-sheen');
},
{ timeout: 3000 },
);
expect(readHomeGuideStage()).toBe('done');
});
it('never replays once done', async () => {
writeHomeGuideStage('done');
stubPluginsFetch();
renderHome([]);
await screen.findByTestId('home-hero-input');
await new Promise((resolve) => setTimeout(resolve, 1200));
expect(readHomeGuideStage()).toBe('done');
expect(document.querySelector('.home-hero__attention-sheen')).toBeNull();
});
});