Fix Windows path URIs and legacy session upgrades / 修复 Windows 路径 URI 与旧会话升级 (#10459)

* Fix Windows path URIs and legacy session upgrades

Problem:
Windows upgrades and path-based features could misinterpret SQLite file URIs,
double-decode file references, lose UNC identities, or report incomplete
session diagnostics as healthy. The packaged upgrade acceptance also used a
current-format fixture and could accept sidebar text as restored history.

Root cause:
Disk database callers built URIs independently, path consumers decoded parsed
URLs twice, LSP conversion dropped remote authorities, and inspection ignored
query failures and live WAL state. The Windows harness did not exercise the
real legacy import boundary or validate the packaged commit field.

Fix:
Centralize SQLite disk URI construction, publish database backups without
replacement, make inspection fail closed, decode file paths once, preserve UNC
file identities, and harden the CI entrypoint. Replace the Windows fixture with
real legacy JSONL data and scope visible-history evidence to the transcript.

Verification:
- go test -p 1 ./... -timeout=20m (root and desktop modules)
- targeted Go race tests for backup, catalog, migration, and media paths
- frontend typecheck, transcript tests, and browser file-preview test
- Node and PowerShell CI contract tests
- Windows amd64 cross-compilation of Desktop tests and the upgrade fixture

* Fix LSP URI conversion on Windows CI

* Encode upgrade fixtures with JSON APIs
This commit is contained in:
SivanCola authored and GitHub committed 2026-09-17 23:21:18 +08:00
1 parent 8dfe505ee4
commit 30bd8ae1ba
39 files changed
+1855 -120

No files matched your search

+10 -6
View File
@@ -264,7 +264,9 @@ jobs:
if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
timeout-minutes: 2
shell: pwsh
run: ./scripts/test-windows-installer-startup.test.ps1
run: |
./scripts/test-windows-installer-startup.test.ps1
./scripts/test-windows-upgrade-startup.test.ps1
# Only main-v2 pushes save, one job per OS and module, so the cache
# stays a few pushes deep instead of churning on every pull request.
@@ -1389,11 +1391,13 @@ jobs:
- name: Install and smoke-test Windows installer identity
shell: pwsh
run: >-
../scripts/test-windows-installer-startup.ps1
-InstallerPath ../dist/Reasonix-windows-amd64-installer.exe
-ExpectedVersion v0.0.0-ci
-EvidenceDirectory "$env:RUNNER_TEMP/reasonix-installer-acceptance"
run: |
go build -o "$env:RUNNER_TEMP/windows-upgrade-fixture.exe" ./cmd/windows-upgrade-fixture
../scripts/test-windows-installer-startup.ps1 `
-InstallerPath ../dist/Reasonix-windows-amd64-installer.exe `
-ExpectedVersion v0.0.0-ci `
-FixtureBuilderPath "$env:RUNNER_TEMP/windows-upgrade-fixture.exe" `
-EvidenceDirectory "$env:RUNNER_TEMP/reasonix-installer-acceptance"
- name: Upload Windows installer acceptance evidence
if: always()
+34
View File
@@ -75,9 +75,20 @@ func TestChatFileReferencePreservesRawFilenameCharacters(t *testing.T) {
root := t.TempDir()
app := newChatReferenceApp(t, root)
writeChatReferenceFile(t, root, "out/raw%20name.txt", "percent")
writeChatReferenceFile(t, root, "out/raw name.txt", "space")
if got := resolveOne(t, app, "out/raw%20name.txt"); got.Status != "resolved" || got.DisplayPath != "out/raw%20name.txt" {
t.Fatalf("literal percent path = %+v", got)
}
if preview := app.ReadReferenceFileForTab("refs", "out/raw%20name.txt"); preview.Err != "" || preview.Body != "percent" {
t.Fatalf("literal percent content = %+v", preview)
}
percentURL := localFileHref(filepath.Join(root, "out", "raw%20name.txt"))
if got := resolveOne(t, app, percentURL); got.Status != "resolved" || got.DisplayPath != "out/raw%20name.txt" {
t.Fatalf("encoded file URL = %+v", got)
}
if preview := app.ReadReferenceFileForTab("refs", percentURL); preview.Err != "" || preview.Body != "percent" {
t.Fatalf("encoded file URL content = %+v", preview)
}
if runtime.GOOS == "windows" {
return
}
@@ -87,6 +98,29 @@ func TestChatFileReferencePreservesRawFilenameCharacters(t *testing.T) {
}
}
func TestLocalPathSourceDecodesURLPathExactlyOnce(t *testing.T) {
tests := []struct {
source string
want string
}{
{"folder/raw%2520name.txt", filepath.FromSlash("folder/raw%20name.txt")},
{"folder/raw%20name.txt", filepath.FromSlash("folder/raw name.txt")},
{"folder/100%25.txt", filepath.FromSlash("folder/100%.txt")},
{"folder/%E4%B8%AD%E6%96%87%23.txt", filepath.FromSlash("folder/中文#.txt")},
}
for _, tt := range tests {
got, err := localPathSource(tt.source)
if err != nil || got != tt.want {
t.Fatalf("localPathSource(%q) = %q, %v; want %q", tt.source, got, err, tt.want)
}
}
for _, source := range []string{"folder/%00.txt", "file:///tmp/%00.txt"} {
if _, err := localPathSource(source); err == nil {
t.Fatalf("localPathSource(%q) accepted NUL", source)
}
}
}
func TestChatFileReferenceOffersSourceForTextMedia(t *testing.T) {
root := t.TempDir()
app := newChatReferenceApp(t, root)
@@ -0,0 +1,21 @@
package main
import (
"flag"
"fmt"
"os"
"reasonix/desktop/internal/upgradefixture"
)
func main() {
mode := flag.String("mode", "", "create or verify")
home := flag.String("home", "", "isolated Reasonix home")
report := flag.String("report", "", "fixture report path")
phase := flag.String("phase", "", "verification phase")
flag.Parse()
if err := upgradefixture.Run(*mode, *home, *report, *phase); err != nil {
fmt.Fprintln(os.Stderr, "windows-upgrade-fixture:", err)
os.Exit(1)
}
}
@@ -33,6 +33,7 @@ try {
await harness.render(items);
await harness.settle();
assert.ok(harness.container.querySelector(".chat-column"));
assert.ok(harness.container.querySelector('[id^="reasonix-chat-transcript-"] .chat-column'), "native upgrade evidence scopes history to the transcript");
assert.equal(harness.container.querySelectorAll(".transcript__window-item").length, 0);
assert.equal(harness.container.querySelectorAll(".chat-tool").length, 0, "completed process unmounts heavy rows");
const disclosure = harness.container.querySelector<HTMLButtonElement>(".chat-process");
@@ -255,7 +255,7 @@ function ChatSession(props: TranscriptProps & { sessionKey: string }) {
}}
onRetryJump={() => { void jump.retry(); }}
onCancelJump={() => jump.cancel()} /></Suspense>
<div ref={scroller} className="transcript chat-flow-scroll" tabIndex={0} data-transcript-render-mode="full"
<div ref={scroller} id={`reasonix-chat-transcript-${tabId ?? "local"}`} className="transcript chat-flow-scroll" tabIndex={0} data-transcript-render-mode="full"
data-transcript-hydrating={hydrating} data-scroll-mode={position.following ? "tail" : "reader"}>
<div ref={column} className="chat-column">
<TranscriptConnection tabId={tabId} />
+426
View File
@@ -0,0 +1,426 @@
// Package upgradefixture creates and verifies disposable legacy data for native
// packaged-app acceptance and the Desktop migration regression.
package upgradefixture
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"reasonix/desktop/internal/workspacestate"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/session"
"reasonix/internal/sqliteuri"
"reasonix/internal/topicstate"
)
const (
fixtureSessionID = "windows-upgrade-fixture"
fixtureTopicID = "windows-upgrade-topic"
fixtureTitle = "Upgrade fixture topic"
fixtureQuestion = "Please restore my earlier conversation."
fixtureText = "Restored assistant body: upgrade-history-7c82 中文 %20 #"
)
type fixtureReport struct {
Home string `json:"home"`
RegistryPath string `json:"registryPath"`
RegistrySHA256 string `json:"registrySha256"`
SessionID string `json:"sessionId"`
TopicID string `json:"topicId"`
VisibleText string `json:"visibleText"`
ProjectRoot string `json:"projectRoot"`
LegacyPath string `json:"legacyPath"`
LegacySHA256 string `json:"legacySha256"`
}
type legacyMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Run requires an isolated, disposable home. It never starts the application.
func Run(mode, home, reportPath, phase string) error {
if strings.TrimSpace(home) == "" || strings.TrimSpace(reportPath) == "" {
return errors.New("--home and --report are required")
}
absHome, err := filepath.Abs(home)
if err != nil {
return err
}
for key, value := range map[string]string{
"REASONIX_HOME": absHome, "REASONIX_STATE_HOME": absHome, "REASONIX_CACHE_HOME": filepath.Join(absHome, "cache"),
} {
previous, existed := os.LookupEnv(key)
if err := os.Setenv(key, value); err != nil {
return err
}
defer func() {
if existed {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
}()
}
switch mode {
case "create":
return createFixture(context.Background(), absHome, reportPath)
case "verify":
return verifyFixture(context.Background(), reportPath, phase)
default:
return errors.New("--mode must be create or verify")
}
}
func createFixture(ctx context.Context, home, reportPath string) error {
if _, err := os.Stat(home); err == nil {
entries, readErr := os.ReadDir(home)
if readErr != nil {
return readErr
}
if len(entries) != 0 {
return errors.New("fixture home must be empty")
}
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(home, 0o700); err != nil {
return err
}
legacyPath := filepath.Join(config.SessionDir(), fixtureSessionID+".jsonl")
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil {
return err
}
legacy, err := encodeLegacyHistory(
legacyMessage{Role: "user", Content: fixtureQuestion},
legacyMessage{Role: "assistant", Content: fixtureText},
)
if err != nil {
return err
}
if err := os.WriteFile(legacyPath, legacy, 0o600); err != nil {
return err
}
if err := agent.SaveBranchMetaPreserveUpdated(legacyPath, agent.BranchMeta{Scope: "global", TopicID: fixtureTopicID, TopicTitle: fixtureTitle}); err != nil {
return err
}
projectRoot := filepath.Join(home, "project # %20 中文")
if err := os.MkdirAll(projectRoot, 0o700); err != nil {
return err
}
projectsBody, err := json.Marshal(map[string]any{"projects": []map[string]any{{"root": projectRoot, "topics": []string{"project-topic"}}}})
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(config.ReasonixHomeDir(), "desktop-projects.json"), projectsBody, 0o600); err != nil {
return err
}
if err := createTopicDatabase(ctx, config.DesktopTopicStatePath(""), fixtureTopicID, "global"); err != nil {
return err
}
if err := createTopicDatabase(ctx, config.DesktopTopicStatePath(projectRoot), "project-topic", "project"); err != nil {
return err
}
registry, err := json.Marshal(map[string]any{
"version": 1,
"generation": 7,
"workspaceIds": []string{"global"},
"workspaces": map[string]any{
"global": map[string]any{
"id": "global",
"root": filepath.Join(config.ReasonixHomeDir(), "global-workspace"),
"title": "Global",
"visible": true,
"sessionIds": []string{},
"futureWorkspace": map[string]any{"preserve": 42},
},
},
"archivedSessionIds": []string{},
"pendingCreates": map[string]any{},
"futureRoot": map[string]any{"preserve": true},
})
if err != nil {
return err
}
registryPath := config.DesktopWorkspaceStatePath()
if err := os.MkdirAll(filepath.Dir(registryPath), 0o700); err != nil {
return err
}
if err := os.WriteFile(registryPath, registry, 0o600); err != nil {
return err
}
tabs := map[string]any{
"tabs": []map[string]any{{"id": "upgrade-tab", "scope": "global", "workspaceId": "global", "topicId": fixtureTopicID, "sessionPath": legacyPath}},
"activeTab": "upgrade-tab", "tabOrder": []string{"upgrade-tab"},
}
tabsBody, err := json.Marshal(tabs)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(config.ReasonixHomeDir(), "desktop-tabs.json"), tabsBody, 0o600); err != nil {
return err
}
digest := sha256.Sum256(registry)
legacyDigest := sha256.Sum256(legacy)
report := fixtureReport{Home: home, RegistryPath: registryPath, RegistrySHA256: hex.EncodeToString(digest[:]), TopicID: fixtureTopicID, VisibleText: fixtureText, ProjectRoot: projectRoot, LegacyPath: legacyPath, LegacySHA256: hex.EncodeToString(legacyDigest[:])}
return writeJSON(reportPath, report)
}
func encodeLegacyHistory(messages ...legacyMessage) ([]byte, error) {
var body strings.Builder
encoder := json.NewEncoder(&body)
for _, message := range messages {
if err := encoder.Encode(message); err != nil {
return nil, err
}
}
return []byte(body.String()), nil
}
func createTopicDatabase(ctx context.Context, path, topicID, marker string) error {
store, err := topicstate.Open(ctx, path)
if err != nil {
return err
}
if _, err := store.Update(ctx, topicID, func(record *topicstate.Record) {
record.Title = fixtureTitle
record.TitleSource = "manual"
}); err != nil {
_ = store.Close()
return err
}
if err := store.Close(); err != nil {
return err
}
dsn, err := sqliteuri.Disk(path, url.Values{"_pragma": {"busy_timeout(5000)"}})
if err != nil {
return err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return err
}
defer db.Close()
for _, statement := range []string{
`ALTER TABLE topics ADD COLUMN future_column TEXT NOT NULL DEFAULT ''`,
`UPDATE topics SET future_column='future-` + marker + `' WHERE topic_id='` + topicID + `'`,
`CREATE TABLE future_data(marker TEXT NOT NULL)`,
`INSERT INTO future_data(marker) VALUES('` + marker + `')`,
} {
if _, err := db.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
func verifyFixture(ctx context.Context, reportPath, phase string) error {
var report fixtureReport
if err := readJSON(reportPath, &report); err != nil {
return err
}
if err := verifyRegistryFields(report.RegistryPath); err != nil {
return err
}
return verifyMigratedSession(ctx, report, reportPath, phase)
}
func verifyRegistryFields(path string) error {
body, err := os.ReadFile(path)
if err != nil {
return err
}
var registry struct {
Version int `json:"version"`
FutureRoot struct {
Preserve bool `json:"preserve"`
} `json:"futureRoot"`
Workspaces map[string]struct {
FutureWorkspace struct {
Preserve int `json:"preserve"`
} `json:"futureWorkspace"`
} `json:"workspaces"`
}
if err := json.Unmarshal(body, &registry); err != nil {
return err
}
if registry.Version != 3 || !registry.FutureRoot.Preserve || registry.Workspaces["global"].FutureWorkspace.Preserve != 42 {
return errors.New("upgraded registry lost its version or unknown field values")
}
return nil
}
func verifyMigratedSession(ctx context.Context, report fixtureReport, reportPath, phase string) error {
state, err := workspacestate.NewStore(report.RegistryPath).Load(ctx)
if err != nil {
return err
}
legacy, err := os.ReadFile(report.LegacyPath)
if err != nil {
return err
}
legacyDigest := sha256.Sum256(legacy)
if hex.EncodeToString(legacyDigest[:]) != report.LegacySHA256 {
return errors.New("legacy JSONL source was modified")
}
mappings := 0
for _, mapping := range state.SourceMappings {
if mapping.Path == report.LegacyPath {
mappings++
report.SessionID = mapping.SessionID
}
}
if mappings != 1 || report.SessionID == "" {
return fmt.Errorf("legacy source mappings=%d, session=%q", mappings, report.SessionID)
}
presentation := state.Presentation[report.SessionID]
if presentation.TopicID != report.TopicID || presentation.Title != fixtureTitle {
return fmt.Errorf("migrated topic relation mismatch: %+v", presentation)
}
count := 0
for _, id := range state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs {
if id == report.SessionID {
count++
}
}
if count != 1 || len(state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs) != 1 || state.SessionStates[report.SessionID].Lifecycle != workspacestate.Active {
return fmt.Errorf("session membership count=%d lifecycle=%q", count, state.SessionStates[report.SessionID].Lifecycle)
}
service, err := session.NewService("desktop", session.NewFilesystemPersistence(config.DesktopSessionStoreDir()))
if err != nil {
return err
}
history, err := service.Query().History(ctx, session.SessionRef{HostID: "desktop", SessionID: report.SessionID})
_ = service.CloseAll(ctx)
if err != nil {
return err
}
if len(history) != 2 || history[0].Role != "user" || history[0].Content != fixtureQuestion || history[1].Role != "assistant" || history[1].Content != report.VisibleText {
return fmt.Errorf("history mismatch: %+v", history)
}
for path, marker := range map[string]string{config.DesktopTopicStatePath(""): "global", config.DesktopTopicStatePath(report.ProjectRoot): "project"} {
if err := verifyTopicDatabase(ctx, path, marker); err != nil {
return err
}
}
if err := verifyBackups(ctx, report); err != nil {
return err
}
resultPath := filepath.Join(filepath.Dir(reportPath), "verification-"+phase+".json")
if phase == "restart" {
var first struct {
SessionID string `json:"sessionId"`
}
if err := readJSON(filepath.Join(filepath.Dir(reportPath), "verification-first.json"), &first); err != nil {
return err
}
if first.SessionID != report.SessionID {
return errors.New("restart changed migrated session identity")
}
}
return writeJSON(resultPath, map[string]any{"phase": phase, "version": state.Version, "sessionId": report.SessionID, "sessionCount": count, "history": report.VisibleText, "legacySha256": report.LegacySHA256, "topicBackups": 2, "unknownData": "preserved", "verifiedAt": time.Now().UTC()})
}
func verifyBackups(ctx context.Context, report fixtureReport) error {
backupRoot := filepath.Join(config.ReasonixHomeDir(), "desktop", "upgrade-backups")
topicBackups, err := filepath.Glob(filepath.Join(backupRoot, "topics-*.sqlite"))
if err != nil {
return fmt.Errorf("list topic backups: %w", err)
}
if len(topicBackups) != 2 {
return fmt.Errorf("topic backups=%d, want 2", len(topicBackups))
}
markers := map[string]bool{}
for _, path := range topicBackups {
marker, err := topicMarker(ctx, path)
if err != nil {
return err
}
markers[marker] = true
}
if !markers["global"] || !markers["project"] {
return fmt.Errorf("topic backup markers=%v", markers)
}
registryBackups, err := filepath.Glob(filepath.Join(backupRoot, "workspace-state-v1.json-*.bak"))
if err != nil {
return fmt.Errorf("list metadata registry backups: %w", err)
}
if len(registryBackups) != 1 {
return fmt.Errorf("metadata registry backups=%d, want 1", len(registryBackups))
}
backupBody, err := os.ReadFile(registryBackups[0])
if err != nil {
return err
}
digest := sha256.Sum256(backupBody)
if hex.EncodeToString(digest[:]) != report.RegistrySHA256 {
return errors.New("registry backup does not match the v1 source")
}
return nil
}
func verifyTopicDatabase(ctx context.Context, path, wantMarker string) error {
marker, err := topicMarker(ctx, path)
if err != nil {
return err
}
if marker != wantMarker {
return fmt.Errorf("topic database %s marker=%q, want %q", path, marker, wantMarker)
}
return nil
}
func topicMarker(ctx context.Context, path string) (string, error) {
dsn, err := sqliteuri.Disk(path, url.Values{"mode": {"ro"}})
if err != nil {
return "", err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return "", err
}
defer db.Close()
var marker, future string
if err := db.QueryRowContext(ctx, `SELECT marker FROM future_data`).Scan(&marker); err != nil {
return "", err
}
if err := db.QueryRowContext(ctx, `SELECT future_column FROM topics LIMIT 1`).Scan(&future); err != nil {
return "", err
}
if future != "future-"+marker {
return "", fmt.Errorf("future topic column=%q for marker %q", future, marker)
}
return marker, nil
}
func writeJSON(path string, value any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
body, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(body, '\n'), 0o600)
}
func readJSON(path string, value any) error {
body, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(body, value)
}
@@ -0,0 +1,55 @@
package upgradefixture
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestEncodeLegacyHistoryEscapesJSONContent(t *testing.T) {
want := []legacyMessage{
{Role: "user", Content: "quote \" slash \\ newline\n中文 %20 #"},
{Role: "assistant", Content: "second line"},
}
body, err := encodeLegacyHistory(want...)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSuffix(string(body), "\n"), "\n")
if len(lines) != len(want) {
t.Fatalf("encoded lines = %d, want %d: %q", len(lines), len(want), body)
}
for i, line := range lines {
var got legacyMessage
if err := json.Unmarshal([]byte(line), &got); err != nil {
t.Fatalf("decode line %d: %v", i, err)
}
if got != want[i] {
t.Fatalf("line %d = %+v, want %+v", i, got, want[i])
}
}
}
func TestRunRestoresEnvironmentOnSuccessAndFailure(t *testing.T) {
for _, key := range []string{"REASONIX_HOME", "REASONIX_STATE_HOME", "REASONIX_CACHE_HOME"} {
t.Setenv(key, "untouched-"+key)
}
home := filepath.Join(t.TempDir(), "isolated")
report := filepath.Join(t.TempDir(), "fixture.json")
for _, mode := range []string{"create", "verify", "invalid"} {
err := Run(mode, home, report, "first")
if mode == "create" && err != nil {
t.Fatal(err)
}
if mode != "create" && err == nil {
t.Fatalf("%s must fail before migration", mode)
}
for _, key := range []string{"REASONIX_HOME", "REASONIX_STATE_HOME", "REASONIX_CACHE_HOME"} {
if got := os.Getenv(key); got != "untouched-"+key {
t.Fatalf("%s leaked %s=%q", mode, key, got)
}
}
}
}
+4 -4
View File
@@ -29,8 +29,8 @@ func localPathSource(source string) (string, error) {
if u.Host != "" && !strings.EqualFold(u.Host, "localhost") {
return "", os.ErrPermission
}
path, err := url.PathUnescape(u.Path)
if err != nil {
path := u.Path
if strings.ContainsRune(path, 0) {
return "", os.ErrInvalid
}
if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' {
@@ -42,8 +42,8 @@ func localPathSource(source string) (string, error) {
if err != nil || u.Scheme != "" {
return "", os.ErrInvalid
}
path, err := url.PathUnescape(u.Path)
if err != nil || strings.ContainsRune(path, 0) {
path := u.Path
if strings.ContainsRune(path, 0) {
return "", os.ErrInvalid
}
return filepath.FromSlash(path), nil
+39
View File
@@ -5,6 +5,8 @@ import (
"encoding/base64"
"encoding/binary"
"hash/crc32"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
@@ -61,6 +63,43 @@ func TestResolveMarkdownImageForTabWorkspaceAndRemotePolicy(t *testing.T) {
}
}
func TestResolveMarkdownImageSelectsPercentAndSpaceNamesExactly(t *testing.T) {
original, _ := os.Getwd()
defer os.Chdir(original)
workspace := t.TempDir()
if err := os.Chdir(workspace); err != nil {
t.Fatal(err)
}
percentBody := append(append([]byte(nil), markdownImageTestPNG...), []byte("percent-file")...)
spaceBody := append(append([]byte(nil), markdownImageTestPNG...), []byte("space-file")...)
if err := os.WriteFile("raw%20name.png", percentBody, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("raw name.png", spaceBody, 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
handler := app.workspaceMediaMiddleware()(http.NotFoundHandler())
for _, tc := range []struct {
source string
name string
body []byte
}{
{source: "raw%2520name.png", name: "raw%20name.png", body: percentBody},
{source: "raw%20name.png", name: "raw name.png", body: spaceBody},
} {
view := app.ResolveMarkdownImageForTab("", tc.source)
if view.ErrorCode != "" || view.Filename != tc.name {
t.Fatalf("resolve %q = %+v", tc.source, view)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, view.URL, nil))
if recorder.Code != http.StatusOK || !bytes.Equal(recorder.Body.Bytes(), tc.body) {
t.Fatalf("GET %q = %d %q, want exact %q", view.URL, recorder.Code, recorder.Body.Bytes(), tc.body)
}
}
}
func TestResolveMarkdownImageForTabRejectsTraversalAndSymlinkEscape(t *testing.T) {
original, _ := os.Getwd()
defer os.Chdir(original)
+52
View File
@@ -64,6 +64,58 @@ func TestHTMLPreviewBindsLocalDependencies(t *testing.T) {
}
}
func TestHTMLPreviewDecodesDependencyPathsExactlyOnce(t *testing.T) {
withPreviewWorkspace(t)
files := map[string]string{
"raw%20name.js": "literal-percent",
"raw name.js": "space",
"100%.css": "literal-percent-sign",
"中文#.png": "unicode-hash",
}
for name, body := range files {
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
htmlBody := `<script src="raw%2520name.js"></script><script src="raw%20name.js"></script>` +
`<link href="100%25.css"><img src="%E4%B8%AD%E6%96%87%23.png">`
if err := os.WriteFile("index.html", []byte(htmlBody), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
preview := app.ReadFile("index.html")
if preview.Err != "" || preview.URL == "" {
t.Fatalf("HTML preview = %+v", preview)
}
tokenRoot := strings.TrimSuffix(preview.URL, "/index.html")
handler := app.workspaceMediaMiddleware()(http.NotFoundHandler())
requests := map[string]string{
"raw%2520name.js": "literal-percent",
"raw%20name.js": "space",
"100%25.css": "literal-percent-sign",
"%E4%B8%AD%E6%96%87%23.png": "unicode-hash",
}
for encoded, want := range requests {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, tokenRoot+"/"+encoded, nil))
if recorder.Code != http.StatusOK || recorder.Body.String() != want {
t.Fatalf("GET %q = %d %q, want 200 %q", encoded, recorder.Code, recorder.Body.String(), want)
}
}
}
func TestHTMLPreviewRejectsEncodedTraversalAndNUL(t *testing.T) {
withPreviewWorkspace(t)
for _, source := range []string{"%2e%2e/outside.js", "asset%00.js"} {
if err := os.WriteFile("index.html", []byte(`<script src="`+source+`"></script>`), 0o644); err != nil {
t.Fatal(err)
}
if preview := NewApp().ReadFile("index.html"); preview.URL != "" || preview.Err == "" {
t.Fatalf("unsafe dependency %q = %+v", source, preview)
}
}
}
func TestHTMLPreviewFailsBeforePublishingIncompleteBundle(t *testing.T) {
withPreviewWorkspace(t)
if err := os.WriteFile("index.html", []byte(`<script src="missing.js"></script>`), 0o644); err != nil {
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/desktop/internal/workspacestate"
"reasonix/internal/config"
"reasonix/internal/topicstate"
)
func createUpgradeTopicFixture(t *testing.T, path, topicID, marker string) *sql.DB {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
store, err := topicstate.Open(t.Context(), path)
if err != nil {
t.Fatal(err)
}
if _, err := store.Update(t.Context(), topicID, func(record *topicstate.Record) {
record.Title = "preserved " + marker
record.TitleSource = "manual"
}); err != nil {
_ = store.Close()
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`PRAGMA journal_mode=WAL`,
`PRAGMA wal_autocheckpoint=0`,
`ALTER TABLE topics ADD COLUMN future_column TEXT NOT NULL DEFAULT ''`,
`UPDATE topics SET future_column='future-` + marker + `' WHERE topic_id='` + topicID + `'`,
`CREATE TABLE future_data(marker TEXT NOT NULL)`,
`INSERT INTO future_data(marker) VALUES('` + marker + `')`,
} {
if _, err := db.ExecContext(t.Context(), statement); err != nil {
_ = db.Close()
t.Fatal(err)
}
}
return db
}
func fileBytes(t *testing.T, path string) []byte {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return body
}
func TestDesktopV1UpgradeBacksUpTopicWALAndRetriesWithoutDuplicateImport(t *testing.T) {
isolateDesktopUserDirs(t)
ctx := context.Background()
projectRoot := filepath.Join(t.TempDir(), "project %20 # 中文")
if err := os.MkdirAll(projectRoot, 0o755); err != nil {
t.Fatal(err)
}
projects := []byte(`{"projects":[{"root":` + quotedJSON(projectRoot) + `,"topics":["project-topic"]}]}`)
if err := os.MkdirAll(desktopConfigDir(), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(desktopConfigDir(), desktopProjectsFile), projects, 0o600); err != nil {
t.Fatal(err)
}
registryPath := config.DesktopWorkspaceStatePath()
if err := os.MkdirAll(filepath.Dir(registryPath), 0o700); err != nil {
t.Fatal(err)
}
originalRegistry := []byte(`{"version":1,"generation":7,"workspaceIds":["global"],"workspaces":{"global":{"id":"global","root":"/global","title":"Mine","visible":false,"sessionIds":["old"],"futureWorkspace":{"keep":42}}},"archivedSessionIds":["old"],"pendingCreates":{},"futureRoot":{"keep":true}}`)
if err := os.WriteFile(registryPath, originalRegistry, 0o600); err != nil {
t.Fatal(err)
}
historyDir := config.SessionDir()
if err := os.MkdirAll(historyDir, 0o700); err != nil {
t.Fatal(err)
}
historyPath := writeLegacySession(t, historyDir, "upgrade-history.jsonl", "preserve old history", time.Now())
historyBefore := fileBytes(t, historyPath)
globalPath := config.DesktopTopicStatePath("")
projectPath := config.DesktopTopicStatePath(projectRoot)
globalDB := createUpgradeTopicFixture(t, globalPath, "global-topic", "global")
defer globalDB.Close()
projectDB := createUpgradeTopicFixture(t, projectPath, "project-topic", "project")
defer projectDB.Close()
globalBefore := fileBytes(t, globalPath)
projectBefore := fileBytes(t, projectPath)
backupDir := filepath.Join(desktopConfigDir(), "desktop", "upgrade-backups")
if err := os.MkdirAll(filepath.Dir(backupDir), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(backupDir, []byte("occupied"), 0o600); err != nil {
t.Fatal(err)
}
store := newDesktopWorkspaceStore()
if err := store.RestoreSession(ctx, "old"); err == nil {
t.Fatal("registry upgrade succeeded despite a blocked metadata backup directory")
}
if got := fileBytes(t, registryPath); !bytes.Equal(got, originalRegistry) {
t.Fatal("failed upgrade published a new registry")
}
if got := fileBytes(t, globalPath); !bytes.Equal(got, globalBefore) {
t.Fatal("failed upgrade changed the global topic database")
}
if got := fileBytes(t, projectPath); !bytes.Equal(got, projectBefore) {
t.Fatal("failed upgrade changed the project topic database")
}
if got := fileBytes(t, historyPath); !bytes.Equal(got, historyBefore) {
t.Fatal("failed upgrade changed old session history")
}
if err := os.Remove(backupDir); err != nil {
t.Fatal(err)
}
if err := store.RestoreSession(ctx, "old"); err != nil {
t.Fatal(err)
}
state, err := newDesktopWorkspaceStore().Load(ctx)
if err != nil {
t.Fatal(err)
}
if state.Version != workspacestate.SchemaVersion || state.SessionStates["old"].Lifecycle != workspacestate.Active {
t.Fatalf("upgraded registry = %+v", state)
}
count := 0
for _, id := range state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs {
if id == "old" {
count++
}
}
if count != 1 {
t.Fatalf("old session membership count = %d, want 1", count)
}
registryBody := string(fileBytes(t, registryPath))
for _, field := range []string{`"futureRoot"`, `"futureWorkspace"`} {
if !strings.Contains(registryBody, field) {
t.Fatalf("registry lost unknown field %s: %s", field, registryBody)
}
}
if got := fileBytes(t, globalPath); !bytes.Equal(got, globalBefore) {
t.Fatal("successful upgrade changed the global topic database")
}
if got := fileBytes(t, projectPath); !bytes.Equal(got, projectBefore) {
t.Fatal("successful upgrade changed the project topic database")
}
if got := fileBytes(t, historyPath); !bytes.Equal(got, historyBefore) {
t.Fatal("successful upgrade changed old session history")
}
backups, err := filepath.Glob(filepath.Join(backupDir, "topics-*.sqlite"))
if err != nil || len(backups) != 2 {
t.Fatalf("topic backups = %v, %v; want two", backups, err)
}
markers := map[string]bool{}
for _, backup := range backups {
db, err := sql.Open("sqlite", backup)
if err != nil {
t.Fatal(err)
}
var marker, title, future string
if err := db.QueryRowContext(ctx, `SELECT marker FROM future_data`).Scan(&marker); err != nil {
_ = db.Close()
t.Fatal(err)
}
topicID := marker + "-topic"
if err := db.QueryRowContext(ctx, `SELECT title,future_column FROM topics WHERE topic_id=?`, topicID).Scan(&title, &future); err != nil {
_ = db.Close()
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
if title != "preserved "+marker || future != "future-"+marker {
t.Fatalf("backup %s lost data: marker=%q title=%q future=%q", backup, marker, title, future)
}
markers[marker] = true
}
if !markers["global"] || !markers["project"] {
t.Fatalf("backup markers = %v", markers)
}
}
func quotedJSON(value string) string {
body, _ := json.Marshal(value)
return string(body)
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"reasonix/desktop/internal/upgradefixture"
"reasonix/internal/config"
"reasonix/internal/session"
)
func TestWindowsUpgradeFixtureMigratesLegacyAndRestarts(t *testing.T) {
isolateDesktopUserDirs(t)
home := filepath.Join(t.TempDir(), "home # %20 中文")
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", home)
t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
reportPath := filepath.Join(t.TempDir(), "fixture.json")
if err := upgradefixture.Run("create", home, reportPath, ""); err != nil {
t.Fatal(err)
}
for _, path := range []string{config.SessionStoreDir(), config.DesktopSessionStoreDir()} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("fixture must not pre-create canonical history: %s: %v", path, err)
}
}
for _, phase := range []string{"first", "restart"} {
app := NewApp()
t.Cleanup(app.closeSessionServices)
if err := app.migrateDesktopSessionsV5(t.Context()); err != nil {
t.Fatal(err)
}
if err := upgradefixture.Run("verify", home, reportPath, phase); err != nil {
t.Fatalf("%s: %v", phase, err)
}
var evidence struct {
SessionID string `json:"sessionId"`
History string `json:"history"`
}
body, err := os.ReadFile(filepath.Join(filepath.Dir(reportPath), "verification-"+phase+".json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(body, &evidence); err != nil {
t.Fatal(err)
}
page, err := app.ReadSessionHistory(session.SessionRef{HostID: localDesktopHostID, SessionID: evidence.SessionID}, "", 10)
if err != nil || len(page.Messages) != 2 || page.Messages[1].Content != evidence.History {
t.Fatalf("%s history API: %+v, %v", phase, page, err)
}
app.closeSessionServices()
}
}
+5 -5
View File
@@ -182,9 +182,9 @@ func collectHTMLPreviewDependencies(absPath, allowedRoot string, rootSize int64)
if strings.HasPrefix(ref.Path, "/") {
return nil, errors.New("HTML preview contains an unsupported root-relative local resource: " + ref.Path)
}
decoded, decodeErr := url.PathUnescape(ref.Path)
if decodeErr != nil {
return nil, errors.New("HTML preview contains an invalid escaped resource path")
decoded := ref.Path
if strings.ContainsRune(decoded, 0) {
return nil, errors.New("HTML preview contains an invalid local resource path")
}
key := filepath.ToSlash(filepath.Clean(filepath.FromSlash(decoded)))
if key == "." || key == ".." || strings.HasPrefix(key, "../") || filepath.IsAbs(key) {
@@ -341,8 +341,8 @@ func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {
modTime := entry.modTime
isRoot := true
if len(parts) == 2 {
requested, unescapeErr := url.PathUnescape(parts[1])
if unescapeErr != nil {
requested := parts[1]
if strings.ContainsRune(requested, 0) {
http.NotFound(w, r)
return
}
+31
View File
@@ -153,3 +153,34 @@ func TestMarkdownMediaTokenBindsAuthorizedIdentity(t *testing.T) {
t.Fatalf("replacement identity response = %d, want 404", rec.Code)
}
}
func TestMediaTokenHandlerSelectsPercentFilenameExactly(t *testing.T) {
original, _ := os.Getwd()
defer os.Chdir(original)
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
files := map[string]string{
"raw%20name.pdf": "literal-percent",
"raw name.pdf": "space",
"100%.pdf": "percent-sign",
"中文#.pdf": "unicode-hash",
}
app := NewApp()
handler := app.workspaceMediaMiddleware()(http.NotFoundHandler())
for name, body := range files {
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
preview := app.ReadFile(name)
if preview.Err != "" || preview.URL == "" {
t.Fatalf("preview %q = %+v", name, preview)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, preview.URL, nil))
if recorder.Code != http.StatusOK || recorder.Body.String() != body {
t.Fatalf("GET %q = %d %q, want exact %q", preview.URL, recorder.Code, recorder.Body.String(), body)
}
}
}
+6
View File
@@ -111,6 +111,9 @@ func doctorSessionsCommand(args []string) int {
fmt.Fprintln(os.Stderr, err)
return 1
}
if status.State == sessioncatalog.StateDegraded {
return 1
}
return 0
}
fmt.Println("Reasonix session catalog")
@@ -134,6 +137,9 @@ func doctorSessionsCommand(args []string) int {
if status.LastError != "" {
fmt.Printf(" note: %s\n", status.LastError)
}
if status.State == sessioncatalog.StateDegraded {
return 1
}
return 0
}
+2 -2
View File
@@ -43,8 +43,8 @@ func TestDoctorSessionsIsReadOnly(t *testing.T) {
cache := t.TempDir()
t.Setenv("REASONIX_CACHE_HOME", cache)
out := captureStdout(t, func() {
if rc := doctorCommand([]string{"sessions", "--json"}, "test-version"); rc != 0 {
t.Fatalf("doctor sessions rc = %d, want 0", rc)
if rc := doctorCommand([]string{"sessions", "--json"}, "test-version"); rc != 1 {
t.Fatalf("doctor sessions rc = %d, want 1 for missing catalog", rc)
}
})
var decoded map[string]any
+59 -2
View File
@@ -205,12 +205,69 @@ func TestURIRoundtrip(t *testing.T) {
if !strings.HasPrefix(uri, "file://") {
t.Errorf("%q → %q is not a file URI", p, uri)
}
if got := uriToPath(uri); got != p {
t.Errorf("roundtrip %q → %q", p, got)
if got, err := uriToPath(uri); err != nil || got != p {
t.Errorf("roundtrip %q → %q, %v", p, got, err)
}
}
}
func TestWindowsFileURIConversions(t *testing.T) {
tests := []struct {
path string
uri string
}{
{`C:\Users\Test User\中文%20.go`, `file:///C:/Users/Test%20User/%E4%B8%AD%E6%96%87%2520.go`},
{`\\server\share\Test User\中文%20.go`, `file://server/share/Test%20User/%E4%B8%AD%E6%96%87%2520.go`},
}
for _, tt := range tests {
if got := pathToURIForOS(tt.path, "windows"); got != tt.uri {
t.Errorf("pathToURIForOS(%q) = %q, want %q", tt.path, got, tt.uri)
}
if got, err := uriToPathForOS(tt.uri, "windows"); err != nil || got != tt.path {
t.Errorf("uriToPathForOS(%q) = %q, %v; want %q", tt.uri, got, err, tt.path)
}
}
}
func TestURIToPathAuthorityAndValidation(t *testing.T) {
if got, err := uriToPathForOS("file://localhost/tmp/a%20b%2520.go", "linux"); err != nil || got != "/tmp/a b%20.go" {
t.Fatalf("localhost URI = %q, %v", got, err)
}
for _, uri := range []string{
"https://server/share/a.go",
"file://server/share/a.go",
"file://server:123/share/a.go",
"file:///tmp/a.go?mode=ro",
"file:///tmp/a.go#fragment",
"file:///tmp/%00.go",
"%",
} {
if _, err := uriToPathForOS(uri, "linux"); err == nil {
t.Errorf("uriToPathForOS(%q) unexpectedly succeeded", uri)
}
}
}
func TestFormatLocationsKeepsInvalidURIAndSkipsSnippet(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "valid.go")
if err := os.WriteFile(path, []byte("package valid\n"), 0o600); err != nil {
t.Fatal(err)
}
m := &Manager{wsRoot: root}
remote := "https://server/share/secret.go"
got := m.formatLocations("definition", []Location{
{URI: pathToURI(path), Range: Range{Start: Position{Line: 0}}},
{URI: remote, Range: Range{Start: Position{Line: 6}}},
})
if !strings.Contains(got, "valid.go:1 package valid") {
t.Fatalf("valid location lost snippet:\n%s", got)
}
if !strings.Contains(got, remote+":7") || strings.Contains(got, remote+":7 ") {
t.Fatalf("invalid URI was treated as a local path:\n%s", got)
}
}
func TestLocateEncoding(t *testing.T) {
content := "package x\nαβ foo()\n" // line 2 has two 2-byte runes then a space
u16, err := locate(content, 2, "foo", encodingUTF16)
+8 -4
View File
@@ -282,11 +282,15 @@ func (m *Manager) formatLocations(kind string, locs []Location) string {
var b strings.Builder
fmt.Fprintf(&b, "%d %s(s):\n", len(locs), kind)
for _, l := range locs {
p := uriToPath(l.URI)
line := l.Range.Start.Line + 1
fmt.Fprintf(&b, "%s:%d", m.rel(p), line)
if snippet := readLine(p, l.Range.Start.Line); snippet != "" {
fmt.Fprintf(&b, " %s", snippet)
p, err := uriToPath(l.URI)
if err != nil {
fmt.Fprintf(&b, "%s:%d", l.URI, line)
} else {
fmt.Fprintf(&b, "%s:%d", m.rel(p), line)
if snippet := readLine(p, l.Range.Start.Line); snippet != "" {
fmt.Fprintf(&b, " %s", snippet)
}
}
b.WriteByte('\n')
}
+49 -6
View File
@@ -1,6 +1,7 @@
package lsp
import (
"errors"
"fmt"
"net/url"
"path/filepath"
@@ -30,24 +31,66 @@ type Location struct {
}
func pathToURI(p string) string {
return pathToURIForOS(p, runtime.GOOS)
}
func pathToURIForOS(p, goos string) string {
p = filepath.ToSlash(p)
if runtime.GOOS == "windows" && len(p) > 1 && p[1] == ':' {
p = "/" + p // C:/x → /C:/x so the URI becomes file:///C:/x
if goos == "windows" {
p = strings.ReplaceAll(p, `\`, "/")
if hostAndPath, ok := strings.CutPrefix(p, "//"); ok {
host, uriPath, found := strings.Cut(hostAndPath, "/")
if found && host != "" {
return (&url.URL{Scheme: "file", Host: host, Path: "/" + uriPath}).String()
}
}
if len(p) > 1 && p[1] == ':' {
p = "/" + p // C:/x → /C:/x so the URI becomes file:///C:/x
}
}
u := url.URL{Scheme: "file", Path: p}
return u.String()
}
func uriToPath(uri string) string {
func uriToPath(uri string) (string, error) {
return uriToPathForOS(uri, runtime.GOOS)
}
func uriToPathForOS(uri, goos string) (string, error) {
u, err := url.Parse(uri)
if err != nil {
return uri
return "", fmt.Errorf("parse URI: %w", err)
}
if !strings.EqualFold(u.Scheme, "file") || u.Opaque != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return "", errors.New("URI is not a local file URI")
}
if strings.ContainsRune(u.Path, 0) {
return "", errors.New("file URI path contains NUL")
}
host := u.Hostname()
if u.Host != "" && (host == "" || u.Port() != "") {
return "", errors.New("file URI has an invalid authority")
}
if host != "" && !strings.EqualFold(host, "localhost") {
if goos != "windows" {
return "", fmt.Errorf("remote file URI authority %q is not local on %s", host, goos)
}
if u.Path == "" || u.Path == "/" {
return "", errors.New("UNC file URI is missing a share path")
}
return `\\` + host + `\` + strings.ReplaceAll(strings.TrimPrefix(u.Path, "/"), "/", `\`), nil
}
p := u.Path
if runtime.GOOS == "windows" && len(p) > 2 && p[0] == '/' && p[2] == ':' {
if p == "" {
return "", errors.New("file URI path is empty")
}
if goos == "windows" && len(p) > 2 && p[0] == '/' && p[2] == ':' {
p = p[1:]
}
return filepath.FromSlash(p)
if goos == "windows" {
return strings.ReplaceAll(p, "/", `\`), nil
}
return p, nil
}
// locate finds symbol on the 1-based line of content and returns the LSP position
+19 -19
View File
@@ -17,6 +17,7 @@ import (
"time"
"reasonix/internal/filelock"
"reasonix/internal/sqliteuri"
moderncsqlite "modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
@@ -181,23 +182,6 @@ func Open(ctx context.Context, opts OpenOptions) (*Handle, error) {
return &Handle{DB: db, Status: status}, nil
}
// diskFileDSN builds a cross-platform SQLite file URI. Windows drive paths must
// be file:///C:/...; a bare file:C:\... URI fails to open and previously forced
// silent memory fallback during rebuild.
func diskFileDSN(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
slash := filepath.ToSlash(abs)
if runtime.GOOS == "windows" && len(slash) >= 2 && slash[1] == ':' {
// C:/Users/... → /C:/Users/... so the URI becomes file:///C:/Users/...
slash = "/" + slash
}
u := &url.URL{Scheme: "file", Path: slash}
return u.String() + "?_pragma=busy_timeout%28150%29&_pragma=foreign_keys%281%29"
}
func open(ctx context.Context, opts OpenOptions, mode Mode) (*sql.DB, error) {
var dsn string
if mode == ModeMemory {
@@ -207,7 +191,13 @@ func open(ctx context.Context, opts OpenOptions, mode Mode) (*sql.DB, error) {
dsn = fmt.Sprintf("file:reasonix-%s-%d-%d?mode=memory&cache=shared", url.PathEscape(opts.MemoryName),
opts.Now().UnixNano(), memoryDatabaseSequence.Add(1))
} else {
dsn = diskFileDSN(opts.Path)
var err error
dsn, err = sqliteuri.Disk(opts.Path, url.Values{
"_pragma": {"busy_timeout(150)", "foreign_keys(1)"},
})
if err != nil {
return nil, err
}
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
@@ -346,7 +336,17 @@ func Inspect(ctx context.Context, path string) Inspection {
}
out.Exists = true
out.Size = info.Size()
db, err := sql.Open("sqlite", diskFileDSN(path)+"&mode=ro&immutable=1")
// A live projection may hold its schema and latest commits only in WAL.
// immutable=1 would ignore that WAL and report a healthy database as broken.
dsn, err := sqliteuri.Disk(path, url.Values{
"_pragma": {"busy_timeout(150)", "foreign_keys(1)"},
"mode": {"ro"},
})
if err != nil {
out.Error = err.Error()
return out
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
out.Error = err.Error()
return out
+17 -9
View File
@@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -81,6 +80,22 @@ func TestInspectDoesNotCreateMissingDatabase(t *testing.T) {
}
}
func TestInspectReadsLiveWALSchema(t *testing.T) {
path := filepath.Join(t.TempDir(), "live.sqlite")
handle, err := Open(t.Context(), OpenOptions{Path: path, Migrations: testMigrations()})
if err != nil {
t.Fatal(err)
}
defer handle.DB.Close()
if _, err := handle.DB.Exec(`PRAGMA wal_autocheckpoint=0; INSERT INTO schema_migrations(version, applied_at) VALUES(42, 1)`); err != nil {
t.Fatal(err)
}
inspection := Inspect(t.Context(), path)
if !inspection.Exists || inspection.Error != "" || inspection.Schema != 42 || inspection.Integrity != "ok" {
t.Fatalf("live WAL inspection=%+v", inspection)
}
}
func TestRebuildPublishesOnlyValidatedReplacement(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "catalog.sqlite")
@@ -147,15 +162,8 @@ func TestRebuildCanRetainPreviousDatabaseForRollback(t *testing.T) {
}
}
func TestDiskFileDSNUsesCrossPlatformURI(t *testing.T) {
func TestDiskOpenUsesCrossPlatformURI(t *testing.T) {
t.Parallel()
dsn := diskFileDSN(filepath.Join(t.TempDir(), "catalog.sqlite"))
if !strings.HasPrefix(dsn, "file:") {
t.Fatalf("dsn=%q", dsn)
}
if strings.Contains(dsn, `\`) {
t.Fatalf("dsn must use forward slashes: %q", dsn)
}
// Opening through the DSN must succeed on this platform.
handle, err := Open(context.Background(), OpenOptions{
Path: filepath.Join(t.TempDir(), "opened.sqlite"), MemoryName: "dsn", Migrations: testMigrations(), RequireDisk: true,
+95
View File
@@ -0,0 +1,95 @@
package sessioncatalog
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
)
func TestInspectReturnsCompleteHealthyStatistics(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "catalog %20 # 会话.sqlite")
catalog, err := Open(ctx, Options{Path: path, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`UPDATE catalog_state SET revision=42 WHERE id=1`,
`INSERT INTO catalog_sessions(path,path_key,directory,directory_key,scope,topic_id,turns_state,repair_state,repair_error_kind)
VALUES('/sessions/a.jsonl','/sessions/a.jsonl','/sessions','/sessions','global','topic-a','unknown','blocked','parse')`,
`INSERT INTO catalog_topics(scope,workspace_root_key,topic_id) VALUES('global','','topic-a')`,
} {
if _, err := catalog.db.ExecContext(ctx, statement); err != nil {
_ = catalog.Close(ctx)
t.Fatal(err)
}
}
if err := catalog.Close(ctx); err != nil {
t.Fatal(err)
}
status, err := Inspect(ctx, path)
if err != nil {
t.Fatal(err)
}
if status.State != StateReady || status.Revision != 42 || status.Indexed != 1 || status.PhysicalSessions != 1 || status.LogicalSessions != 1 {
t.Fatalf("unexpected inspection: %+v", status)
}
if status.RepairPending != 1 || status.RepairBlocked != 1 || status.RepairErrorKinds["parse"] != 1 {
t.Fatalf("repair statistics: %+v", status)
}
}
func TestInspectReportsQueryFailureAsDegradedWithoutPartialStatistics(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "catalog.sqlite")
catalog, err := Open(ctx, Options{Path: path, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
if _, err := catalog.db.ExecContext(ctx, `UPDATE catalog_state SET revision=42 WHERE id=1; DROP TABLE catalog_sessions`); err != nil {
_ = catalog.Close(ctx)
t.Fatal(err)
}
if err := catalog.Close(ctx); err != nil {
t.Fatal(err)
}
status, err := Inspect(ctx, path)
if err != nil {
t.Fatal(err)
}
if status.State != StateDegraded || status.Revision != 0 || status.Indexed != 0 {
t.Fatalf("query failure published partial statistics: %+v", status)
}
if !strings.Contains(status.LastError, "count indexed sessions") {
t.Fatalf("last error lacks failing stage: %q", status.LastError)
}
}
func TestInspectReturnsContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := Inspect(ctx, filepath.Join(t.TempDir(), "missing.sqlite"))
if !errors.Is(err, context.Canceled) {
t.Fatalf("Inspect error = %v, want context cancellation", err)
}
}
func TestInspectReadsCommittedWALWhileCatalogIsOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "live.sqlite")
catalog, err := Open(t.Context(), Options{Path: path, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
defer catalog.Close(t.Context())
if _, err := catalog.db.ExecContext(t.Context(), `PRAGMA wal_autocheckpoint=0; UPDATE catalog_state SET revision=42 WHERE id=1`); err != nil {
t.Fatal(err)
}
status, err := Inspect(t.Context(), path)
if err != nil || status.State != StateReady || status.Revision != 42 {
t.Fatalf("live WAL inspection = %+v, %v", status, err)
}
}
+73 -29
View File
@@ -17,6 +17,7 @@ import (
"reasonix/internal/agent"
"reasonix/internal/projectiondb"
"reasonix/internal/sqliteuri"
)
func (c *Catalog) ReconcileDirectory(ctx context.Context, target DirectoryTarget) error {
@@ -664,6 +665,9 @@ func Inspect(ctx context.Context, path string) (Status, error) {
return status, nil
}
inspection := projectiondb.Inspect(ctx, path)
if err := ctx.Err(); err != nil {
return status, err
}
if inspection.Error != "" && !inspection.Exists {
status.LastError = inspection.Error
return status, nil
@@ -680,38 +684,78 @@ func Inspect(ctx context.Context, path string) (Status, error) {
status.LastError = inspection.Error
return status, nil
}
u := &url.URL{Scheme: "file", Path: path}
db, err := sql.Open("sqlite", u.String()+"?mode=ro&_pragma=busy_timeout%28150%29")
dsn, err := sqliteuri.Disk(path, url.Values{
"mode": {"ro"},
"_pragma": {"busy_timeout(150)"},
})
if err != nil {
return status, err
status.LastError = "build read-only database URI: " + err.Error()
return status, nil
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
status.LastError = "open read-only database: " + err.Error()
return status, nil
}
defer db.Close()
status.State = StateReady
_ = db.QueryRowContext(ctx, `SELECT revision FROM catalog_state WHERE id=1`).Scan(&status.Revision)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions`).Scan(&status.Indexed)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown'`).Scan(&status.RepairPending)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state IN ('pending','active')`).Scan(&status.RepairActive)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state='deferred'`).Scan(&status.RepairDeferred)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state='blocked'`).Scan(&status.RepairBlocked)
_ = db.QueryRowContext(ctx, `SELECT COALESCE(MIN(repair_retry_at),0) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state='deferred'`).Scan(&status.NextRepairAt)
rows, queryErr := db.QueryContext(ctx, `SELECT repair_error_kind,COUNT(*) FROM catalog_sessions
WHERE turns_state='unknown' AND repair_error_kind<>'' GROUP BY repair_error_kind`)
if queryErr == nil {
status.RepairErrorKinds = map[string]int64{}
for rows.Next() {
var kind string
var count int64
if rows.Scan(&kind, &count) == nil {
status.RepairErrorKinds[kind] = count
}
result := status
fail := func(stage string, err error) (Status, error) {
if ctxErr := ctx.Err(); ctxErr != nil {
return status, ctxErr
}
_ = rows.Close()
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return status, err
}
status.LastError = stage + ": " + err.Error()
return status, nil
}
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE missing_since=0`).Scan(&status.PhysicalSessions)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_topics`).Scan(&status.LogicalSessions)
_ = db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT recovery_group_id) FROM catalog_sessions WHERE recovered=1 AND recovery_group_id<>'' AND missing_since=0`).Scan(&status.RecoveryGroups)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND missing_since=0`).Scan(&status.RecoveryBranches)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND recovery_role='diverged' AND missing_since=0`).Scan(&status.RecoveryDiverged)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND recovery_role='covered_copy' AND missing_since=0`).Scan(&status.CleanupEligible)
return status, nil
queries := []struct {
stage string
query string
dest any
}{
{"read catalog revision", `SELECT revision FROM catalog_state WHERE id=1`, &result.Revision},
{"count indexed sessions", `SELECT COUNT(*) FROM catalog_sessions`, &result.Indexed},
{"count pending repairs", `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown'`, &result.RepairPending},
{"count active repairs", `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state IN ('pending','active')`, &result.RepairActive},
{"count deferred repairs", `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state='deferred'`, &result.RepairDeferred},
{"count blocked repairs", `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state='blocked'`, &result.RepairBlocked},
{"read next repair time", `SELECT COALESCE(MIN(repair_retry_at),0) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state='deferred'`, &result.NextRepairAt},
{"count physical sessions", `SELECT COUNT(*) FROM catalog_sessions WHERE missing_since=0`, &result.PhysicalSessions},
{"count logical sessions", `SELECT COUNT(*) FROM catalog_topics`, &result.LogicalSessions},
{"count recovery groups", `SELECT COUNT(DISTINCT recovery_group_id) FROM catalog_sessions WHERE recovered=1 AND recovery_group_id<>'' AND missing_since=0`, &result.RecoveryGroups},
{"count recovery branches", `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND missing_since=0`, &result.RecoveryBranches},
{"count diverged recoveries", `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND recovery_role='diverged' AND missing_since=0`, &result.RecoveryDiverged},
{"count cleanup eligible recoveries", `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND recovery_role='covered_copy' AND missing_since=0`, &result.CleanupEligible},
}
for _, query := range queries {
if err := db.QueryRowContext(ctx, query.query).Scan(query.dest); err != nil {
return fail(query.stage, err)
}
}
rows, err := db.QueryContext(ctx, `SELECT repair_error_kind,COUNT(*) FROM catalog_sessions
WHERE turns_state='unknown' AND repair_error_kind<>'' GROUP BY repair_error_kind`)
if err != nil {
return fail("count repair error kinds", err)
}
result.RepairErrorKinds = map[string]int64{}
for rows.Next() {
var kind string
var count int64
if err := rows.Scan(&kind, &count); err != nil {
_ = rows.Close()
return fail("scan repair error kinds", err)
}
result.RepairErrorKinds[kind] = count
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return fail("iterate repair error kinds", err)
}
if err := rows.Close(); err != nil {
return fail("close repair error kinds", err)
}
result.State = StateReady
result.LastError = ""
return result, nil
}
+37
View File
@@ -0,0 +1,37 @@
// Package sqliteuri builds SQLite file URIs for local disk databases.
package sqliteuri
import (
"errors"
"net/url"
"path/filepath"
"runtime"
"strings"
)
// Disk converts a local disk path into an absolute SQLite file URI. Query
// parameters are encoded separately from the path so path punctuation cannot
// be interpreted as SQLite options.
func Disk(path string, query url.Values) (string, error) {
if strings.TrimSpace(path) == "" {
return "", errors.New("sqlite disk path is empty")
}
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
return disk(abs, query, runtime.GOOS), nil
}
func disk(absPath string, query url.Values, goos string) string {
slash := filepath.ToSlash(absPath)
if goos == "windows" {
slash = strings.ReplaceAll(slash, `\`, "/")
if len(slash) >= 2 && slash[1] == ':' {
slash = "/" + slash
}
}
u := &url.URL{Scheme: "file", Path: slash}
u.RawQuery = query.Encode()
return u.String()
}
+55
View File
@@ -0,0 +1,55 @@
package sqliteuri
import (
"net/url"
"path/filepath"
"strings"
"testing"
)
func TestDiskUsesAbsoluteEncodedPathAndSeparateQuery(t *testing.T) {
query := url.Values{"mode": {"ro"}, "_pragma": {"busy_timeout(5000)", "foreign_keys(1)"}}
dsn, err := Disk(filepath.Join("relative dir", "会话%23#.sqlite"), query)
if err != nil {
t.Fatal(err)
}
u, err := url.Parse(dsn)
if err != nil {
t.Fatal(err)
}
wantSuffix := filepath.ToSlash(filepath.Join("relative dir", "会话%23#.sqlite"))
if !strings.HasSuffix(u.Path, wantSuffix) || !strings.HasPrefix(u.Path, "/") {
t.Fatalf("decoded path = %q, want absolute suffix %q", u.Path, wantSuffix)
}
if u.Query().Get("mode") != "ro" || len(u.Query()["_pragma"]) != 2 {
t.Fatalf("query = %#v", u.Query())
}
if strings.Contains(strings.SplitN(dsn, "?", 2)[0], "#") || !strings.Contains(dsn, "%25") || !strings.Contains(dsn, "%23") {
t.Fatalf("path is not URI encoded: %s", dsn)
}
}
func TestDiskRejectsBlankPath(t *testing.T) {
if _, err := Disk(" \t", nil); err == nil {
t.Fatal("Disk accepted a blank path")
}
}
func TestDiskWindowsDriveAndUNCForms(t *testing.T) {
tests := []struct {
name string
path string
want string
}{
{name: "forward drive", path: `C:/Users/Test User/会话%23#.sqlite`, want: `file:///C:/Users/Test%20User/%E4%BC%9A%E8%AF%9D%2523%23.sqlite`},
{name: "backslash drive", path: `C:\Users\Test User\data.sqlite`, want: `file:///C:/Users/Test%20User/data.sqlite`},
{name: "UNC", path: `\\server\share\Test User\data.sqlite`, want: `file:////server/share/Test%20User/data.sqlite`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := disk(tt.path, nil, "windows"); got != tt.want {
t.Fatalf("disk() = %q, want %q", got, tt.want)
}
})
}
}
+33 -6
View File
@@ -7,6 +7,8 @@ import (
"net/url"
"os"
"path/filepath"
"reasonix/internal/sqliteuri"
)
// BackupExisting reads an existing database without running schema migrations.
@@ -20,16 +22,41 @@ func BackupExisting(ctx context.Context, path, destination string) error {
if !info.Mode().IsRegular() {
return fmt.Errorf("topic state is not a regular file")
}
u := url.URL{Scheme: "file", Path: filepath.ToSlash(path)}
q := u.Query()
if _, err := os.Lstat(destination); err == nil {
return &os.PathError{Op: "backup", Path: destination, Err: os.ErrExist}
} else if !os.IsNotExist(err) {
return err
}
q := url.Values{}
q.Set("mode", "ro")
q.Add("_pragma", "busy_timeout(5000)")
u.RawQuery = q.Encode()
db, err := sql.Open("sqlite", u.String())
dsn, err := sqliteuri.Disk(path, q)
if err != nil {
return err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return err
}
defer db.Close()
_, err = db.ExecContext(ctx, "VACUUM INTO ?", destination)
return err
// Build the snapshot privately and publish it without replacement. VACUUM
// itself accepts existing empty files, and a preflight existence check alone
// cannot protect a destination created while the snapshot is being built.
parent, err := filepath.Abs(filepath.Dir(destination))
if err != nil {
return err
}
tmp, err := os.MkdirTemp(parent, ".topic-backup-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
snapshot := filepath.Join(tmp, "snapshot.sqlite")
if _, err := db.ExecContext(ctx, "VACUUM INTO ?", snapshot); err != nil {
return err
}
if err := os.Chmod(snapshot, 0o600); err != nil {
return err
}
return os.Link(snapshot, destination)
}
+142 -1
View File
@@ -1,14 +1,18 @@
package topicstate
import (
"bytes"
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func TestBackupExistingIncludesWALAndUnknownTables(t *testing.T) {
path := filepath.Join(t.TempDir(), "source.sqlite")
path := filepath.Join(t.TempDir(), "source %20 # 主题.sqlite")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
@@ -34,3 +38,140 @@ func TestBackupExistingIncludesWALAndUnknownTables(t *testing.T) {
t.Fatalf("missing source: %v", err)
}
}
func TestBackupExistingRejectsEmptyDestination(t *testing.T) {
source := filepath.Join(t.TempDir(), "source.sqlite")
db, err := sql.Open("sqlite", source)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`CREATE TABLE preserved(value TEXT); INSERT INTO preserved VALUES('source')`); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
destination := filepath.Join(t.TempDir(), "existing.sqlite")
if err := os.WriteFile(destination, nil, 0600); err != nil {
t.Fatal(err)
}
if err := BackupExisting(t.Context(), source, destination); !errors.Is(err, os.ErrExist) {
t.Fatalf("existing destination error = %v, want ErrExist", err)
}
if got, err := os.ReadFile(destination); err != nil || len(got) != 0 {
t.Fatalf("existing empty file changed: %d bytes, %v", len(got), err)
}
}
func TestBackupExistingDoesNotOverwriteDestinationOrSource(t *testing.T) {
path := filepath.Join(t.TempDir(), "source.sqlite")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`CREATE TABLE preserved(value TEXT); INSERT INTO preserved VALUES('source')`); err != nil {
_ = db.Close()
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
sourceBefore, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
destination := filepath.Join(t.TempDir(), "existing.sqlite")
destinationBefore := []byte("do not replace")
if err := os.WriteFile(destination, destinationBefore, 0o600); err != nil {
t.Fatal(err)
}
if err := BackupExisting(t.Context(), path, destination); err == nil {
t.Fatal("backup unexpectedly replaced an existing destination")
}
if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, sourceBefore) {
t.Fatalf("source changed: err=%v", err)
}
if got, err := os.ReadFile(destination); err != nil || !bytes.Equal(got, destinationBefore) {
t.Fatalf("destination changed: err=%v got=%q", err, got)
}
}
func TestBackupFailureDoesNotPublishPartialDestination(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "corrupt.sqlite")
original := []byte("not a SQLite database")
if err := os.WriteFile(source, original, 0o600); err != nil {
t.Fatal(err)
}
destination := filepath.Join(root, "backup.sqlite")
if err := BackupExisting(t.Context(), source, destination); err == nil {
t.Fatal("corrupt source must fail backup")
}
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
t.Fatalf("failed backup published a destination: %v", err)
}
if got, err := os.ReadFile(source); err != nil || !bytes.Equal(got, original) {
t.Fatalf("failed backup modified source: %q, %v", got, err)
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 1 {
t.Fatalf("backup left temporary artifacts: %+v, %v", entries, err)
}
}
func TestBackupDoesNotReplaceDestinationCreatedDuringSnapshot(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "source.sqlite")
db, err := sql.Open("sqlite", source)
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
if _, err := db.Exec(`CREATE TABLE preserved(value TEXT); INSERT INTO preserved VALUES('source'); BEGIN EXCLUSIVE`); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
destination := filepath.Join(root, "backup.sqlite")
done := make(chan struct{})
var backupErr error
go func() {
backupErr = BackupExisting(ctx, source, destination)
close(done)
}()
defer func() {
cancel()
_, _ = db.Exec(`ROLLBACK`)
<-done
}()
// The exclusive source lock prevents VACUUM from finishing. Observing its
// private directory proves the initial destination check has already passed.
deadline := time.Now().Add(3 * time.Second)
for {
paths, err := filepath.Glob(filepath.Join(root, ".topic-backup-*"))
if err != nil {
t.Fatal(err)
}
if len(paths) > 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("backup did not reach the locked snapshot stage")
}
time.Sleep(10 * time.Millisecond)
}
original := []byte("created by another process after preflight")
if err := os.WriteFile(destination, original, 0o600); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`COMMIT`); err != nil {
t.Fatal(err)
}
<-done
if !errors.Is(backupErr, os.ErrExist) {
t.Fatalf("concurrent destination error=%v, want ErrExist", backupErr)
}
if got, err := os.ReadFile(destination); err != nil || !bytes.Equal(got, original) {
t.Fatalf("concurrent destination changed: %q, %v", got, err)
}
}
+9 -15
View File
@@ -13,11 +13,12 @@ import (
"net/url"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"reasonix/internal/sqliteuri"
moderncsqlite "modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
@@ -85,7 +86,13 @@ func Open(ctx context.Context, path string) (*Store, error) {
}
_ = os.Chmod(filepath.Dir(path), 0o700)
db, err := sql.Open("sqlite", diskFileDSN(path))
dsn, err := sqliteuri.Disk(path, url.Values{
"_pragma": {"busy_timeout(2000)", "foreign_keys(1)"},
})
if err != nil {
return nil, err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
@@ -127,19 +134,6 @@ func Open(ctx context.Context, path string) (*Store, error) {
return &Store{path: path, db: db, now: time.Now}, nil
}
func diskFileDSN(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
slash := filepath.ToSlash(abs)
if runtime.GOOS == "windows" && len(slash) >= 2 && slash[1] == ':' {
slash = "/" + slash
}
u := &url.URL{Scheme: "file", Path: slash}
return u.String() + "?_pragma=busy_timeout%282000%29&_pragma=foreign_keys%281%29"
}
func applyMigrations(ctx context.Context, db *sql.DB, now func() time.Time) error {
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
+8 -1
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"net/url"
"os"
"path/filepath"
"runtime"
@@ -12,6 +13,8 @@ import (
"testing"
"time"
"reasonix/internal/sqliteuri"
_ "modernc.org/sqlite"
)
@@ -167,7 +170,11 @@ func TestMergeMissingTitleIndexDoesNotOverwriteNewerRename(t *testing.T) {
func TestStoreRejectsFutureSchemaWithoutChangingFile(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "topic-state-v1.sqlite")
db, err := sql.Open("sqlite", diskFileDSN(path))
dsn, err := sqliteuri.Disk(path, url.Values{"_pragma": {"busy_timeout(2000)", "foreign_keys(1)"}})
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
+14 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { appendFileSync, readFileSync } from "node:fs";
import { appendFileSync, readFileSync, realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";
const ZERO_SHA = /^0{40}$/;
const ROOT_DOC = /^[^/]+\.md$/;
@@ -136,7 +137,18 @@ function summary(result) {
return lines.join("\n") + "\n";
}
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
function isMainModule() {
// stdin/eval hosts can provide a sentinel or a nonexistent argv[1]. Importing
// this module must not require that host argument to name a filesystem entry.
if (!process.argv[1] || process.argv[1] === "-") return false;
try {
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
} catch {
return false;
}
}
if (isMainModule()) {
try {
const args = parseArgs(process.argv.slice(2));
let files;
+40 -1
View File
@@ -1,9 +1,10 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { pathToFileURL } from "node:url";
import { changedFiles, classifyPaths } from "./ci-paths.mjs";
test("explicit documentation skips build surfaces", () => {
@@ -118,6 +119,44 @@ test("invalid diff identities fail instead of producing a skip", () => {
assert.throws(() => changedFiles({ base: "f".repeat(40), head: "HEAD" }));
});
test("CLI entry runs from paths with URL-significant characters", t => {
const root = mkdtempSync(path.join(os.tmpdir(), "reasonix-ci-entry-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const expectedNames = ["code", "desktop", "desktop_go", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging", "site", "sdk", "notes_only"];
for (const directory of ["ordinary", "with space", "中文", "hash#directory", "literal%20directory"]) {
const target = path.join(root, directory, "ci-paths.mjs");
mkdirSync(path.dirname(target), { recursive: true });
copyFileSync(new URL("./ci-paths.mjs", import.meta.url), target);
const output = execFileSync(process.execPath, [target, "--full"], { encoding: "utf8" });
const values = Object.fromEntries(output.trim().split("\n").map(line => line.split("=")));
assert.deepEqual(Object.keys(values).sort(), [...expectedNames].sort(), directory);
for (const name of expectedNames) assert.equal(values[name], name === "notes_only" ? "false" : "true", `${directory}: ${name}`);
}
});
test("CLI writes GitHub output and module import stays side-effect free", t => {
const root = mkdtempSync(path.join(os.tmpdir(), "reasonix-ci-output-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "hash#literal%20", "ci-paths.mjs");
const outputPath = path.join(root, "GitHub output.txt");
mkdirSync(path.dirname(target), { recursive: true });
copyFileSync(new URL("./ci-paths.mjs", import.meta.url), target);
const stdout = execFileSync(process.execPath, [target, "--full", "--github-output", outputPath], { encoding: "utf8" });
assert.equal(stdout, "");
const output = readFileSync(outputPath, "utf8");
assert.equal(output.trim().split("\n").length, 13);
assert.match(output, /^desktop=true$/m);
assert.match(output, /^notes_only=false$/m);
const imported = execFileSync(process.execPath, ["--input-type=module", "--eval", `import(${JSON.stringify(pathToFileURL(target).href)})`], { encoding: "utf8" });
assert.equal(imported, "");
const stdinImported = execFileSync(process.execPath, ["--input-type=module", "-"], {
input: `await import(${JSON.stringify(pathToFileURL(target).href)});`, encoding: "utf8",
});
assert.equal(stdinImported, "");
});
// site and sdk are in this list because the required aggregates accept a
// skipped site or a no-op sdk; a PR that edits the routing contract would
// otherwise satisfy those expectations without running either surface.
@@ -22,6 +22,13 @@ test("inventory keeps same-named tests from different packages and rejects missi
assert.throws(() => inventoryFromJSON(""), /no desktop test inventory/);
});
test("URI upgrade and preview regressions have exactly one Windows execution owner", () => {
for (const name of ["TestWindowsUpgradeFixtureMigratesLegacyAndRestarts", "TestDesktopV1UpgradeBacksUpTopicWALAndRetriesWithoutDuplicateImport",
"TestChatFileReferencePreservesRawFilenameCharacters", "TestResolveMarkdownImageSelectsPercentAndSpaceNamesExactly"]) {
assert.equal(owners(name).length, 1, name);
}
});
test("CI runs all groups separately and retains the aggregate and native probe", () => {
const source = readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8");
const matrixJob = source.match(/\n desktop-windows-go-group:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]*:|$)/)?.[1];
+14 -2
View File
@@ -2,6 +2,7 @@
param(
[Parameter(Mandatory=$true)][string]$InstallerPath,
[Parameter(Mandatory=$true)][string]$ExpectedVersion,
[string]$FixtureBuilderPath = '',
[string]$EvidenceDirectory = (Join-Path $env:TEMP ('reasonix-installer-' + [guid]::NewGuid().ToString('N'))),
[switch]$DisposableEnvironment
)
@@ -109,7 +110,7 @@ function Assert-UninstalledState {
}
function Invoke-WindowsInstallerAcceptance {
param([string]$InstallerPath, [string]$ExpectedVersion, [string]$EvidenceDirectory, [switch]$DisposableEnvironment)
param([string]$InstallerPath, [string]$ExpectedVersion, [string]$FixtureBuilderPath, [string]$EvidenceDirectory, [switch]$DisposableEnvironment)
$ErrorActionPreference = 'Stop'
if ($ExpectedVersion -notmatch '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$') {
throw "ExpectedVersion is not a canonical Reasonix version: $ExpectedVersion"
@@ -160,11 +161,21 @@ function Invoke-WindowsInstallerAcceptance {
$repair = 'passed'
}
$oldDataUpgrade = 'not requested'
if ($FixtureBuilderPath) {
& (Join-Path $PSScriptRoot 'test-windows-upgrade-startup.ps1') `
-ApplicationPath (Join-Path $install 'Reasonix.exe') -FixtureBuilderPath $FixtureBuilderPath `
-ExpectedVersion $ExpectedVersion -EvidenceDirectory (Join-Path $EvidenceDirectory 'old-data-upgrade')
if ($LASTEXITCODE -ne 0) { throw "Old-data upgrade acceptance failed with exit code $LASTEXITCODE" }
$oldDataUpgrade = 'passed'
}
# Failure preserves the fixture/logs for diagnosis. Uninstall only after
# every tested instance exited normally, never while a failed shell lives.
$dataSnapshot = @{ $defaultSentinel = $defaultHash }
$homes = @($env:REASONIX_HOME, (Join-Path $EvidenceDirectory 'fresh-install\home'))
if ($repair -eq 'passed') { $homes += (Join-Path $EvidenceDirectory 'repaired-install\home') }
if ($oldDataUpgrade -eq 'passed') { $homes += (Join-Path $EvidenceDirectory 'old-data-upgrade\home # %20 中文') }
foreach ($homePath in $homes) {
New-Item -ItemType Directory -Force -Path $homePath | Out-Null
[IO.File]::WriteAllText((Join-Path $homePath 'acceptance-retention.txt'), [guid]::NewGuid().ToString('N'))
@@ -186,6 +197,7 @@ function Invoke-WindowsInstallerAcceptance {
version = $ExpectedVersion
freshInstall = 'passed'
repairedInstall = $repair
oldDataUpgrade = $oldDataUpgrade
uninstall = 'passed'
dataPreserved = 'passed'
repairFrom = if ($repair -eq 'passed') { $truncatedVersion } else { $null }
@@ -200,5 +212,5 @@ function Invoke-WindowsInstallerAcceptance {
# process/registry boundaries; it never runs an installation.
if ($MyInvocation.InvocationName -ne '.') {
Invoke-WindowsInstallerAcceptance -InstallerPath $InstallerPath -ExpectedVersion $ExpectedVersion `
-EvidenceDirectory $EvidenceDirectory -DisposableEnvironment:$DisposableEnvironment
-FixtureBuilderPath $FixtureBuilderPath -EvidenceDirectory $EvidenceDirectory -DisposableEnvironment:$DisposableEnvironment
}
+34 -2
View File
@@ -4,13 +4,16 @@ param(
[Parameter(Mandatory=$true, ParameterSetName='Installed')][string]$InstallRoot,
[string]$ExpectedVersion = '',
[string]$ArtifactPath = '',
[string]$DataHome = '',
[string]$ExpectedVisibleText = '',
[string]$EvidenceDirectory = (Join-Path $env:TEMP ('reasonix-recovery-' + [guid]::NewGuid().ToString('N')))
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'windows-acceptance-environment.ps1')
. (Join-Path $PSScriptRoot 'windows-upgrade-ui-evidence.ps1')
New-Item -ItemType Directory -Force $EvidenceDirectory | Out-Null
$install = if ($PSCmdlet.ParameterSetName -eq 'Installed') { [IO.Path]::GetFullPath($InstallRoot) } else { Join-Path $EvidenceDirectory 'install' }
$dataHome = Join-Path $EvidenceDirectory 'home'
$dataHome = if ($DataHome) { [IO.Path]::GetFullPath($DataHome) } else { Join-Path $EvidenceDirectory 'home' }
if ($PSCmdlet.ParameterSetName -eq 'Portable') {
if (Test-Path $install) { throw 'Evidence install directory must be new; refusing to overwrite a running fixture.' }
Expand-Archive -LiteralPath $PortableZip -DestinationPath $install
@@ -58,6 +61,32 @@ function Assert-Ready {
throw 'No verified target shell; inspect preserved logs'
}
function Assert-VisibleContentAndCapture($process, [string]$text, [string]$outputPath) {
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName System.Drawing
$deadline = [DateTime]::UtcNow.AddSeconds(20)
$found = $false
$root = $null
while ([DateTime]::UtcNow -lt $deadline -and -not $found) {
$process.Refresh()
if ($process.MainWindowHandle -ne 0) {
$root = [Windows.Automation.AutomationElement]::FromHandle($process.MainWindowHandle)
$found = Test-VisibleUpgradeHistory $root $text
}
if (-not $found) { Start-Sleep -Milliseconds 250 }
}
if ($null -eq $root) { throw 'The packaged shell did not expose a native window for upgrade evidence.' }
$bounds = $root.Current.BoundingRectangle
if ($bounds.Width -le 0 -or $bounds.Height -le 0) { throw 'The packaged shell window has invalid bounds.' }
$bitmap = [Drawing.Bitmap]::new([int]$bounds.Width, [int]$bounds.Height)
try {
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try { $graphics.CopyFromScreen([int]$bounds.X, [int]$bounds.Y, 0, 0, $bitmap.Size) } finally { $graphics.Dispose() }
$bitmap.Save($outputPath, [Drawing.Imaging.ImageFormat]::Png)
} finally { $bitmap.Dispose() }
if (-not $found) { throw "Packaged UI did not expose expected upgraded history text: $text" }
}
$acceptanceEnvironment = Enter-WindowsAcceptanceEnvironment -DataHome $dataHome
try {
$attempt = Start-Process $launcher -PassThru -RedirectStandardOutput (Join-Path $EvidenceDirectory 'launcher.stdout.log') -RedirectStandardError (Join-Path $EvidenceDirectory 'launcher.stderr.log')
@@ -70,6 +99,9 @@ try {
if (-not $again.WaitForExit(40000) -or $again.ExitCode -ne 0) { throw 'Second launch failed or timed out' }
$second = Assert-Ready
if ($first.Shell.Id -ne $second.Shell.Id -or $first.Service.Id -ne $second.Service.Id -or $first.Status.generation -ne $second.Status.generation) { throw 'Second launch replaced the healthy instance' }
if ($ExpectedVisibleText) {
Assert-VisibleContentAndCapture $first.Shell $ExpectedVisibleText (Join-Path $EvidenceDirectory 'upgraded-history.png')
}
Start-Process $shellPath -ArgumentList '--reasonix-lifecycle-request=quit' | Out-Null
if (-not $first.Shell.WaitForExit(15000) -or -not $first.Service.WaitForExit(1000)) { throw 'Normal exit left a shell or service alive' }
$shellLog = Get-Content (Join-Path $dataHome 'desktop-shell\logs\shell.log') -Raw
@@ -82,7 +114,7 @@ try {
architecture=$env:PROCESSOR_ARCHITECTURE
shellPID=$first.Shell.Id
servicePID=$first.Service.Id
startup='passed'; secondLaunch='passed'; normalExit='passed'
startup='passed'; secondLaunch='passed'; normalExit='passed'; visibleHistory=if ($ExpectedVisibleText) { 'passed' } else { 'not requested' }
signing='not checked'; installer=if ($PSCmdlet.ParameterSetName -eq 'Installed') { 'passed' } else { 'not exercised' }; recoveryConsent='not exercised'
}
$report | ConvertTo-Json | Set-Content (Join-Path $EvidenceDirectory 'result.json')
+65
View File
@@ -0,0 +1,65 @@
[CmdletBinding()]
param(
[string]$ApplicationPath,
[string]$FixtureBuilderPath,
[string]$ExpectedVersion,
[string]$EvidenceDirectory
)
$ErrorActionPreference = 'Stop'
function Invoke-UpgradeFixture([string]$builder, [string[]]$arguments) {
& $builder @arguments
if ($LASTEXITCODE -ne 0) { throw "Upgrade fixture failed with exit code $LASTEXITCODE" }
}
function Invoke-UpgradeStartup([string]$installRoot, [string]$version, [string]$fixtureHome, [string]$text, [string]$evidence) {
& (Join-Path $PSScriptRoot 'test-windows-startup-recovery.ps1') `
-InstallRoot $installRoot -ExpectedVersion $version -DataHome $fixtureHome `
-ExpectedVisibleText $text -EvidenceDirectory $evidence
if (-not $?) { throw 'Upgraded startup verification failed.' }
}
function Invoke-WindowsUpgradeAcceptance {
param(
[Parameter(Mandatory=$true)][string]$ApplicationPath,
[Parameter(Mandatory=$true)][string]$FixtureBuilderPath,
[Parameter(Mandatory=$true)][string]$ExpectedVersion,
[Parameter(Mandatory=$true)][string]$EvidenceDirectory
)
$application = (Resolve-Path -LiteralPath $ApplicationPath).Path
$fixtureBuilder = (Resolve-Path -LiteralPath $FixtureBuilderPath).Path
$installRoot = Split-Path $application -Parent
$evidence = [IO.Path]::GetFullPath($EvidenceDirectory)
if (Test-Path -LiteralPath $evidence) { throw 'Upgrade acceptance evidence directory must be new.' }
$current = Get-Content -LiteralPath (Join-Path $installRoot 'current.json') -Raw | ConvertFrom-Json
$release = Join-Path $installRoot $current.activeDir
$source = Get-Content -LiteralPath (Join-Path $release 'app/resources/build.json') -Raw | ConvertFrom-Json
if ($current.activeVersion -ne $ExpectedVersion -or $source.version -ne $ExpectedVersion) {
throw 'Upgrade acceptance package version does not match the expected version.'
}
if ([string]$source.commit -notmatch '^[0-9a-fA-F]{40}$') {
throw 'Packaged build.json must contain a full source commit before recording acceptance.'
}
New-Item -ItemType Directory -Path $evidence | Out-Null
$fixtureHome = Join-Path $evidence 'home # %20 中文'
$fixtureReport = Join-Path $evidence 'fixture.json'
Invoke-UpgradeFixture $fixtureBuilder @('--mode', 'create', '--home', $fixtureHome, '--report', $fixtureReport)
$fixture = Get-Content -LiteralPath $fixtureReport -Raw | ConvertFrom-Json
if ([string]::IsNullOrWhiteSpace($fixture.visibleText)) { throw 'Fixture assistant body marker is missing.' }
foreach ($phase in @('first', 'restart')) {
Invoke-UpgradeStartup $installRoot $ExpectedVersion $fixtureHome $fixture.visibleText (Join-Path $evidence $phase)
Invoke-UpgradeFixture $fixtureBuilder @('--mode', 'verify', '--home', $fixtureHome, '--report', $fixtureReport, '--phase', $phase)
}
@{
version = $ExpectedVersion
sourceSha = $source.commit
applicationSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $application).Hash
oldDataUpgrade = 'passed'
restart = 'passed'
visibleHistory = 'passed'
registryBackup = 'passed'
topicUnknownData = 'passed'
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $evidence 'result.json') -Encoding utf8
}
if ($MyInvocation.InvocationName -ne '.') { Invoke-WindowsUpgradeAcceptance @PSBoundParameters }
@@ -0,0 +1,102 @@
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'test-windows-upgrade-startup.ps1')
. (Join-Path $PSScriptRoot 'windows-upgrade-ui-evidence.ps1')
function Assert-True($value, [string]$message) { if (-not $value) { throw $message } }
function Assert-Throws([scriptblock]$action, [string]$message) {
$caught = $false
try { & $action } catch { $caught = $true }
Assert-True $caught $message
}
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('reasonix-upgrade-contract-' + [guid]::NewGuid().ToString('N'))
$savedHome = $HOME
try {
$release = Join-Path $testRoot 'install/releases/test/app/resources'
New-Item -ItemType Directory -Path $release -Force | Out-Null
$application = Join-Path $testRoot 'install/Reasonix.exe'
$builder = Join-Path $testRoot 'fixture.exe'
Set-Content -LiteralPath $application -Value 'mock launcher'
Set-Content -LiteralPath $builder -Value 'mock builder'
$version = 'v1.38.9-5'
$commit = '0123456789abcdef0123456789abcdef01234567'
@{activeVersion=$version; activeDir='releases/test'} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $testRoot 'install/current.json')
$buildPath = Join-Path $release 'build.json'
@{version=$version; commit=$commit} | ConvertTo-Json | Set-Content -LiteralPath $buildPath
$script:calls = [Collections.Generic.List[string]]::new()
$script:failPhase = ''
# Only replace native process boundaries; run the real evidence orchestration.
function Invoke-UpgradeFixture([string]$builder, [string[]]$arguments) {
$mode = $arguments[1]
$fixtureHome = $arguments[3]
$report = $arguments[5]
Assert-True ($fixtureHome -ne $HOME -and $fixtureHome.Contains('home # %20 中文')) 'Fixture must use a disposable home.'
if ($mode -eq 'create') {
$script:calls.Add('create')
@{visibleText='assistant-only marker'} | ConvertTo-Json | Set-Content -LiteralPath $report
} else {
$phase = $arguments[7]
$script:calls.Add("verify-$phase")
if ($script:failPhase -eq $phase) { throw 'Injected verification failure' }
}
}
function Invoke-UpgradeStartup([string]$installRoot, [string]$version, [string]$fixtureHome, [string]$text, [string]$evidence) {
Assert-True ($text -eq 'assistant-only marker') 'Startup must require the assistant marker.'
$script:calls.Add('startup-' + (Split-Path $evidence -Leaf))
}
$evidence = Join-Path $testRoot 'success'
Invoke-WindowsUpgradeAcceptance $application $builder $version $evidence
$result = Get-Content -LiteralPath (Join-Path $evidence 'result.json') -Raw | ConvertFrom-Json
Assert-True ($HOME -eq $savedHome) 'Acceptance changed the PowerShell home.'
Assert-True ($result.sourceSha -eq $commit) 'Evidence must read build.json.commit.'
Assert-True ($result.applicationSha256 -eq (Get-FileHash -Algorithm SHA256 -LiteralPath $application).Hash) 'Evidence must hash the tested application.'
Assert-True (($script:calls -join ',') -eq 'create,startup-first,verify-first,startup-restart,verify-restart') 'Upgrade and restart must both be checked in order.'
foreach ($invalidCommit in @('', 'unknown', '0123456')) {
@{version=$version; commit=$invalidCommit; sourceSha=$commit} | ConvertTo-Json | Set-Content -LiteralPath $buildPath
$badEvidence = Join-Path $testRoot ('invalid-' + [guid]::NewGuid().ToString('N'))
Assert-Throws { Invoke-WindowsUpgradeAcceptance $application $builder $version $badEvidence } 'Missing/invalid commit must fail closed.'
Assert-True (-not (Test-Path -LiteralPath $badEvidence)) 'Invalid package must not get acceptance evidence.'
}
@{version=$version; commit=$commit} | ConvertTo-Json | Set-Content -LiteralPath $buildPath
foreach ($phase in @('first', 'restart')) {
$script:failPhase = $phase
$badEvidence = Join-Path $testRoot "failure-$phase"
Assert-Throws { Invoke-WindowsUpgradeAcceptance $application $builder $version $badEvidence } 'Data verification failure must propagate.'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $badEvidence 'result.json'))) 'Failed verification cannot publish a pass.'
}
# Model the native tree enumeration boundary. Selection and visibility checks
# below use the same function as packaged Windows acceptance.
function Get-UpgradeUIDescendants($element) {
foreach ($child in $element.Children) { $child; Get-UpgradeUIDescendants $child }
}
function New-UIElement([string]$name, [string]$id='', [bool]$offscreen=$false, [object[]]$children=@()) {
return [pscustomobject]@{
Current=[pscustomobject]@{Name=$name; AutomationId=$id; IsOffscreen=$offscreen; BoundingRectangle=[pscustomobject]@{Width=200; Height=20}}
Children=$children
}
}
$marker = 'assistant-only marker'
$body = New-UIElement $marker
$transcript = New-UIElement '' 'reasonix-chat-transcript-upgrade-tab' $false @($body)
$sidebar = New-UIElement $marker 'sidebar-topic'
$root = New-UIElement '' '' $false @($sidebar)
Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'A matching sidebar title must not pass.'
$root.Children = @($sidebar, $transcript)
Assert-True (Test-VisibleUpgradeHistory $root $marker) 'Visible transcript assistant body should pass.'
$body.Current.IsOffscreen = $true
Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'Offscreen history must not pass.'
$body.Current.IsOffscreen = $false
$transcript.Current.IsOffscreen = $true
Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'Hidden transcript must not pass.'
$transcript.Current.IsOffscreen = $false
$body.Current.BoundingRectangle.Width = 0
Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'Zero-size history must not pass.'
$body.Current.BoundingRectangle.Width = 200
$body.Current.Name = 'unrelated body'
Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'Matching text outside the transcript must not pass.'
Write-Host 'Windows upgrade orchestration and UI evidence contracts passed (mocked native boundaries).'
} finally {
Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue
}
+2 -1
View File
@@ -9,7 +9,8 @@ const smokeRoots = internalRoots(
// persistentshell drives a real ConPTY and a PowerShell wrapper that no other
// platform exercises, so Windows is the only lane that can prove it.
"persistentshell", "proc",
"remote", "repair", "sandbox", "sessioncatalog", "sysproxy", "winsandbox", "workspacelease",
"lsp", "projectiondb", "remote", "repair", "sandbox", "sessioncatalog", "sqliteuri", "sysproxy",
"topicstate", "winsandbox", "workspacelease",
).concat("reasonix/cmd");
export function selectPackages(packages, group) {
+8 -2
View File
@@ -6,7 +6,9 @@ import { isolatedGroups, selectPackages, testArgs } from "./windows-go-tests.mjs
const packages = ["reasonix/cmd/reasonix", "reasonix/internal/agent", "reasonix/internal/agent/testutil",
"reasonix/internal/agentpreset", "reasonix/internal/boot", "reasonix/internal/control",
"reasonix/internal/control/child", "reasonix/internal/extension/sidecar", "reasonix/internal/proc",
"reasonix/internal/newpackage", "reasonix/internal/winsandbox", "reasonix/tools/repolint"];
"reasonix/internal/lsp", "reasonix/internal/newpackage", "reasonix/internal/projectiondb",
"reasonix/internal/sessioncatalog", "reasonix/internal/sqliteuri", "reasonix/internal/topicstate",
"reasonix/internal/winsandbox", "reasonix/tools/repolint"];
test("the full Windows groups cover every package exactly once, including new packages", () => {
const grouped = ["full", ...isolatedGroups].flatMap(group => selectPackages(packages, group));
@@ -16,7 +18,11 @@ test("the full Windows groups cover every package exactly once, including new pa
});
test("PR smoke keeps platform coverage without duplicating isolated suites", () => {
assert.deepEqual(selectPackages(packages, "smoke"), ["reasonix/cmd/reasonix", "reasonix/internal/extension/sidecar", "reasonix/internal/proc", "reasonix/internal/winsandbox"]);
assert.deepEqual(selectPackages(packages, "smoke"), [
"reasonix/cmd/reasonix", "reasonix/internal/extension/sidecar", "reasonix/internal/proc", "reasonix/internal/lsp",
"reasonix/internal/projectiondb", "reasonix/internal/sessioncatalog", "reasonix/internal/sqliteuri",
"reasonix/internal/topicstate", "reasonix/internal/winsandbox",
]);
for (const group of isolatedGroups) {
assert.deepEqual(testArgs(packages, group).slice(0, 4), ["test", "-p", "1", "-timeout=8m"]);
}
+18
View File
@@ -0,0 +1,18 @@
# The selector is deliberately scoped to the rendered transcript. Sidebar topic
# titles and offscreen accessibility nodes are not evidence of restored history.
function Get-UpgradeUIDescendants($element) {
return $element.FindAll([Windows.Automation.TreeScope]::Descendants, [Windows.Automation.Condition]::TrueCondition)
}
function Test-VisibleUpgradeHistory($root, [string]$text) {
if ([string]::IsNullOrWhiteSpace($text)) { return $false }
foreach ($transcript in (Get-UpgradeUIDescendants $root)) {
if (-not ([string]$transcript.Current.AutomationId).StartsWith('reasonix-chat-transcript-') -or $transcript.Current.IsOffscreen) { continue }
foreach ($element in (Get-UpgradeUIDescendants $transcript)) {
$current = $element.Current
if (-not $current.IsOffscreen -and $current.BoundingRectangle.Width -gt 0 -and $current.BoundingRectangle.Height -gt 0 -and
$current.Name.Contains($text)) { return $true }
}
}
return $false
}