mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
8dfe505ee4
commit
30bd8ae1ba
39 files changed
+1855
-120
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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, ®istry); 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user