From 1e019464363cb6f416b9c1d908a7944106a8b963 Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Wed, 11 Nov 2026 06:05:51 +0800 Subject: [PATCH 1/4] refactor: consume shared theme SDK and CI contracts Use immutable public release artifacts and retain product-specific behavior. Independent review and real release installation recorded in the sharing handoff. --- .github/scripts/ci-node.sh | 5 + .github/workflows/tag-ci.yml | 25 +- contracts/fastresearch-today/v1/README.md | 54 ++++ contracts/fastresearch-today/v1/cases.json | 45 ++++ .../fastresearch-today/v1/request.schema.json | 8 + go.mod | 6 +- go.sum | 12 +- .../httpapi/fastresearch_contract_test.go | 232 ++++++++++++++++ web/.gitignore | 1 + web/index.html | 4 +- web/package-lock.json | 53 ++++ web/package.json | 2 + web/public/theme-init.js | 25 -- web/src/{Gantt.tsx => GanttView.tsx} | 0 web/src/GoalManager.tsx | 2 +- web/src/ThemeControl.tsx | 2 - web/src/main.tsx | 2 + web/src/styles.css | 111 +++----- web/src/theme-init.ts | 7 + web/src/theme.ts | 207 +++----------- web/src/workbench-theme.css | 6 +- web/tests/theme.test.mjs | 255 +++++++----------- web/vite.config.ts | 27 +- 23 files changed, 635 insertions(+), 456 deletions(-) create mode 100644 .github/scripts/ci-node.sh create mode 100644 contracts/fastresearch-today/v1/README.md create mode 100644 contracts/fastresearch-today/v1/cases.json create mode 100644 contracts/fastresearch-today/v1/request.schema.json create mode 100644 internal/httpapi/fastresearch_contract_test.go create mode 100644 web/.gitignore delete mode 100644 web/public/theme-init.js rename web/src/{Gantt.tsx => GanttView.tsx} (100%) create mode 100644 web/src/theme-init.ts diff --git a/.github/scripts/ci-node.sh b/.github/scripts/ci-node.sh new file mode 100644 index 0000000..e1802a4 --- /dev/null +++ b/.github/scripts/ci-node.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +npm test +npm run build +test -f dist/index.html diff --git a/.github/workflows/tag-ci.yml b/.github/workflows/tag-ci.yml index ebc554d..6bd6192 100644 --- a/.github/workflows/tag-ci.yml +++ b/.github/workflows/tag-ci.yml @@ -2,7 +2,9 @@ name: Tag CI FastTask on: push: + branches: [main] tags: ['v*'] + pull_request: workflow_dispatch: permissions: @@ -13,23 +15,24 @@ concurrency: cancel-in-progress: true jobs: - validate: + backend: runs-on: ubuntu-latest timeout-minutes: 30 + env: + GOWORK: 'off' + CGO_ENABLED: '1' steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: 1.26.8 cache: true - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - cache-dependency-path: web/package-lock.json + - run: go mod download + - run: go mod verify - run: go test ./... - - run: CGO_ENABLED=1 go build -trimpath -o bin/fasttask ./cmd/fasttask - - working-directory: web - run: npm ci - - working-directory: web - run: npm run build + - run: go build -trimpath -o bin/fasttask ./cmd/fasttask + + web: + uses: FastR-D/.github/.github/workflows/node-npm-check.yml@98eac6706014d070c5563eb75e3899611507f176 + with: + working-directory: web diff --git a/contracts/fastresearch-today/v1/README.md b/contracts/fastresearch-today/v1/README.md new file mode 100644 index 0000000..e4fdb29 --- /dev/null +++ b/contracts/fastresearch-today/v1/README.md @@ -0,0 +1,54 @@ +# FastResearch today v1 + +Owner: FastTask. `POST /api/v1/integrations/fastresearch/today`, JSON, `Cache-Control: no-store`. +The path version is the contract release identifier, not a required body field. + +Primary identity: FastCAS client_credentials, scope `integrations:fastresearch`. +Issuer registration must issue the Task audience (default `fasttask-api`); +the SDK grant does not accept an arbitrary audience override. Task verifies the +signature, issuer, audience, expiry, access token_use, service=true, sub=client_id, +client allowlist and each normalized receiver's exact allowlist entry. + +Legacy identity remains an actual supported path: `X-API-Key` (or existing +`X-FastResearch-Provision-Key`) only when `FASTTASK_FASTRESEARCH_LEGACY_INGEST_KEY_ENABLED=true` +and the configured ingest key matches. Never send it with Authorization; never +retry a rejected Bearer using the key. Credentials are server-side only. + +`request.schema.json` describes raw wire input; normalization takes the first 200 +unique nonempty trimmed refs, preserving order. Empty normalized lists return +400. `cases.json` supplies synthetic fixtures for both HTTP receiver and Research +client tests. Unknown receivers have found=false, items=[], zero progress. +Known receivers include user/date/plan metadata and items. A per-member `error` +may accompany lookup/refresh failures; HTTP 200 alone is not a complete-member +success. RefreshSchedule may derive today's plan; this API does not create or +complete underlying tasks. Counts reflect satisfied/in_progress/skipped/planned. + +The positive fixture maps `account-a` to an active user, a frozen current UTC +plan at revision 3, and three real task/item rows: in_progress, satisfied and +planned. The receiver test seeds domain rows in SQLite; it does not inject HTTP +responses or replace RefreshSchedule/PlanItemViews. It compares the entire wire +response, including Task IDs, commitments, completion metadata, counts and the +FastRead/FastWrite workspace projections. `missing-account` stays unknown. +`$today` is replaced with the current UTC calendar date by both runners; stable +IDs and the fixed updated_at remain exact assertions. `research_today` records +the expected Research member projection, including snake_case to camelCase +fields and null workspace for an unclassified task. + +The mapped `outside` account is deliberately absent from the service allowlist: +service queries return 403, while the explicitly enabled legacy shared key can +still read it. Legacy has its existing integration-wide authority, not the +service client's receiver restriction. + +Run `GOWORK=off go test ./internal/httpapi -run TestFastResearchTodayWireContract -count=1`. +For a local cross-product run, also set `FASTRESEARCH_CONTRACT_PROBE` to the +absolute Research `scripts/fasttask-today-contract.mjs` path. This makes the same +receiver test run the installed npm SDK through Research's production factory, +against live loopback Task and a disposable signed OIDC issuer, then starts the +actual Research server and queries its member today route with service-only +configuration. The issuer counts grants to verify token reuse. Ordinary product +CI needs only its own checkout and registry dependencies. + +Research vendors this fixture under `contracts/fastresearch-today/v1`; updates +must be copied from this receiver-owned directory and reviewed on both sides. +No tasks/progress/accounts endpoint is promised by this contract. These two ends +are not evidence of two independent sending products or a deployed FastCAS run. diff --git a/contracts/fastresearch-today/v1/cases.json b/contracts/fastresearch-today/v1/cases.json new file mode 100644 index 0000000..614a970 --- /dev/null +++ b/contracts/fastresearch-today/v1/cases.json @@ -0,0 +1,45 @@ +{ + "request": {"receiver_refs": [" account-a ", "account-a", "", "missing-account"]}, + "normalized_refs": ["account-a", "missing-account"], + "response": {"members": [ + { + "receiver_ref": "account-a", "found": true, + "user_id": "task-user-a", "display_name": "Research member", + "local_date": "$today", "timezone": "UTC", "plan_id": "today-plan-a", + "plan_status": "active", "plan_revision": 3, "updated_at": "2026-11-10T00:00:00Z", + "items": [ + {"id": "item-read", "task_id": "task-read", "kind": "core", "title": "fastread: Read evidence", "commitment": "Extract two claims", "minimum_action": "Read the abstract", "status": "in_progress", "target_minutes": 30, "position": 1, "workspace": {"system": "fastread", "label": "FastRead", "url": "https://fastrd.io/read/"}}, + {"id": "item-write", "task_id": "task-write", "kind": "core", "title": "fastwrite: Draft findings", "commitment": "Save a findings draft", "minimum_action": "Write one paragraph", "status": "satisfied", "completion_type": "result", "completion_summary": "Draft saved", "target_minutes": 45, "position": 2, "workspace": {"system": "fastwrite", "label": "FastWrite", "url": "https://fastrd.io/write/"}}, + {"id": "item-review", "task_id": "task-review", "kind": "core", "title": "Review evidence", "commitment": "Check the claim list", "minimum_action": "Open the checklist", "status": "planned", "target_minutes": 45, "position": 3} + ], + "progress": {"total": 3, "completed": 1, "in_progress": 1, "planned": 1, "skipped": 0} + }, + {"receiver_ref": "missing-account", "found": false, "items": [], "progress": {"total": 0, "completed": 0, "in_progress": 0, "planned": 0, "skipped": 0}} + ]}, + "research_today": { + "received": true, "date": "$today", "timezone": "UTC", "planStatus": "active", "planRevision": 3, "updatedAt": "2026-11-10T00:00:00Z", + "items": [ + {"id": "item-read", "taskId": "task-read", "kind": "core", "title": "fastread: Read evidence", "commitment": "Extract two claims", "minimumAction": "Read the abstract", "status": "in_progress", "completionType": "", "completionSummary": "", "targetMinutes": 30, "position": 1, "workspace": {"system": "fastread", "label": "FastRead", "url": "https://fastrd.io/read/"}}, + {"id": "item-write", "taskId": "task-write", "kind": "core", "title": "fastwrite: Draft findings", "commitment": "Save a findings draft", "minimumAction": "Write one paragraph", "status": "satisfied", "completionType": "result", "completionSummary": "Draft saved", "targetMinutes": 45, "position": 2, "workspace": {"system": "fastwrite", "label": "FastWrite", "url": "https://fastrd.io/write/"}}, + {"id": "item-review", "taskId": "task-review", "kind": "core", "title": "Review evidence", "commitment": "Check the claim list", "minimumAction": "Open the checklist", "status": "planned", "completionType": "", "completionSummary": "", "targetMinutes": 45, "position": 3, "workspace": null} + ], + "progress": {"total": 3, "completed": 1, "inProgress": 1, "planned": 1, "skipped": 0}, "error": "" + }, + "auth_cases": [ + {"name": "service", "status": 200}, + {"name": "legacy", "status": 200}, + {"name": "mixed", "status": 400}, + {"name": "missing", "status": 401}, + {"name": "wrong-scope", "status": 401}, + {"name": "wrong-audience", "status": 401}, + {"name": "wrong-issuer", "status": 401}, + {"name": "wrong-client", "status": 401}, + {"name": "non-service", "status": 401}, + {"name": "wrong-subject", "status": 401}, + {"name": "expired", "status": 401}, + {"name": "wrong-signature", "status": 401}, + {"name": "wrong-token-use", "status": 401}, + {"name": "denied-receiver", "status": 403}, + {"name": "service-disabled", "status": 503} + ] +} diff --git a/contracts/fastresearch-today/v1/request.schema.json b/contracts/fastresearch-today/v1/request.schema.json new file mode 100644 index 0000000..6fe8ae9 --- /dev/null +++ b/contracts/fastresearch-today/v1/request.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FastResearch today wire request", + "type": "object", + "required": ["receiver_refs"], + "properties": {"receiver_refs": {"type": "array", "items": {"type": "string"}}}, + "description": "Receiver trims whitespace, drops empty/duplicate values and takes the first 200 unique refs. At least one normalized ref is required. Unknown properties are ignored." +} diff --git a/go.mod b/go.mod index aa93006..b96062b 100644 --- a/go.mod +++ b/go.mod @@ -19,12 +19,12 @@ require ( ) require ( - github.com/FastR-D/FastCAS/sdk/go v0.1.0 + github.com/FastR-D/FastCAS/sdk/go v0.1.1 github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/coreos/go-oidc/v3 v3.21.0 + github.com/coreos/go-oidc/v3 v3.21.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 @@ -61,5 +61,3 @@ require ( golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) - -replace github.com/FastR-D/FastCAS/sdk/go => ../FastCAS/sdk/go diff --git a/go.sum b/go.sum index 38beaa3..9813975 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/FastR-D/FastCAS/sdk/go v0.1.1 h1:m25Cswwcmb74yPVtgw8qwcdDNU612NB7F7pbqO9cwuo= +github.com/FastR-D/FastCAS/sdk/go v0.1.1/go.mod h1:ycKZhIg/RA27BeMYvJ2VduqGbwgSnlbotC3TNdOXaDQ= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -83,20 +85,20 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= @@ -124,9 +126,9 @@ golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/httpapi/fastresearch_contract_test.go b/internal/httpapi/fastresearch_contract_test.go new file mode 100644 index 0000000..65554d6 --- /dev/null +++ b/internal/httpapi/fastresearch_contract_test.go @@ -0,0 +1,232 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/FastR-D/FastTask/internal/application" + "github.com/FastR-D/FastTask/internal/config" + "github.com/FastR-D/FastTask/internal/persistence" + platformauth "github.com/FastR-D/FastTask/internal/platform/auth" + "github.com/go-jose/go-jose/v4" + "github.com/golang-jwt/jwt/v5" +) + +func TestFastResearchTodayWireContract(t *testing.T) { + var fixture struct { + Request json.RawMessage `json:"request"` + Response map[string]any `json:"response"` + AuthCases []struct { + Name string `json:"name"` + Status int `json:"status"` + } `json:"auth_cases"` + } + raw, err := os.ReadFile("../../contracts/fastresearch-today/v1/cases.json") + if err != nil { + t.Fatal(err) + } + if err = json.Unmarshal([]byte(strings.ReplaceAll(string(raw), "$today", persistence.Now().UTC().Format("2006-01-02"))), &fixture); err != nil { + t.Fatal(err) + } + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + var issuer *httptest.Server + var grants atomic.Int32 + token := func(name string) string { + claims := jwt.MapClaims{"iss": issuer.URL, "sub": "research-service", "aud": []string{"fasttask-api"}, "client_id": "research-service", "scope": "integrations:fastresearch", "service": true, "token_use": "access", "exp": time.Now().Add(time.Minute).Unix(), "iat": time.Now().Unix()} + signingKey := key + switch name { + case "wrong-scope": + claims["scope"] = "openid" + case "wrong-audience": + claims["aud"] = []string{"research-api"} + case "wrong-issuer": + claims["iss"] = "https://other.invalid" + case "wrong-client": + claims["sub"] = "other" + claims["client_id"] = "other" + case "non-service": + claims["service"] = false + case "wrong-subject": + claims["sub"] = "user-subject" + case "expired": + claims["exp"] = time.Now().Add(-time.Minute).Unix() + case "wrong-signature": + signingKey = otherKey + case "wrong-token-use": + claims["token_use"] = "id" + } + value := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + value.Header["kid"] = "fixture" + signed, signErr := value.SignedString(signingKey) + if signErr != nil { + t.Fatal(signErr) + } + return signed + } + issuer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/.well-known/openid-configuration": + json.NewEncoder(w).Encode(map[string]any{"issuer": issuer.URL, "authorization_endpoint": issuer.URL + "/authorize", "token_endpoint": issuer.URL + "/token", "jwks_uri": issuer.URL + "/jwks", "userinfo_endpoint": issuer.URL + "/userinfo", "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, "token_endpoint_auth_methods_supported": []string{"client_secret_basic"}}) + case "/jwks": + json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "fixture", Algorithm: "RS256", Use: "sig"}}}) + case "/token": + grants.Add(1) + id, secret, ok := r.BasicAuth() + id, _ = url.QueryUnescape(id) + secret, _ = url.QueryUnescape(secret) + if err := r.ParseForm(); err != nil || !ok || id != "research-service" || secret != "disposable-secret" || r.Form.Get("grant_type") != "client_credentials" || r.Form.Get("scope") != "integrations:fastresearch" { + w.WriteHeader(401) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid_client"}) + return + } + json.NewEncoder(w).Encode(map[string]any{"access_token": token("service"), "token_type": "Bearer", "expires_in": 60}) + default: + http.NotFound(w, r) + } + })) + defer issuer.Close() + store, err := persistence.Open(filepath.Join(t.TempDir(), "today.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err = store.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + seedFastResearchTodayContract(t, store) + cfg := config.Config{PublicURL: "http://localhost", JWTSecret: strings.Repeat("s", 40), WebDist: t.TempDir(), FastCASServiceEnabled: true, FastCASServiceIssuer: issuer.URL, FastCASServiceClientID: "task", FastCASServiceAudience: "fasttask-api", FastCASServiceLoopbackHTTP: true, FastCASServiceAllowedClientIDs: []string{"research-service"}, FastCASServiceAllowedReceiverRefs: []string{"account-a", "missing-account"}, FastResearchLegacyIngestKeyEnabled: true, FastResearchIngestKey: "disposable-key"} + server := New(application.New(store), platformauth.New(store, cfg), cfg, nil) + for _, tc := range fixture.AuthCases { + t.Run(tc.Name, func(t *testing.T) { + body := string(fixture.Request) + if tc.Name == "denied-receiver" { + body = `{"receiver_refs":["outside"]}` + } + request := httptest.NewRequest("POST", "/api/v1/integrations/fastresearch/today", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + if tc.Name != "legacy" && tc.Name != "missing" { + request.Header.Set("Authorization", "Bearer "+token(tc.Name)) + } + if tc.Name == "legacy" || tc.Name == "mixed" { + request.Header.Set("X-API-Key", cfg.FastResearchIngestKey) + } + server.cfg.FastCASServiceEnabled = tc.Name != "service-disabled" + if tc.Name == "service-disabled" { + server.auth = platformauth.New(store, server.cfg) + } + defer func() { server.cfg = cfg; server.auth = platformauth.New(store, cfg) }() + response := httptest.NewRecorder() + server.Engine.ServeHTTP(response, request) + if response.Code != tc.Status { + t.Fatalf("status=%d want=%d: %s", response.Code, tc.Status, response.Body.String()) + } + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatal("missing no-store") + } + if tc.Status == 200 { + var actual map[string]any + if err := json.Unmarshal(response.Body.Bytes(), &actual); err != nil || !reflect.DeepEqual(actual, fixture.Response) { + t.Fatalf("response mismatch: %s (%v)", response.Body.String(), err) + } + } + }) + } + for _, body := range []string{`{"receiver_refs":[]}`, `{"receiver_refs":[" "]}`, `{"receiver_refs":42}`} { + r := httptest.NewRequest("POST", "/api/v1/integrations/fastresearch/today", strings.NewReader(body)) + r.Header.Set("X-API-Key", cfg.FastResearchIngestKey) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + server.Engine.ServeHTTP(w, r) + if w.Code != 400 { + t.Fatalf("invalid refs status=%d", w.Code) + } + } + t.Run("legacy-outside-service-allowlist", func(t *testing.T) { + request := httptest.NewRequest("POST", "/api/v1/integrations/fastresearch/today", strings.NewReader(`{"receiver_refs":["outside"]}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-API-Key", cfg.FastResearchIngestKey) + response := httptest.NewRecorder() + server.Engine.ServeHTTP(response, request) + var actual struct { + Members []fastResearchTodayMember `json:"members"` + } + if err := json.Unmarshal(response.Body.Bytes(), &actual); err != nil || response.Code != 200 || len(actual.Members) != 1 || !actual.Members[0].Found || actual.Members[0].UserID != "task-user-outside" { + t.Fatalf("legacy authorization changed: %d %s (%v)", response.Code, response.Body.String(), err) + } + }) + // Optional local topology joins the real Go receiver to the actual Research + // production client. Product-only CI still runs all wire cases above. + if probe := os.Getenv("FASTRESEARCH_CONTRACT_PROBE"); probe != "" { + task := httptest.NewServer(server.Engine) + defer task.Close() + command := exec.Command("node", probe) + command.Env = append(os.Environ(), "FASTTASK_URL="+task.URL, "FASTTASK_SERVICE_ISSUER="+issuer.URL, "FASTTASK_SERVICE_CLIENT_ID=research-service", "FASTTASK_SERVICE_CLIENT_SECRET=disposable-secret", "FASTTASK_SERVICE_ALLOW_LOOPBACK_HTTP=true", "FASTTASK_API_KEY=disposable-key") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("Research consumer: %v\n%s", err, output) + } else { + t.Log(string(output)) + } + if grants.Load() != 2 { + t.Fatalf("want one grant per Research client (probe and production server), got %d", grants.Load()) + } + } +} + +func seedFastResearchTodayContract(t *testing.T, store *persistence.Store) { + t.Helper() + // Seed domain rows, never a pre-serialized HTTP response or a mocked plan + // projection. The real receiver must resolve the mapping and refresh the plan. + stamp := time.Date(2026, time.November, 10, 0, 0, 0, 0, time.UTC) + user := persistence.User{ID: "task-user-a", Identifier: "today-member", PasswordHash: "unused", DisplayName: "Research member", Timezone: "UTC", Locale: "en", Role: "member", Status: "active", Revision: 1, CreatedAt: stamp, UpdatedAt: stamp} + outside := user + outside.ID, outside.Identifier, outside.DisplayName = "task-user-outside", "outside-member", "Outside member" + goal := persistence.Goal{ID: "today-goal-a", UserID: user.ID, Title: "Research evidence", SuccessCriteria: "Review findings", Status: "active", Revision: 1, CreatedAt: stamp, UpdatedAt: stamp} + plan := persistence.DailyPlan{ID: "today-plan-a", UserID: user.ID, LocalDate: persistence.Now().UTC().Format("2006-01-02"), Timezone: "UTC", Status: "active", AlgorithmVersion: "deterministic-v3", CurrentRevision: 3, Revision: 3, FrozenAt: &stamp, CreatedAt: stamp, UpdatedAt: stamp} + for _, row := range []any{ + &user, &outside, + &persistence.FastResearchAccount{AccountID: "account-a", UserID: user.ID, KeyID: "fixture-key", Person: user.DisplayName, CreatedAt: stamp, UpdatedAt: stamp}, + &persistence.FastResearchAccount{AccountID: "outside", UserID: outside.ID, KeyID: "outside-key", Person: outside.DisplayName, CreatedAt: stamp, UpdatedAt: stamp}, + &goal, &plan, + } { + if err := store.DB.Create(row).Error; err != nil { + t.Fatal(err) + } + } + for index, row := range []struct { + id, title, commitment, minimum, status, completionType, completionSummary string + minutes int + }{ + {"read", "fastread: Read evidence", "Extract two claims", "Read the abstract", "in_progress", "", "", 30}, + {"write", "fastwrite: Draft findings", "Save a findings draft", "Write one paragraph", "satisfied", "result", "Draft saved", 45}, + {"review", "Review evidence", "Check the claim list", "Open the checklist", "planned", "", "", 45}, + } { + task := persistence.Task{ID: "task-" + row.id, UserID: user.ID, GoalID: goal.ID, Type: "action", Title: row.title, Status: "ready", SuccessCriteria: row.commitment, MinimumAction: row.minimum, EstimateMinutes: row.minutes, Position: index + 1, Revision: 1, CreatedAt: stamp, UpdatedAt: stamp} + item := persistence.DailyPlanItem{ID: "item-" + row.id, LogicalID: "item-" + row.id, UserID: user.ID, PlanID: plan.ID, TaskID: &task.ID, Kind: "core", Title: row.title, Commitment: row.commitment, MinimumAction: row.minimum, AllowedTypes: "result,step,time,minimum_action", Status: row.status, CompletionType: row.completionType, CompletionSummary: row.completionSummary, PlanRevision: 3, TargetMinutes: row.minutes, Position: index + 1, Revision: 1, CreatedAt: stamp, UpdatedAt: stamp} + for _, model := range []any{&task, &item} { + if err := store.DB.Create(model).Error; err != nil { + t.Fatal(err) + } + } + } +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..12b46b8 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1 @@ +/test-results/ diff --git a/web/index.html b/web/index.html index fe139ac..8c6a4a1 100644 --- a/web/index.html +++ b/web/index.html @@ -1,10 +1,10 @@ - + - + diff --git a/web/package-lock.json b/web/package-lock.json index 1b357a6..be2163e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "@ai-sdk/openai-compatible": "3.0.53", "@ai-sdk/provider": "4.0.17", "@assistant-ui/react": "^0.15.21", + "@fastrd/ui-foundation": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", "@fontsource/material-icons": "^5.3.0", "@vitejs/plugin-react": "latest", "assistant-stream": "^0.3.44", @@ -25,6 +26,7 @@ "vite": "latest" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@types/node": "26.6.2", "@types/react": "latest", "@types/react-dom": "latest", @@ -1767,6 +1769,12 @@ "node": ">=6.9.0" } }, + "node_modules/@fastrd/ui-foundation": { + "version": "0.1.0", + "resolved": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", + "integrity": "sha512-5O4s9BXXHXWk7GbzPfMYX6HjNi0DKryfWLRPYMZINNb4ew3arjCKbJvxFaSvqcCvhHxmrf8OZDg9GPmfvdnRtw==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -1977,6 +1985,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", @@ -8232,6 +8256,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index 5ee7429..5e9956d 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "test:panel": "node --test tests/panel-*.test.mjs" }, "dependencies": { + "@fastrd/ui-foundation": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", "@ai-sdk/openai-compatible": "3.0.53", "@ai-sdk/provider": "4.0.17", "@assistant-ui/react": "^0.15.21", @@ -29,6 +30,7 @@ "vite": "latest" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@types/node": "26.6.2", "@types/react": "latest", "@types/react-dom": "latest", diff --git a/web/public/theme-init.js b/web/public/theme-init.js deleted file mode 100644 index 90882ee..0000000 --- a/web/public/theme-init.js +++ /dev/null @@ -1,25 +0,0 @@ -// Apply the saved choices before the first paint so neither colour mode nor visual theme flashes. -// Keep the keys, class names and colours in sync with src/theme.ts. -;(() => { - try { - let stored - try { stored = localStorage.getItem('fasttask.theme') } catch {} - const preference = stored === 'light' || stored === 'dark' ? stored : 'system' - const dark = preference === 'dark' || - (preference === 'system' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) - const root = document.documentElement - let visualTheme - try { visualTheme = localStorage.getItem('fasttask.visual-theme') } catch {} - if (visualTheme !== 'classic' && visualTheme !== 'workbench') visualTheme = 'workbench' - if (root.dataset) root.dataset.ftTheme = visualTheme - root.classList.remove('mdui-theme-auto', 'mdui-theme-light', 'mdui-theme-dark') - root.classList.add(preference === 'system' ? 'mdui-theme-auto' : `mdui-theme-${preference}`) - const metas = [...document.querySelectorAll('meta[name="theme-color"]')] - const target = metas.shift() || document.head.appendChild(Object.assign(document.createElement('meta'), { name: 'theme-color' })) - target.removeAttribute('media') - target.setAttribute('content', dark ? '#10161e' : '#f7f8fa') - metas.forEach(meta => meta.remove()) - } catch { - // Storage or matchMedia unavailable: the default mdui-theme-auto class still follows the system. - } -})() diff --git a/web/src/Gantt.tsx b/web/src/GanttView.tsx similarity index 100% rename from web/src/Gantt.tsx rename to web/src/GanttView.tsx diff --git a/web/src/GoalManager.tsx b/web/src/GoalManager.tsx index 98a6092..306e066 100644 --- a/web/src/GoalManager.tsx +++ b/web/src/GoalManager.tsx @@ -4,7 +4,7 @@ import { createCreationAttempt } from './creation-attempt' import { SESSION_OWNER_CHANGED } from './session-owner' import { ProposalSummary } from './agent/tools/ProposalApproval' import { GoalMapView } from './Lens' -import { GanttView } from './Gantt' +import { GanttView } from './GanttView' import { buildGanttRows, plannedDateError } from './gantt' import { fieldValue, useMduiEvent } from './mdui-react' import { waitJob } from './jobs' diff --git a/web/src/ThemeControl.tsx b/web/src/ThemeControl.tsx index 726ffc4..3fdc64f 100644 --- a/web/src/ThemeControl.tsx +++ b/web/src/ThemeControl.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react' import { fieldValue } from './mdui-react' import { - announceThemeChange, announceVisualThemeChange, isVisualTheme, isThemePreference, @@ -56,7 +55,6 @@ function useThemePreference(): [ThemePreference, (value: ThemePreference) => voi function choose(value: ThemePreference) { setThemePreference(value) setPreference(value) - announceThemeChange(value) } return [preference, choose] } diff --git a/web/src/main.tsx b/web/src/main.tsx index d883e6c..fad8cac 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,8 @@ import React from 'react' import ReactDOM from 'react-dom/client' import './mdui' +import '@fastrd/ui-foundation/tokens.css' +import '@fastrd/ui-foundation/mdui.css' import { App } from './App' import './styles.css' import './workbench-theme.css' diff --git a/web/src/styles.css b/web/src/styles.css index 217090f..ad32afc 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -12,87 +12,36 @@ --ft-space-5:1.5rem; /* 24px — between groups */ --ft-space-6:2rem; /* 32px — between sections */ } -/* FastWrite's blue/slate palette, mapped onto MDUI's existing theme contract. - Keep the semantic error and completion colours independent of the brand. */ -:root{ - --mdui-color-primary-light:21,95,184; - --mdui-color-on-primary-light:255,255,255; - --mdui-color-primary-container-light:219,234,254; - --mdui-color-on-primary-container-light:17,61,112; - --mdui-color-inverse-primary-light:100,168,238; - --mdui-color-secondary-light:53,64,82; - --mdui-color-on-secondary-light:255,255,255; - --mdui-color-secondary-container-light:219,234,254; - --mdui-color-on-secondary-container-light:17,61,112; - --mdui-color-background-light:247,248,250; - --mdui-color-on-background-light:21,28,40; - --mdui-color-surface-light:255,255,255; - --mdui-color-surface-dim-light:223,227,232; - --mdui-color-surface-bright-light:255,255,255; - --mdui-color-surface-container-lowest-light:255,255,255; - --mdui-color-surface-container-low-light:252,252,253; - --mdui-color-surface-container-light:239,241,244; - --mdui-color-surface-container-high-light:223,227,232; - --mdui-color-surface-container-highest-light:203,209,217; - --mdui-color-surface-variant-light:239,241,244; - --mdui-color-on-surface-light:21,28,40; - --mdui-color-on-surface-variant-light:75,85,101; - --mdui-color-inverse-surface-light:35,45,61; - --mdui-color-inverse-on-surface-light:244,247,250; - --mdui-color-outline-light:146,155,168; - --mdui-color-outline-variant-light:223,227,232; - --mdui-color-surface-tint-color-light:21,95,184; - --mdui-color-primary-dark:100,168,238; - --mdui-color-on-primary-dark:16,36,61; - --mdui-color-primary-container-dark:22,56,95; - --mdui-color-on-primary-container-dark:183,220,255; - --mdui-color-inverse-primary-dark:21,95,184; - --mdui-color-secondary-dark:182,193,206; - --mdui-color-on-secondary-dark:35,45,61; - --mdui-color-secondary-container-dark:22,56,95; - --mdui-color-on-secondary-container-dark:183,220,255; - --mdui-color-background-dark:16,22,30; - --mdui-color-on-background-dark:244,247,250; - --mdui-color-surface-dark:23,30,39; - --mdui-color-surface-dim-dark:17,22,30; - --mdui-color-surface-bright-dark:42,53,67; - --mdui-color-surface-container-lowest-dark:17,22,30; - --mdui-color-surface-container-low-dark:21,27,36; - --mdui-color-surface-container-dark:28,37,48; - --mdui-color-surface-container-high-dark:42,53,67; - --mdui-color-surface-container-highest-dark:59,72,89; - --mdui-color-surface-variant-dark:42,53,67; - --mdui-color-on-surface-dark:244,247,250; - --mdui-color-on-surface-variant-dark:182,193,206; - --mdui-color-inverse-surface-dark:244,247,250; - --mdui-color-inverse-on-surface-dark:21,28,40; - --mdui-color-outline-dark:130,144,163; - --mdui-color-outline-variant-dark:59,72,89; - --mdui-color-surface-tint-color-dark:100,168,238; - --ft-color-action-fill-light:21,95,184; - --ft-color-action-hover-light:16,74,145; - --ft-color-focus-light:40,120,208; - --ft-color-action-fill-dark:14,99,156; - --ft-color-action-hover-dark:17,119,187; - --ft-color-focus-dark:91,163,234; - --ft-color-on-action:255,255,255; -} -:root,.mdui-theme-light{ - --ft-color-action-fill:var(--ft-color-action-fill-light); - --ft-color-action-hover:var(--ft-color-action-hover-light); - --ft-color-focus:var(--ft-color-focus-light); -} -.mdui-theme-dark{ - --ft-color-action-fill:var(--ft-color-action-fill-dark); - --ft-color-action-hover:var(--ft-color-action-hover-dark); - --ft-color-focus:var(--ft-color-focus-dark); -} -@media(prefers-color-scheme:dark){ - .mdui-theme-auto{ - --ft-color-action-fill:var(--ft-color-action-fill-dark); - --ft-color-action-hover:var(--ft-color-action-hover-dark); - --ft-color-focus:var(--ft-color-focus-dark); - } +/* Remaining MDUI roles used by classic/workbench. Palette and mode come from foundation. */ +.fastui-mdui{ + /* MDUI uses primary for text too; filled actions keep the separate primary-fill role below. */ + --mdui-color-primary:var(--fastui-focus-rgb); + --mdui-color-secondary:var(--fastui-secondary-rgb); + --mdui-color-on-secondary:var(--fastui-surface-rgb); + --mdui-color-secondary-container:var(--fastui-selected-rgb); + --mdui-color-on-secondary-container:var(--fastui-text-rgb); + --mdui-color-tertiary:var(--fastui-success-rgb); + --mdui-color-on-tertiary:var(--fastui-surface-rgb); + --mdui-color-tertiary-container:var(--fastui-raised-rgb); + --mdui-color-on-tertiary-container:var(--fastui-success-rgb); + --mdui-color-on-error:var(--fastui-surface-rgb); + --mdui-color-error-container:var(--fastui-raised-rgb); + --mdui-color-on-error-container:var(--fastui-danger-rgb); + --mdui-color-surface-dim:var(--fastui-workspace-rgb); + --mdui-color-surface-bright:var(--fastui-surface-rgb); + --mdui-color-surface-container-lowest:var(--fastui-page-rgb); + --mdui-color-surface-container-low:var(--fastui-surface-rgb); + --mdui-color-surface-container-high:var(--fastui-border-rgb); + --mdui-color-surface-container-highest:var(--fastui-border-strong-rgb); + --mdui-color-surface-variant:var(--fastui-raised-rgb); + --mdui-color-inverse-surface:var(--fastui-text-rgb); + --mdui-color-inverse-on-surface:var(--fastui-surface-rgb); + --mdui-color-inverse-primary:var(--fastui-focus-rgb); + --mdui-color-surface-tint-color:var(--fastui-primary-rgb); + --ft-color-action-fill:var(--fastui-primary-rgb); + --ft-color-action-hover:var(--fastui-primary-hover-rgb); + --ft-color-focus:var(--fastui-focus-rgb); + --ft-color-on-action:var(--fastui-on-primary-rgb); } /* FastWrite uses a separate dark fill so white button labels stay legible, while links and focus rings can use the brighter blue text palette. */ diff --git a/web/src/theme-init.ts b/web/src/theme-init.ts new file mode 100644 index 0000000..56bf14e --- /dev/null +++ b/web/src/theme-init.ts @@ -0,0 +1,7 @@ +import { initColorMode } from '@fastrd/ui-foundation' +import { applyVisualTheme, syncTheme, THEME_STORAGE_KEY } from './theme' + +// Bundled as a blocking classic script: the installed foundation controller exists before paint. +window.fastTaskColorMode = initColorMode({ storageKey: THEME_STORAGE_KEY }) +applyVisualTheme() +window.fastTaskColorMode.subscribe(syncTheme) diff --git a/web/src/theme.ts b/web/src/theme.ts index e202c70..3aeb188 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -1,201 +1,80 @@ -// Theme preference for the web client. -// -// The app follows the operating system by default. A pin to light or dark is expressed the way -// MDUI expects it: a class on that rebinds its semantic colour tokens. The boot script in -// index.html applies the same rule before React mounts, so a pinned light theme never starts with a -// dark first paint. Keep the storage key, the class names and the palette colours in sync with it. +import type { initColorMode } from '@fastrd/ui-foundation' export type ThemePreference = 'system' | 'light' | 'dark' export type ResolvedTheme = 'light' | 'dark' export type VisualTheme = 'workbench' | 'classic' - export const THEME_STORAGE_KEY = 'fasttask.theme' export const VISUAL_THEME_STORAGE_KEY = 'fasttask.visual-theme' - -// ThemeControl listens for this event so the rail and the drawer controls cannot disagree after one -// of them changes the setting. It also carries the new value to every mounted control. export const THEME_CHANGE_EVENT = 'fasttask:theme' export const VISUAL_THEME_CHANGE_EVENT = 'fasttask:visual-theme' -const PREFERENCES: readonly ThemePreference[] = ['system', 'light', 'dark'] -const VISUAL_THEMES: readonly VisualTheme[] = ['workbench', 'classic'] -const THEME_CLASSES = ['mdui-theme-auto', 'mdui-theme-light', 'mdui-theme-dark'] - -// Background colours of the two palettes in styles.css. The browser paints its address bar and the -// installed app's status bar with this value, so it has to follow the resolved theme, not the stored -// preference: "system + dark OS" is a dark chrome and a dark page. -export const THEME_COLORS: Record = { light: '#f7f8fa', dark: '#10161e' } +declare global { + interface Window { fastTaskColorMode: ReturnType } +} -/** isThemePreference narrows a value that came out of storage or an event payload. */ export function isThemePreference(value: unknown): value is ThemePreference { - return PREFERENCES.includes(value as ThemePreference) + return value === 'light' || value === 'dark' || value === 'system' } - -/** isVisualTheme narrows a persisted or event-provided visual theme. */ export function isVisualTheme(value: unknown): value is VisualTheme { - return VISUAL_THEMES.includes(value as VisualTheme) + return value === 'workbench' || value === 'classic' } - -/** The new workbench visual language is the default. Classic remains available as an explicit choice. */ +export function readThemePreference(): ThemePreference { return window.fastTaskColorMode.mode } export function readVisualTheme(storage: Storage | null = safeStorage()): VisualTheme { try { - const stored = storage?.getItem(VISUAL_THEME_STORAGE_KEY) - return isVisualTheme(stored) ? stored : 'workbench' - } catch { - return 'workbench' - } + const value = storage?.getItem(VISUAL_THEME_STORAGE_KEY) + return isVisualTheme(value) ? value : 'workbench' + } catch { return 'workbench' } } - export function writeVisualTheme(value: VisualTheme, storage: Storage | null = safeStorage()): void { - if (!storage || !isVisualTheme(value)) return - try { storage.setItem(VISUAL_THEME_STORAGE_KEY, value) } catch { - // The theme still applies for this session when storage is blocked. - } -} - -/** readThemePreference treats a missing or unrecognizable value as "follow the system". A value this - * module did not write is not a preference. */ -export function readThemePreference(storage: Storage | null = safeStorage()): ThemePreference { - try { - const stored = storage?.getItem(THEME_STORAGE_KEY) - return isThemePreference(stored) ? stored : 'system' - } catch { - return 'system' - } -} - -/** writeThemePreference stores the choice. "system" removes the key so the default is represented by - * the absence of a setting, matching the harness mode preference. */ -export function writeThemePreference(value: ThemePreference, storage: Storage | null = safeStorage()): void { - if (!storage) return - try { - if (value === 'system') storage.removeItem(THEME_STORAGE_KEY) - else if (isThemePreference(value)) storage.setItem(THEME_STORAGE_KEY, value) - } catch { - // Blocked storage costs the choice on the next load. The theme still applies to this session. - } -} - -/** prefersDarkTheme reads the operating system preference. A missing matchMedia is treated as light, - * which is also what MDUI's default palette assumes. */ -export function prefersDarkTheme(doc: Document | undefined = globalThis.document): boolean { - return Boolean(doc?.defaultView?.matchMedia?.('(prefers-color-scheme: dark)').matches) -} - -export function resolveTheme(preference: ThemePreference, prefersDark: boolean): ResolvedTheme { - return preference === 'system' ? (prefersDark ? 'dark' : 'light') : preference + if (!isVisualTheme(value)) return + try { storage?.setItem(VISUAL_THEME_STORAGE_KEY, value) } catch { /* Session-only preference. */ } } - -/** applyTheme sets the MDUI theme class and the browser chrome colour, and returns the palette that - * is actually on screen. */ -export function applyTheme( - preference: ThemePreference = readThemePreference(), - doc: Document | undefined = globalThis.document, - prefersDark: boolean = prefersDarkTheme(doc), -): ResolvedTheme { - const resolved = resolveTheme(preference, prefersDark) - if (!doc) return resolved - const root = doc.documentElement - root.classList.remove(...THEME_CLASSES) - root.classList.add(preference === 'system' ? 'mdui-theme-auto' : `mdui-theme-${preference}`) - applyThemeColor(doc, resolved) - return resolved +export function applyVisualTheme(value: VisualTheme = readVisualTheme(), doc: Document = document): VisualTheme { + doc.documentElement.dataset.ftTheme = value + return value } - -/** applyVisualTheme changes only the visual language, leaving light/dark mode untouched. */ -export function applyVisualTheme( - visualTheme: VisualTheme = readVisualTheme(), - doc: Document | undefined = globalThis.document, -): VisualTheme { - if (!doc) return visualTheme - doc.documentElement.dataset.ftTheme = visualTheme - return visualTheme +export function setVisualTheme(value: VisualTheme, doc: Document = document, storage: Storage | null = safeStorage()): VisualTheme { + writeVisualTheme(value, storage) + return applyVisualTheme(value, doc) } -/** setThemePreference persists the choice and applies it immediately. */ -export function setThemePreference( - value: ThemePreference, - doc: Document | undefined = globalThis.document, - storage: Storage | null = safeStorage(), -): ResolvedTheme { - writeThemePreference(value, storage) - return applyTheme(value, doc) +// MDUI classes and PWA chrome are host integration; foundation owns resolution and listeners. +export function syncTheme(): void { + const colorMode = window.fastTaskColorMode + const root = document.documentElement + root.classList.remove('mdui-theme-auto', 'mdui-theme-light', 'mdui-theme-dark') + root.classList.add(colorMode.mode === 'system' ? 'mdui-theme-auto' : `mdui-theme-${colorMode.resolved}`) + root.style.colorScheme = colorMode.resolved + const metas = [...document.querySelectorAll('meta[name="theme-color"]')] + const initial = metas.find(meta => meta.media.includes(colorMode.resolved))?.content + const target = metas.shift() ?? document.head.appendChild(Object.assign(document.createElement('meta'), { name: 'theme-color' })) + target.removeAttribute('media') + target.content = getComputedStyle(root).getPropertyValue('--fastui-page').trim() || initial || target.content + for (const extra of metas) extra.remove() + announceThemeChange(colorMode.mode) } -export function setVisualTheme( - value: VisualTheme, - doc: Document | undefined = globalThis.document, - storage: Storage | null = safeStorage(), -): VisualTheme { - writeVisualTheme(value, storage) - return applyVisualTheme(value, doc) +export function setThemePreference(value: ThemePreference): ResolvedTheme { + const colorMode = window.fastTaskColorMode + colorMode.setMode(value) + return colorMode.resolved } - -/** announceThemeChange tells the other mounted controls about a change made by one of them. */ export function announceThemeChange(value: ThemePreference): void { - if (typeof window === 'undefined') return - window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: value })) + window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: value })) } - export function announceVisualThemeChange(value: VisualTheme): void { - if (typeof window === 'undefined') return - window.dispatchEvent(new CustomEvent(VISUAL_THEME_CHANGE_EVENT, { detail: value })) + window.dispatchEvent(new CustomEvent(VISUAL_THEME_CHANGE_EVENT, { detail: value })) } - -/** watchTheme keeps the browser chrome in step with the system while the preference is "system", - * and picks up a choice made in another tab. Returns the cleanup for the effect that owns it. */ export function watchTheme(): () => void { - if (typeof window === 'undefined') return () => undefined - applyTheme() + syncTheme() applyVisualTheme() - const media = window.matchMedia?.('(prefers-color-scheme: dark)') - const onSystemChange = () => { - // The applied class also works when storage is blocked and the choice only lasts this session. - if (!document.documentElement.classList.contains('mdui-theme-auto')) return - applyTheme('system') - announceThemeChange('system') - } const onStorage = (event: StorageEvent) => { - if (event.key !== null && event.key !== THEME_STORAGE_KEY && event.key !== VISUAL_THEME_STORAGE_KEY) return - if (event.key === null || event.key === VISUAL_THEME_STORAGE_KEY) { - const visualTheme = readVisualTheme() - applyVisualTheme(visualTheme) - announceVisualThemeChange(visualTheme) - if (event.key !== null) return - } - const preference = readThemePreference() - applyTheme(preference) - announceThemeChange(preference) + if (event.key !== null && event.key !== VISUAL_THEME_STORAGE_KEY) return + announceVisualThemeChange(applyVisualTheme()) } - media?.addEventListener?.('change', onSystemChange) window.addEventListener('storage', onStorage) - return () => { - media?.removeEventListener?.('change', onSystemChange) - window.removeEventListener('storage', onStorage) - } -} - -function applyThemeColor(doc: Document, resolved: ResolvedTheme): void { - const color = THEME_COLORS[resolved] - const metas = Array.from(doc.querySelectorAll('meta[name="theme-color"]')) - const target = metas.shift() ?? doc.createElement('meta') - if (!target.parentNode) { - target.setAttribute('name', 'theme-color') - doc.head.appendChild(target) - } - target.removeAttribute('media') - target.setAttribute('content', color) - // index.html ships one meta per system palette so the no-script fallback stays honest. Once the - // resolved theme is known, a single meta is the only one that can be correct. - for (const extra of metas) extra.remove() + return () => window.removeEventListener('storage', onStorage) } - function safeStorage(): Storage | null { - try { - const storage = globalThis.localStorage - storage?.getItem(THEME_STORAGE_KEY) - return storage ?? null - } catch { - return null - } + try { return localStorage } catch { return null } } diff --git a/web/src/workbench-theme.css b/web/src/workbench-theme.css index e352242..28c69bf 100644 --- a/web/src/workbench-theme.css +++ b/web/src/workbench-theme.css @@ -1,6 +1,6 @@ /* Classic stays in styles.css. This layer changes presentation without remounting business views. */ html[data-ft-theme='workbench'] { - --ft-wb-canvas: #eef0f3; + --ft-wb-canvas: var(--fastui-workspace); --ft-wb-surface: rgb(var(--mdui-color-surface)); --ft-wb-soft: rgb(var(--mdui-color-surface-container-low)); --ft-wb-border: rgb(var(--mdui-color-outline-variant)); @@ -11,10 +11,6 @@ html[data-ft-theme='workbench'] { --ft-wb-selected: rgb(var(--mdui-color-primary-container)); --ft-wb-radius: 6px; } -html[data-ft-theme='workbench'].mdui-theme-dark { --ft-wb-canvas:#10161e; } -@media(prefers-color-scheme:dark) { - html[data-ft-theme='workbench'].mdui-theme-auto { --ft-wb-canvas:#10161e; } -} /* MDUI receives RGB tuples through its existing palette; CSS colours above are only for native elements. */ html[data-ft-theme='workbench'] :is(.app-layout,.login-card) { diff --git a/web/tests/theme.test.mjs b/web/tests/theme.test.mjs index 963b24b..afaa1be 100644 --- a/web/tests/theme.test.mjs +++ b/web/tests/theme.test.mjs @@ -1,157 +1,102 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { readFileSync } from 'node:fs'; -import { runInNewContext } from 'node:vm'; -import { - applyTheme, - applyVisualTheme, - readThemePreference, - readVisualTheme, - resolveTheme, - setThemePreference, - setVisualTheme, - writeThemePreference, - writeVisualTheme, -} from '../src/theme.ts'; +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { chromium, expect } from '@playwright/test' +import { preview } from 'vite' +import { mkdir, writeFile } from 'node:fs/promises' -function memoryStorage() { - const values = new Map(); - return { - values, - getItem: key => values.get(key) ?? null, - setItem: (key, value) => values.set(key, String(value)), - removeItem: key => values.delete(key), - }; -} - -function fakeDocument(initialClass = 'mdui-theme-auto') { - const classes = new Set(initialClass.split(' ')); - const metas = []; - const meta = (media, content) => ({ - media, - content, - parentNode: {}, - removed: false, - setAttribute(name, value) { this[name] = value }, - removeAttribute(name) { delete this[name] }, - remove() { this.removed = true }, - }); - metas.push(meta('(prefers-color-scheme: light)', '#f7f8fa'), meta('(prefers-color-scheme: dark)', '#10161e')); - return { - classes, - metas, - documentElement: { - dataset: {}, - classList: { - add: value => classes.add(value), - remove: (...values) => values.forEach(value => classes.delete(value)), - }, - }, - querySelectorAll: () => metas.filter(value => !value.removed), - createElement: () => meta(), - head: { appendChild(node) { metas.push(node); return node } }, - }; -} - -test('the theme choice persists, treats unknown storage as system, and survives blocked storage', () => { - const storage = memoryStorage(); - assert.equal(readThemePreference(storage), 'system'); - writeThemePreference('dark', storage); - assert.equal(readThemePreference(storage), 'dark'); - writeThemePreference('system', storage); - assert.equal(storage.values.size, 0); - - writeThemePreference('sepia', storage); - storage.setItem('fasttask.theme', 'sepia'); - assert.equal(readThemePreference(storage), 'system'); - - const blocked = { - getItem() { throw new Error('blocked') }, - setItem() { throw new Error('blocked') }, - removeItem() { throw new Error('blocked') }, - }; - assert.equal(readThemePreference(blocked), 'system'); - assert.equal(setThemePreference('light', undefined, blocked), 'light'); -}); - -test('system resolves against the operating system while a pin ignores it', () => { - assert.equal(resolveTheme('system', true), 'dark'); - assert.equal(resolveTheme('system', false), 'light'); - assert.equal(resolveTheme('light', true), 'light'); - assert.equal(resolveTheme('dark', false), 'dark'); -}); - -test('visual theme defaults to workbench and persists independently from colour mode', () => { - const storage = memoryStorage(); - assert.equal(readVisualTheme(storage), 'workbench'); - writeVisualTheme('classic', storage); - assert.equal(readVisualTheme(storage), 'classic'); - storage.setItem('fasttask.theme', 'dark'); - assert.equal(readThemePreference(storage), 'dark'); - writeVisualTheme('invalid', storage); - assert.equal(readVisualTheme(storage), 'classic'); - const blocked = {getItem() {throw new Error('blocked')},setItem() {throw new Error('blocked')}}; - const doc = fakeDocument(); - assert.equal(readVisualTheme(blocked), 'workbench'); - setVisualTheme('classic', doc, blocked); - assert.equal(doc.documentElement.dataset.ftTheme, 'classic'); -}); - -test('boot restores both visual themes with each colour mode', () => { - const script = readFileSync(new URL('../public/theme-init.js', import.meta.url), 'utf8'); - for(const visual of ['workbench','classic']) for(const mode of ['light','dark','system']) { - const doc = fakeDocument(); - runInNewContext(script, { - document:doc, - localStorage:{getItem:key=>key==='fasttask.visual-theme'?visual:mode}, - window:{matchMedia:()=>({matches:true})}, - }); - assert.equal(doc.documentElement.dataset.ftTheme, visual); - assert.deepEqual([...doc.classes], [mode==='system'?'mdui-theme-auto':`mdui-theme-${mode}`]); - assert.equal(doc.metas[0].content,mode==='light'?'#f7f8fa':'#10161e'); - } -}); - -test('applying a pin sets the MDUI class and one resolved browser chrome colour', () => { - const doc = fakeDocument(); - assert.equal(applyTheme('light', doc, true), 'light'); - assert.deepEqual([...doc.classes], ['mdui-theme-light']); - assert.equal(doc.metas[0].content, '#f7f8fa'); - assert.equal('media' in doc.metas[0], false); - assert.equal(doc.metas[1].removed, true); - - assert.equal(applyTheme('system', doc, true), 'dark'); - assert.deepEqual([...doc.classes], ['mdui-theme-auto']); - assert.equal(doc.metas[0].content, '#10161e'); -}); - -test('visual theme applies only the independent data attribute', () => { - const doc = fakeDocument(); - assert.equal(applyVisualTheme('classic', doc), 'classic'); - assert.equal(doc.documentElement.dataset.ftTheme, 'classic'); - assert.deepEqual([...doc.classes], ['mdui-theme-auto']); - assert.equal(setVisualTheme('workbench', doc, memoryStorage()), 'workbench'); - assert.equal(doc.documentElement.dataset.ftTheme, 'workbench'); -}); - -test('the blocking boot script restores the theme before React, including when storage is blocked', () => { - const script = readFileSync(new URL('../public/theme-init.js', import.meta.url), 'utf8'); - for (const [stored, darkOS, expectedClass, expectedColor] of [ - ['light', true, 'mdui-theme-light', '#f7f8fa'], - ['dark', false, 'mdui-theme-dark', '#10161e'], - [null, true, 'mdui-theme-auto', '#10161e'], - ['invalid', false, 'mdui-theme-auto', '#f7f8fa'], - ['blocked', true, 'mdui-theme-auto', '#10161e'], - ]) { - const doc = fakeDocument(); - runInNewContext(script, { - document: doc, - localStorage: { getItem() { if (stored === 'blocked') throw new Error('blocked'); return stored } }, - window: { matchMedia: () => ({ matches: darkOS }) }, - }); - assert.deepEqual([...doc.classes], [expectedClass]); - assert.equal(doc.documentElement.dataset.ftTheme, 'workbench'); - assert.equal(doc.metas[0].content, expectedColor); - assert.equal(doc.metas[1].removed, true); +test('production themes preserve the mounted assistant, draft, focus and PWA chrome', { timeout: 120000 }, async () => { + const artifacts = 'test-results/fastui' + await mkdir(artifacts, { recursive: true }) + const server = await preview({ configFile: false, preview: { host: '127.0.0.1', port: 0 } }) + const base = `http://127.0.0.1:${server.httpServer.address().port}` + const browser = await chromium.launch({ headless: true }) + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block', colorScheme: 'light' }) + const errors = [], scenarios = [] + const user = { id: 'theme-user', identifier: 'theme-user', display_name: 'Theme user', timezone: 'UTC', role: 'admin', locale: 'zh-CN', status: 'active', revision: 1, created_at: '2026-11-10T00:00:00Z' } + const token = `header.${Buffer.from(JSON.stringify({ sub: user.id, exp: 4102444800 })).toString('base64url')}.signature` + await context.route('**/api/v1/**', async route => { + const path = new URL(route.request().url()).pathname.replace('/api/v1', '') + if (path === '/panel/profile') return route.fulfill({ status: 404, json: { detail: 'Profile not configured' } }) + let value = { items: [] } + if (path === '/auth/refresh') value = { access_token: token, refresh_token: 'fixture-refresh', user } + else if (path === '/me') value = user + else if (path === '/agent/availability') value = { configured: true, mode: 'server', model_name: 'fixture-model' } + else if (path === '/agent/thread-state') value = { state: null } + else if (path === '/agent/threads') value = { items: [], next_cursor: null } + else if (path === '/daily-plans/current') value = { plan: { id: 'theme-plan', local_date: new Intl.DateTimeFormat('en-CA', { timeZone: 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()), timezone: 'UTC', status: 'draft', revision: 1, current_revision: 1 }, items: [], availability: { status: 'no_tasks', reason: '暂无任务', ready_count: 0, waiting_count: 0, can_replan: true } } + else if (path === '/planning-settings') value = { daily_budget_minutes: 120, primary_goal_id: null } + await route.fulfill({ json: value }) + }) + await context.addInitScript(() => { + if (!localStorage.getItem('fasttask_refresh')) localStorage.setItem('fasttask_refresh', 'fixture-refresh') + }) + try { + const page = await context.newPage() + page.on('pageerror', error => { errors.push(error.message); console.error(error.message) }) + await page.goto(base) + const composer = page.locator('.agent-input') + await expect(composer).toBeVisible() + await composer.fill('保留这段研究草稿') + const original = await composer.elementHandle() + const peer = await context.newPage() + await peer.goto(base) + for (const visual of ['classic', 'workbench']) for (const mode of ['light', 'dark', 'system']) { + await composer.focus() + await peer.evaluate(({ visual, mode }) => { + localStorage.setItem('fasttask.visual-theme', visual) + localStorage.setItem('fasttask.theme', mode) + }, { visual, mode }) + await expect(page.locator('html')).toHaveAttribute('data-ft-theme', visual) + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', mode === 'dark' ? 'dark' : 'light') + await expect(composer).toBeFocused() + await expect(composer).toHaveValue('保留这段研究草稿') + assert.equal(await original.evaluate(el => el === document.querySelector('.agent-input')), true) + assert.equal(await page.evaluate(() => document.querySelector('meta[name="theme-color"]').content === getComputedStyle(document.documentElement).getPropertyValue('--fastui-page').trim()), true) + await page.screenshot({ path: `${artifacts}/${visual}-${mode}.png` }) + } + scenarios.push('classic/workbench × light/dark/system preserve assistant DOM, focus, draft and PWA color') + await page.emulateMedia({ colorScheme: 'dark' }) + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'dark') + await page.emulateMedia({ colorScheme: 'light' }) + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'light') + await page.locator('.workbench-sidebar .theme-menu [slot="trigger"]').click() + await page.locator('.workbench-sidebar .theme-menu mdui-menu-item[value="dark"]').click() + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'dark') + assert.equal(await page.evaluate(() => localStorage.getItem('fasttask.theme')), 'dark') + scenarios.push('system media change and real MDUI theme menu persist existing storage key') + await page.reload() + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'dark') + await expect(composer).toBeVisible() + for (const width of [1440, 1024, 390]) { + await page.setViewportSize({ width, height: 1000 }) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true) + await page.screenshot({ path: `${artifacts}/workbench-${width}.png` }) + } + // Hold the React entry at the network boundary: the blocking foundation boot must stand alone. + for (const [stored, system, resolved] of [['light', 'dark', 'light'], ['dark', 'light', 'dark'], ['invalid', 'dark', 'dark'], ['blocked', 'dark', 'dark']]) { + const cold = await browser.newContext({ colorScheme: system, serviceWorkers: 'block' }) + await cold.addInitScript(stored => { + if (stored === 'blocked') { + Object.defineProperty(Storage.prototype, 'getItem', { value() { throw new DOMException('blocked', 'SecurityError') } }) + } else { + localStorage.setItem('fasttask.theme', stored) + localStorage.setItem('fasttask.visual-theme', 'classic') + } + }, stored) + await cold.route('**/assets/index-*.js', route => route.abort()) + const boot = await cold.newPage() + await boot.goto(base) + await expect(boot.locator('html')).toHaveAttribute('data-fastui-theme', resolved) + await expect(boot.locator('html')).toHaveAttribute('data-ft-theme', stored === 'blocked' ? 'workbench' : 'classic') + assert.equal(await boot.locator('#root').evaluate(el => el.childElementCount), 0) + assert.equal(await boot.evaluate(() => document.querySelector('meta[name="theme-color"]').content === getComputedStyle(document.documentElement).getPropertyValue('--fastui-page').trim()), true) + await cold.close() + } + scenarios.push('blocking pre-React boot honors pin, invalid preference, blocked storage and PWA color') + assert.deepEqual(errors, []) + await writeFile(`${artifacts}/results.json`, JSON.stringify({ status: 'passed', scenarios }, null, 2)) + } finally { + await browser.close() + await new Promise(resolve => server.httpServer.close(resolve)) } -}); +}) diff --git a/web/vite.config.ts b/web/vite.config.ts index 392e895..a7a568a 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:zlib' -import { defineConfig, type Plugin } from 'vite' +import { build, defineConfig, type Plugin } from 'vite' import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' @@ -12,6 +12,30 @@ import { VitePWA } from 'vite-plugin-pwa' // sibling module it does expose. const require = createRequire(import.meta.url) +// Preserve the pre-paint boot boundary while consuming the installed ESM package. +// The app reads the same controller from window; it creates no second mode listener. +function themeBoot(): Plugin { + const source = async () => { + const output = await build({ configFile: false, logLevel: 'silent', + build: { write: false, minify: true, lib: { entry: join(import.meta.dirname, 'src/theme-init.ts'), name: 'FastTaskTheme', formats: ['iife'] } }, + }) + const bundle = Array.isArray(output) ? output[0] : output + if (!('output' in bundle)) throw new Error('Theme boot must produce a bundle') + const chunk = bundle.output.find(item => item.type === 'chunk') + if (!chunk) throw new Error('Theme boot chunk is missing') + return chunk.code + } + return { + name: 'fasttask:theme-boot', + async generateBundle() { this.emitFile({ type: 'asset', fileName: 'theme-init.js', source: await source() }) }, + configureServer(server) { + server.middlewares.use('/theme-init.js', (_req, response, next) => { + void source().then(code => { response.setHeader('Content-Type', 'text/javascript'); response.end(code) }).catch(next) + }) + }, + } +} + function fxCoreWasmPath(): string { return join(dirname(require.resolve('libfx/wasm')), 'fx-core.wasm') } @@ -45,6 +69,7 @@ function selfHostFxCore(): Plugin { export default defineConfig({ plugins: [ react(), + themeBoot(), selfHostFxCore(), // pwa.md §3: an installable, offline-readable PWA. injectManifest (a custom // src/sw.ts) is required because generateSW cannot express the per-user cache From 4dc065c85befdb338a30e54e2a2257eeab5f0923 Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Wed, 11 Nov 2026 07:10:37 +0800 Subject: [PATCH 2/4] fix: prepare browser CI and always release theme test resources --- .github/scripts/ci-node.sh | 6 +++- web/tests/theme-cleanup.test.mjs | 28 ++++++++++++++++ web/tests/theme.test.mjs | 57 ++++++++++++++++++-------------- 3 files changed, 65 insertions(+), 26 deletions(-) create mode 100644 web/tests/theme-cleanup.test.mjs diff --git a/.github/scripts/ci-node.sh b/.github/scripts/ci-node.sh index e1802a4..7a42157 100644 --- a/.github/scripts/ci-node.sh +++ b/.github/scripts/ci-node.sh @@ -1,5 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -npm test npm run build test -f dist/index.html +npx --no-install playwright install --with-deps chromium +CHROME_BIN="$(node --input-type=module -e "import { chromium } from '@playwright/test'; process.stdout.write(chromium.executablePath())")" +export CHROME_BIN +"$CHROME_BIN" --version +npm test diff --git a/web/tests/theme-cleanup.test.mjs b/web/tests/theme-cleanup.test.mjs new file mode 100644 index 0000000..2aeb72f --- /dev/null +++ b/web/tests/theme-cleanup.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { test } from 'node:test' + +test('missing managed browser fails and exits without leaking the theme preview server', () => { + const browsers = mkdtempSync(join(tmpdir(), 'fasttask-empty-browsers-')) + try { + // Execute the unchanged scenario in one Node process, with a genuinely empty browser cache. + const env = { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browsers } + delete env.NODE_TEST_CONTEXT // The standalone child must not inherit the parent's binary reporter protocol. + const result = spawnSync(process.execPath, ['--input-type=module', '--eval', `await import(${JSON.stringify(new URL('./theme.test.mjs', import.meta.url).href)})`], { + cwd: fileURLToPath(new URL('../', import.meta.url)), + env, + encoding: 'utf8', + timeout: 15000, + }) + assert.ifError(result.error) + assert.equal(result.signal, null, 'failure must exit naturally, without the watchdog killing it') + assert.equal(result.status, 1, 'missing browser must remain a test failure') + assert.match(result.stdout + result.stderr, /browserType\.launch/) + } finally { + rmSync(browsers, { recursive: true, force: true }) + } +}) diff --git a/web/tests/theme.test.mjs b/web/tests/theme.test.mjs index afaa1be..c489942 100644 --- a/web/tests/theme.test.mjs +++ b/web/tests/theme.test.mjs @@ -7,30 +7,31 @@ import { mkdir, writeFile } from 'node:fs/promises' test('production themes preserve the mounted assistant, draft, focus and PWA chrome', { timeout: 120000 }, async () => { const artifacts = 'test-results/fastui' await mkdir(artifacts, { recursive: true }) - const server = await preview({ configFile: false, preview: { host: '127.0.0.1', port: 0 } }) - const base = `http://127.0.0.1:${server.httpServer.address().port}` - const browser = await chromium.launch({ headless: true }) - const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block', colorScheme: 'light' }) - const errors = [], scenarios = [] - const user = { id: 'theme-user', identifier: 'theme-user', display_name: 'Theme user', timezone: 'UTC', role: 'admin', locale: 'zh-CN', status: 'active', revision: 1, created_at: '2026-11-10T00:00:00Z' } - const token = `header.${Buffer.from(JSON.stringify({ sub: user.id, exp: 4102444800 })).toString('base64url')}.signature` - await context.route('**/api/v1/**', async route => { - const path = new URL(route.request().url()).pathname.replace('/api/v1', '') - if (path === '/panel/profile') return route.fulfill({ status: 404, json: { detail: 'Profile not configured' } }) - let value = { items: [] } - if (path === '/auth/refresh') value = { access_token: token, refresh_token: 'fixture-refresh', user } - else if (path === '/me') value = user - else if (path === '/agent/availability') value = { configured: true, mode: 'server', model_name: 'fixture-model' } - else if (path === '/agent/thread-state') value = { state: null } - else if (path === '/agent/threads') value = { items: [], next_cursor: null } - else if (path === '/daily-plans/current') value = { plan: { id: 'theme-plan', local_date: new Intl.DateTimeFormat('en-CA', { timeZone: 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()), timezone: 'UTC', status: 'draft', revision: 1, current_revision: 1 }, items: [], availability: { status: 'no_tasks', reason: '暂无任务', ready_count: 0, waiting_count: 0, can_replan: true } } - else if (path === '/planning-settings') value = { daily_budget_minutes: 120, primary_goal_id: null } - await route.fulfill({ json: value }) - }) - await context.addInitScript(() => { - if (!localStorage.getItem('fasttask_refresh')) localStorage.setItem('fasttask_refresh', 'fixture-refresh') - }) + let server, browser try { + server = await preview({ configFile: false, preview: { host: '127.0.0.1', port: 0 } }) + const base = `http://127.0.0.1:${server.httpServer.address().port}` + browser = await chromium.launch({ headless: true }) + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block', colorScheme: 'light' }) + const errors = [], scenarios = [] + const user = { id: 'theme-user', identifier: 'theme-user', display_name: 'Theme user', timezone: 'UTC', role: 'admin', locale: 'zh-CN', status: 'active', revision: 1, created_at: '2026-11-10T00:00:00Z' } + const token = `header.${Buffer.from(JSON.stringify({ sub: user.id, exp: 4102444800 })).toString('base64url')}.signature` + await context.route('**/api/v1/**', async route => { + const path = new URL(route.request().url()).pathname.replace('/api/v1', '') + if (path === '/panel/profile') return route.fulfill({ status: 404, json: { detail: 'Profile not configured' } }) + let value = { items: [] } + if (path === '/auth/refresh') value = { access_token: token, refresh_token: 'fixture-refresh', user } + else if (path === '/me') value = user + else if (path === '/agent/availability') value = { configured: true, mode: 'server', model_name: 'fixture-model' } + else if (path === '/agent/thread-state') value = { state: null } + else if (path === '/agent/threads') value = { items: [], next_cursor: null } + else if (path === '/daily-plans/current') value = { plan: { id: 'theme-plan', local_date: new Intl.DateTimeFormat('en-CA', { timeZone: 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()), timezone: 'UTC', status: 'draft', revision: 1, current_revision: 1 }, items: [], availability: { status: 'no_tasks', reason: '暂无任务', ready_count: 0, waiting_count: 0, can_replan: true } } + else if (path === '/planning-settings') value = { daily_budget_minutes: 120, primary_goal_id: null } + await route.fulfill({ json: value }) + }) + await context.addInitScript(() => { + if (!localStorage.getItem('fasttask_refresh')) localStorage.setItem('fasttask_refresh', 'fixture-refresh') + }) const page = await context.newPage() page.on('pageerror', error => { errors.push(error.message); console.error(error.message) }) await page.goto(base) @@ -96,7 +97,13 @@ test('production themes preserve the mounted assistant, draft, focus and PWA chr assert.deepEqual(errors, []) await writeFile(`${artifacts}/results.json`, JSON.stringify({ status: 'passed', scenarios }, null, 2)) } finally { - await browser.close() - await new Promise(resolve => server.httpServer.close(resolve)) + try { + await browser?.close() + } finally { + if (server) await new Promise((resolve, reject) => { + server.httpServer.close(error => error ? reject(error) : resolve()) + server.httpServer.closeAllConnections() + }) + } } }) From c19a3e81016c31ca1d3ad98aadde5a0eeea78900 Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Wed, 11 Nov 2026 14:43:38 +0800 Subject: [PATCH 3/4] chore: consume verified npm UI and transport releases --- web/package-lock.json | 8 ++++---- web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index be2163e..a19e5b3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,7 @@ "@ai-sdk/openai-compatible": "3.0.53", "@ai-sdk/provider": "4.0.17", "@assistant-ui/react": "^0.15.21", - "@fastrd/ui-foundation": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", + "@fastrd/ui-foundation": "0.1.2", "@fontsource/material-icons": "^5.3.0", "@vitejs/plugin-react": "latest", "assistant-stream": "^0.3.44", @@ -1770,9 +1770,9 @@ } }, "node_modules/@fastrd/ui-foundation": { - "version": "0.1.0", - "resolved": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", - "integrity": "sha512-5O4s9BXXHXWk7GbzPfMYX6HjNi0DKryfWLRPYMZINNb4ew3arjCKbJvxFaSvqcCvhHxmrf8OZDg9GPmfvdnRtw==", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@fastrd/ui-foundation/-/ui-foundation-0.1.2.tgz", + "integrity": "sha512-Jobx7DanQOejVRFfsT5DFXND7rtcvSUzZ5Zui+lY3oAuretz8vwkzqHh1Gd0+ezJubw82WeFsnsXdvbWB4t1YA==", "license": "MIT" }, "node_modules/@floating-ui/core": { diff --git a/web/package.json b/web/package.json index 5e9956d..cafba23 100644 --- a/web/package.json +++ b/web/package.json @@ -12,7 +12,7 @@ "test:panel": "node --test tests/panel-*.test.mjs" }, "dependencies": { - "@fastrd/ui-foundation": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", + "@fastrd/ui-foundation": "0.1.2", "@ai-sdk/openai-compatible": "3.0.53", "@ai-sdk/provider": "4.0.17", "@assistant-ui/react": "^0.15.21", From 863690067df675becdc3c84262adc5585303267f Mon Sep 17 00:00:00 2001 From: GuGuMur <2221533105@qq.com> Date: Wed, 11 Nov 2026 23:25:05 +0800 Subject: [PATCH 4/4] Prepare FastDeploy v2.0.2 production deployment --- .github/workflows/rollback.yml | 2 +- .github/workflows/tag-ci.yml | 4 ++-- docs/fastdeploy.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml index 717bc0c..b827554 100644 --- a/.github/workflows/rollback.yml +++ b/.github/workflows/rollback.yml @@ -14,7 +14,7 @@ permissions: jobs: rollback: if: github.ref == 'refs/heads/main' - uses: FastR-D/FastDeploy/.github/workflows/rollback.yml@cc223a0b83b0ef2389974dd70a4b74148cd1af87 + uses: FastR-D/FastDeploy/.github/workflows/rollback.yml@9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2 with: app: fasttask expected-release: ${{ inputs.expected-release }} diff --git a/.github/workflows/tag-ci.yml b/.github/workflows/tag-ci.yml index 21b0a18..4ad27ed 100644 --- a/.github/workflows/tag-ci.yml +++ b/.github/workflows/tag-ci.yml @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@v4 with: repository: FastR-D/FastDeploy - ref: cc223a0b83b0ef2389974dd70a4b74148cd1af87 + ref: 9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2 path: .fastdeploy-tools persist-credentials: false - name: Package the tested runtime @@ -51,7 +51,7 @@ jobs: deploy: needs: validate if: startsWith(github.ref, 'refs/tags/v') - uses: FastR-D/FastDeploy/.github/workflows/deploy.yml@cc223a0b83b0ef2389974dd70a4b74148cd1af87 + uses: FastR-D/FastDeploy/.github/workflows/deploy.yml@9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2 with: app: fasttask artifact-name: release-${{ github.sha }} diff --git a/docs/fastdeploy.md b/docs/fastdeploy.md index 904fa40..1d90e52 100644 --- a/docs/fastdeploy.md +++ b/docs/fastdeploy.md @@ -4,7 +4,7 @@ 普通运行参数直接写在该 workflow 的 `runtime-env`,使用字面 `KEY=value`,不要加 shell 引号。敏感值用该文件 `runtime-secrets` 中的同名仓库 Secrets,使用 `toJSON` 保留特殊字符。SSH 五项来自 FastR-D 组织的 `FASTRD_SSH_*` Secrets;请勿把凭证写入 YAML 或构建产物。 -构建 job 上传同一次运行的 `release.tar.gz` 和 `manifest.json`。FastDeploy 和打包器均固定到完整提交 `cc223a0b83b0ef2389974dd70a4b74148cd1af87`,服务器不拉源码或重新安装依赖。代码相对路径由 [FastDeploy 应用配置](https://github.com/FastR-D/FastDeploy/blob/cc223a0b83b0ef2389974dd70a4b74148cd1af87/config/apps.json) 注入,业务数据使用其中记录的原持久化路径。 +构建 job 上传同一次运行的 `release.tar.gz` 和 `manifest.json`。FastDeploy 和打包器均固定到完整提交 `9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2`,服务器不拉源码或重新安装依赖。代码相对路径由 [FastDeploy 应用配置](https://github.com/FastR-D/FastDeploy/blob/9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2/config/apps.json) 注入,业务数据使用其中记录的原持久化路径。 发布示例(版本号每次必须唯一):