mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-10 10:01:48 -10:00
Compare commits
7
Commits
studio-v2.59.0
...
studio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dc2b1938b | ||
|
|
6c9c6851af | ||
|
|
7facc49510 | ||
|
|
e19fc12a59 | ||
|
|
2ff95dbc5e | ||
|
|
f4a646549c | ||
|
|
302b6732a5 |
No files matched your search
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/safety/sandbox"
|
||||
)
|
||||
@@ -57,11 +58,11 @@ func TestMemoryBenchAnswerIsolationAtSandboxBoundary(t *testing.T) {
|
||||
if err := validateAnswerIsolation(tasks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work := t.TempDir()
|
||||
work := testenv.TempDir(t)
|
||||
if err := stageAnswerIsolation(tasks[0].answerRoot, work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := t.TempDir()
|
||||
home := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[sandbox]\nbash = \"off\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -105,7 +106,7 @@ func TestAnswerIsolationRefusesUnsafeWorkdirAndExistingConfig(t *testing.T) {
|
||||
if err := stageAnswerIsolation(answerRoot, filepath.Dir(answerRoot)); err == nil {
|
||||
t.Fatal("workdir inside the checkout was accepted")
|
||||
}
|
||||
work := t.TempDir()
|
||||
work := testenv.TempDir(t)
|
||||
path := filepath.Join(work, "reasonix.toml")
|
||||
if err := os.WriteFile(path, []byte("# task fixture\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -120,7 +121,7 @@ func TestAnswerIsolationRefusesUnsafeWorkdirAndExistingConfig(t *testing.T) {
|
||||
|
||||
func TestRunTaskDropsIsolationConfigBeforeGrading(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
suite := t.TempDir()
|
||||
suite := testenv.TempDir(t)
|
||||
taskDir := filepath.Join(suite, "tasks", "demo")
|
||||
if err := os.MkdirAll(filepath.Join(taskDir, "memory", "project"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -129,7 +130,7 @@ func TestRunTaskDropsIsolationConfigBeforeGrading(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(verify), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(t.TempDir(), "fake-agent")
|
||||
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -e
|
||||
test -f reasonix.toml
|
||||
@@ -154,7 +155,7 @@ done
|
||||
|
||||
func TestMemoryBenchRejectsAnswerReadInTrajectory(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
suite := t.TempDir()
|
||||
suite := testenv.TempDir(t)
|
||||
taskDir := filepath.Join(suite, "tasks", "demo")
|
||||
answer := filepath.Join(taskDir, "memory", "project", "fact.md")
|
||||
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
|
||||
@@ -166,7 +167,7 @@ func TestMemoryBenchRejectsAnswerReadInTrajectory(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte("#!/bin/sh\ntest -f answer.txt\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(t.TempDir(), "fake-agent")
|
||||
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -e
|
||||
printf 'ok\n' > answer.txt
|
||||
@@ -194,7 +195,7 @@ done
|
||||
|
||||
func TestMissingAnswerTrajectoryKeepsOtherTasksRunning(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
root := filepath.Join(t.TempDir(), "tasks")
|
||||
root := filepath.Join(testenv.TempDir(t), "tasks")
|
||||
var tasks []task
|
||||
for _, id := range []string{"missing", "complete"} {
|
||||
dir := filepath.Join(root, id)
|
||||
@@ -206,7 +207,7 @@ func TestMissingAnswerTrajectoryKeepsOtherTasksRunning(t *testing.T) {
|
||||
}
|
||||
tasks = append(tasks, task{ID: id, Prompt: id, TimeoutSec: 30, dir: dir, answerRoot: root})
|
||||
}
|
||||
bin := filepath.Join(t.TempDir(), "fake-agent")
|
||||
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -e
|
||||
printf 'ok\n' > answer.txt
|
||||
@@ -251,7 +252,7 @@ func TestAnswerTrajectoryAuditDistinguishesReadsFromListingsAndDenials(t *testin
|
||||
{"glob lists name only", `{"event":{"kind":"tool_result","tool":{"name":"glob","args":"{\"pattern\":\"/repo/tasks/demo/memory/project/*.md\"}","output":"/repo/tasks/demo/memory/project/fact.md"}}}`, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "run.trajectory.jsonl")
|
||||
path := filepath.Join(testenv.TempDir(t), "run.trajectory.jsonl")
|
||||
if err := os.WriteFile(path, []byte(tc.data+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -264,7 +265,7 @@ func TestAnswerTrajectoryAuditDistinguishesReadsFromListingsAndDenials(t *testin
|
||||
}
|
||||
|
||||
func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "tasks")
|
||||
root := filepath.Join(testenv.TempDir(t), "tasks")
|
||||
answer := filepath.Join(root, "demo", "memory", "project", "fact.md")
|
||||
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -272,7 +273,7 @@ func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
|
||||
if err := os.WriteFile(answer, []byte("---\nname: fact\n---\nA fact body in the first trajectory leg.\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
for index, data := range []string{
|
||||
`{"event":{"kind":"tool_result","tool":{"name":"bash","args":"{\"command\":\"cat /repo/tasks/demo/memory/project/fact.md\"}","output":"A fact body in the first trajectory leg."}}}`,
|
||||
`{"event":{"kind":"text","text":"done"}}`,
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// fixtureRows is a synthetic batch: three solvable tasks, one of them solved on
|
||||
@@ -64,7 +66,7 @@ func timeAt(s string) time.Time {
|
||||
|
||||
func writeFixtureBatch(t *testing.T) (string, batchManifest) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
if err := writeBatch(fixtureProvenance(dir), fixtureRows()); err != nil {
|
||||
t.Fatalf("write batch: %v", err)
|
||||
}
|
||||
@@ -174,7 +176,7 @@ func TestBatchAuditRoundTrip(t *testing.T) {
|
||||
// The axes carry what changes a figure's meaning, never the operator's machine:
|
||||
// meter/tape/trajectory paths and steer text stay out of the manifest.
|
||||
func TestBatchAxesKeepPathsAndFreeTextOut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
prov := fixtureProvenance(dir)
|
||||
prov.axes = batchAxes(suiteConfig{
|
||||
profile: "delivery", cacheArm: "warm", effort: "high", anchor: "blind",
|
||||
@@ -260,7 +262,7 @@ func TestBatchAuditNamesTheFigureThatMoved(t *testing.T) {
|
||||
// manifest, no commit to tie the figures to, and rows cut short.
|
||||
func TestBatchAuditRefusesUnverifiableBatches(t *testing.T) {
|
||||
t.Run("missing manifest", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
writeRows(t, dir, fixtureRows())
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
@@ -387,7 +389,7 @@ const manifestWithoutOptionals = `{
|
||||
`
|
||||
|
||||
func TestBatchAuditReadsAbsentFieldsAsUnknown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
writeRows(t, dir, fixtureRows())
|
||||
_, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// benchmarksRoot holds every committed task corpus. Validation discovers the
|
||||
@@ -363,7 +364,7 @@ func fullDecisions() map[authoringRule]ruleDecision {
|
||||
func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
|
||||
universe := func(t *testing.T, corpora ...string) (string, []corpusPolicy) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var policies []corpusPolicy
|
||||
for _, name := range corpora {
|
||||
dir := tempCorpus(t, root, name, "t-"+name, "")
|
||||
@@ -437,7 +438,7 @@ func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
|
||||
name: "a registry entry that shadows the root's corpus by name",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
elsewhere := tempCorpus(t, t.TempDir(), "registered", "t-impostor", "")
|
||||
elsewhere := tempCorpus(t, testenv.TempDir(t), "registered", "t-impostor", "")
|
||||
policies[0].Dir = elsewhere
|
||||
return root, policies
|
||||
},
|
||||
@@ -505,7 +506,7 @@ func TestStopPolicyAppliesToEveryRegisteredCorpus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
corpus string
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestWilsonAndNewcombeMatchKnownValues(t *testing.T) {
|
||||
@@ -42,7 +44,7 @@ func writeReport(t *testing.T, dir, name, capability string, pass int, n int) st
|
||||
}
|
||||
|
||||
func TestReproductionSection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
a := writeReport(t, dir, "a.json", "sha256:aaaaaaaaaaaaaaaa", 40, 50)
|
||||
same := writeReport(t, dir, "b.json", "sha256:aaaaaaaaaaaaaaaa", 38, 50)
|
||||
got := reproductionSection(a, same)
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func withFakeIO(t *testing.T, w func(string, []byte, os.FileMode) error, r func(string) ([]byte, error)) {
|
||||
@@ -38,7 +40,7 @@ func TestRestoreSourceFailsWhenTheBytesDoNotComeBack(t *testing.T) {
|
||||
|
||||
// The real filesystem is the other witness: writing over a directory fails.
|
||||
func TestRestoreSourceFailsOnARealWriteError(t *testing.T) {
|
||||
if err := restoreSource(t.TempDir(), []byte("original")); err == nil {
|
||||
if err := restoreSource(testenv.TempDir(t), []byte("original")); err == nil {
|
||||
t.Fatal("restoring onto a directory reported success")
|
||||
}
|
||||
}
|
||||
@@ -47,7 +49,7 @@ func TestTreeMatchesSeesADirtyTree(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
repo := testenv.TempDir(t)
|
||||
git := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
@@ -88,7 +90,7 @@ func TestRunMutationFailsWhenTheRestoreFails(t *testing.T) {
|
||||
if _, err := exec.LookPath("go"); err != nil {
|
||||
t.Skip("go unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
repo := testenv.TempDir(t)
|
||||
git := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
@@ -150,7 +152,7 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
repo := testenv.TempDir(t)
|
||||
git := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
@@ -197,7 +199,7 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
|
||||
// tree must not be left on base just because the failure happened early. The
|
||||
// defer that restores is registered before the check for exactly this path.
|
||||
func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
repo := testenv.TempDir(t)
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
|
||||
@@ -29,7 +29,7 @@ printf '%%s' "$metrics" > metrics-path.txt
|
||||
printf '%%s' '%s' > "$metrics%s"
|
||||
exit %d
|
||||
`, body, suffix, code)
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
path := filepath.Join(testenv.TempDir(t), name)
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write stub: %v", err)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ case "$metrics" in
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
path := filepath.Join(testenv.TempDir(t), name)
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write stub: %v", err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
|
||||
@@ -44,7 +46,7 @@ func readAll(t *testing.T, resp *http.Response) string {
|
||||
// A run replayed from its tape gets the recorded stream byte for byte, never
|
||||
// reaches the provider, and is metered as the recorded run was.
|
||||
func TestTapeReplaysARecordedRunWithoutTheProvider(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
live := readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
@@ -71,7 +73,7 @@ func TestTapeReplaysARecordedRunWithoutTheProvider(t *testing.T) {
|
||||
// A kernel change that alters what the model is asked shows as the first
|
||||
// request whose body departs from the tape, named down to the message.
|
||||
func TestTapeReplayNamesTheFirstRequestThatDiffers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
@@ -123,7 +125,7 @@ func waitFor(t *testing.T, what string, cond func() bool) {
|
||||
// A recording whose request file cannot be written is reported rather than
|
||||
// treated as a tape: request.json is what marks the exchange complete.
|
||||
func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
orig := tapeWriteFile
|
||||
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
|
||||
if strings.HasSuffix(path, "request.json") {
|
||||
@@ -161,7 +163,7 @@ func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
|
||||
// A tape with no recorded request is incomplete, not a divergence: there is
|
||||
// nothing to compare against, and blaming the harness would be wrong.
|
||||
func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
@@ -193,7 +195,7 @@ func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
|
||||
// first: stopping is what waits for the handlers whose deferred writes carry the
|
||||
// tape counters. Snapshotting before that misses the last exchange.
|
||||
func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
@@ -214,7 +216,7 @@ func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
|
||||
// Re-recording over an existing tape must not let the previous run's
|
||||
// completeness marker make a partial new exchange look complete.
|
||||
func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
@@ -263,7 +265,7 @@ func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
|
||||
// incomplete exchange and prints no line about one. Only the failure paths were
|
||||
// pinned before.
|
||||
func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base := testenv.TempDir(t)
|
||||
app := filepath.Join(base, "ReasonixStudio")
|
||||
exe := filepath.Join(app, "Reasonix Studio.exe")
|
||||
cases := []struct {
|
||||
@@ -33,7 +34,7 @@ func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base := testenv.TempDir(t)
|
||||
app := filepath.Join(base, "ReasonixStudio")
|
||||
exe := filepath.Join(app, "Reasonix Studio.exe")
|
||||
mk := func(name string) string {
|
||||
@@ -68,7 +69,7 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base := testenv.TempDir(t)
|
||||
home := filepath.Join(base, "state")
|
||||
app := filepath.Join(base, "app")
|
||||
a, b := filepath.Join(base, "a"), filepath.Join(base, "b")
|
||||
@@ -96,7 +97,7 @@ func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLaunchWithNothingChosenOrRememberedOpensTheAutoWorkspaceNotHome(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base := testenv.TempDir(t)
|
||||
state := filepath.Join(base, "state")
|
||||
home := filepath.Join(base, "home")
|
||||
app := filepath.Join(base, "app")
|
||||
@@ -127,7 +128,7 @@ func TestLaunchWithNothingChosenOrRememberedOpensTheAutoWorkspaceNotHome(t *test
|
||||
}
|
||||
|
||||
func TestPickLaunchWorkspaceCreatesTheAutoWorkspaceLast(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base := testenv.TempDir(t)
|
||||
app := filepath.Join(base, "app")
|
||||
exe := filepath.Join(app, "Reasonix Studio")
|
||||
auto := filepath.Join(base, "state", "workspaces", "auto")
|
||||
|
||||
@@ -112,7 +112,7 @@ func (r recordingRunner) Run(_ context.Context, input string) error {
|
||||
// hub, its config path and its capabilities are the ones the host builds.
|
||||
func testHub(t *testing.T, cfg config.ServeConfig) (*serve.Hub, chan string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
got := make(chan string, 1)
|
||||
bc := serve.NewBroadcaster()
|
||||
ctrl := control.New(control.Options{
|
||||
@@ -382,7 +382,7 @@ func TestOnlyAPipeIsAParentLease(t *testing.T) {
|
||||
t.Error("the null device was read as a parent holding this host open")
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "not-a-pipe")
|
||||
path := filepath.Join(testenv.TempDir(t), "not-a-pipe")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -399,7 +399,7 @@ func TestOnlyAPipeIsAParentLease(t *testing.T) {
|
||||
// The whole run, driven the way Electron drives it: a pipe for the lease, one
|
||||
// line of handshake, traffic, then the parent letting go.
|
||||
func TestRunServesUntilTheParentLetsGo(t *testing.T) {
|
||||
page := t.TempDir()
|
||||
page := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(page, "index.html"), []byte("<title>studio</title>"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/platform/packagegrant"
|
||||
)
|
||||
|
||||
func TestStripPackageGrantsOnlyAnswersForAnApplication(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
for name, app := range map[string]string{
|
||||
"no application": "",
|
||||
"a path that is nothing": filepath.Join(dir, "missing.exe"),
|
||||
@@ -27,7 +28,7 @@ func TestStripPackageGrantsOnlyAnswersForAnApplication(t *testing.T) {
|
||||
// The shell reads this line as structure, so both lists are present even when
|
||||
// empty: a null would be a third answer it has to guess about.
|
||||
func TestStripPackageGrantsReportsOneLineOfJSON(t *testing.T) {
|
||||
app := filepath.Join(t.TempDir(), "Reasonix Studio.exe")
|
||||
app := filepath.Join(testenv.TempDir(t), "Reasonix Studio.exe")
|
||||
if err := os.WriteFile(app, []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestLinuxReleaseBuildsAndCollectsBothNativeArchitectures(t *testing.T) {
|
||||
@@ -84,7 +86,7 @@ func TestLinuxCIPackageMatrixPreservesUpstreamScope(t *testing.T) {
|
||||
{"pull_request", "desktop/electron/test/unit.mjs", true},
|
||||
} {
|
||||
t.Run(tc.event+"/"+tc.file, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
// A shell function owns command lookup on every host, including Git
|
||||
// Bash on Windows; a PATH shim can resolve the runner's real git.
|
||||
git := "git() { case \"$*\" in 'diff -z --name-only --no-renames fixture HEAD') printf '%s\\0' \"$MATRIX_TEST_FILE\" ;; 'diff --name-only --no-renames fixture HEAD') printf '%s\\n' \"$MATRIX_TEST_FILE\" ;; *) return 1 ;; esac; };\n"
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"aead.dev/minisign"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
@@ -28,7 +29,7 @@ func TestSignFiles(t *testing.T) {
|
||||
t.Setenv("MINISIGN_PRIVATE_KEY", string(enc))
|
||||
t.Setenv("MINISIGN_PASSWORD", "pw")
|
||||
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
artifact := filepath.Join(dir, "Reasonix-linux-amd64.tar.gz")
|
||||
payload := []byte("pretend this is a release tarball")
|
||||
if err := os.WriteFile(artifact, payload, 0o644); err != nil {
|
||||
@@ -51,7 +52,7 @@ func TestSignFiles(t *testing.T) {
|
||||
// every platform is listed with a download URL, a parallel .minisig URL, and a
|
||||
// non-empty digest. The .minisig and latest.json files must be ignored.
|
||||
func TestGenManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
names := []string{
|
||||
"Reasonix-darwin-arm64.zip",
|
||||
"Reasonix-darwin-amd64.zip",
|
||||
@@ -158,7 +159,7 @@ func TestGenManifest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenManifestCanReuseStableNotesForStandaloneRC(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "Reasonix-linux-amd64.tar.gz"), []byte("rc"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -181,7 +182,7 @@ func TestGenManifestCanReuseStableNotesForStandaloneRC(t *testing.T) {
|
||||
// TestGenManifestIgnoresUnknownNativePackages ensures a .deb without a known
|
||||
// platform key is skipped rather than inventing a native_packages entry.
|
||||
func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
for _, n := range []string{
|
||||
"Reasonix-linux-amd64.tar.gz",
|
||||
"Reasonix-mystery.deb",
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
@@ -13,7 +14,7 @@ import (
|
||||
// the shared chunk store named; an unsigned index fails the release instead of
|
||||
// being offered.
|
||||
func TestManifestListsEachSignedDeltaFromTheMirror(t *testing.T) {
|
||||
dir, deltaDir := t.TempDir(), t.TempDir()
|
||||
dir, deltaDir := testenv.TempDir(t), testenv.TempDir(t)
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
if err := os.WriteFile(filepath.Join(dir, "ReasonixStudio-windows-amd64-installer.exe"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -9,11 +9,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
body := []byte("studio archive bytes")
|
||||
for _, name := range []string{
|
||||
@@ -71,7 +72,7 @@ func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
|
||||
// on the Studio side does. Only what installs itself may resolve as a platform
|
||||
// asset, or the version panel offers a move it cannot make.
|
||||
func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-linux-amd64.tar.gz",
|
||||
@@ -128,7 +129,7 @@ func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
|
||||
|
||||
func TestEmptyDirIsAFailedRelease(t *testing.T) {
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
if err := run(t.TempDir(), "v0.1.0", "studio-v0.1.0", "", false); err == nil {
|
||||
if err := run(testenv.TempDir(t), "v0.1.0", "studio-v0.1.0", "", false); err == nil {
|
||||
t.Fatal("a release with no artifacts must fail, not publish an empty manifest")
|
||||
}
|
||||
}
|
||||
@@ -136,7 +137,7 @@ func TestEmptyDirIsAFailedRelease(t *testing.T) {
|
||||
// A mirrored release is fetched from the mirror first, with GitHub kept as the
|
||||
// address a client falls back to; every full artifact and its signature alike.
|
||||
func TestMirroredManifestPutsTheMirrorFirst(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe",
|
||||
@@ -193,7 +194,7 @@ func TestUnmirroredManifestNamesOnlyGitHub(t *testing.T) {
|
||||
// Both Windows architectures ship an installer and a portable archive under
|
||||
// the same naming rule; each installer answers only its own platform key.
|
||||
func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe",
|
||||
@@ -258,7 +259,7 @@ func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
|
||||
}
|
||||
return m
|
||||
}
|
||||
debOnly, both := t.TempDir(), t.TempDir()
|
||||
debOnly, both := testenv.TempDir(t), testenv.TempDir(t)
|
||||
write(debOnly, "ReasonixStudio-linux-amd64.deb")
|
||||
write(both, "ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm")
|
||||
before, after := generate(debOnly), generate(both)
|
||||
@@ -292,7 +293,7 @@ func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
|
||||
// the second architecture must not move the first one's entries.
|
||||
func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
generate := func() update.Manifest {
|
||||
if err := run(dir, "v2.39.0", "studio-v2.39.0", "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -345,7 +346,7 @@ func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
|
||||
// replaces: it must not appear as a platform asset or a native package, and
|
||||
// adding it must not move any entry a shipped client reads.
|
||||
func TestAnAppImageIsOfferedButNeverResolvedAsAnUpdate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
for _, name := range []string{"ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -396,7 +396,7 @@ export const CHAT: Messages = {
|
||||
"chat.selected_text": { zh: "选中内容", en: "Selected text" },
|
||||
"chat.selects_enter_references_tab_completes": { zh: "↑↓ 选择 · 回车引用 · Tab 补全或进入目录 · Esc 关闭", en: "↑↓ selects · Enter references · Tab completes or enters a folder · Esc closes" },
|
||||
"chat.selects_tab_or_enter_completes": { zh: "↑↓ 选择 · Tab 或回车补全 · Esc 关闭", en: "↑↓ selects · Tab or Enter completes · Esc closes" },
|
||||
"chat.send_text_to_another_reasonix": { zh: "向本机另一个 Reasonix 会话发送文字;对方用户批准后它的模型才会读到,发送本身不授予任何权限。", en: "Send text to another Reasonix session on this machine; its model reads it only after that session's user approves, and sending grants no permission." },
|
||||
"chat.send_text_to_another_reasonix": { zh: "向本机另一个 Reasonix 会话发送文字;派发开关开着时对方的模型会直接读到,关着时要对方用户批准,发送本身不授予任何权限。", en: "Send text to another Reasonix session on this machine; with the dispatch switch on its model reads it directly, otherwise its user must approve first, and sending grants no permission." },
|
||||
"chat.send_the_edit": { zh: "改完重发", en: "Send the edit" },
|
||||
"chat.sending": { zh: "正在重发…", en: "Sending…" },
|
||||
"chat.sent_to_background": { zh: "放到后台运行", en: "Sent to the background" },
|
||||
|
||||
@@ -40,7 +40,7 @@ export const REMOTE: Messages = {
|
||||
"remote.could_not_read_the_state": { zh: "无法读取会话间消息的状态。", en: "Could not read the state of messages between sessions." },
|
||||
"remote.creating_qr_code": { zh: "正在生成二维码…", en: "Creating QR code…" },
|
||||
"remote.default_connection_directory_path": { zh: "默认连接目录:{path}", en: "Default connection directory: {path}" },
|
||||
"remote.delivered_to_to_it_is": { zh: "已送到「{to}」,对方的用户批准后才会读到。", en: "Delivered to “{to}”; it is read only after that session’s user approves it." },
|
||||
"remote.delivered_to_to_it_is": { zh: "已送到「{to}」,对方的模型会直接读到(派发开关关着时需对方用户批准)。", en: "Delivered to “{to}”; its model reads it directly (it waits for its user only if the dispatch switch is off)." },
|
||||
"remote.device_access": { zh: "设备访问", en: "Device access" },
|
||||
"remote.device_access_n_online": { zh: "设备访问 · {n} 台在线", en: "Device access · {n} online" },
|
||||
"remote.device_n": { zh: "设备 {n}", en: "Device {n}" },
|
||||
@@ -234,7 +234,7 @@ export const REMOTE: Messages = {
|
||||
"remote.when_something_on_your_computer": { zh: "电脑有事等你时,这台手机会收到一条通知。通知里只有“哪一类”和数量,不含会话名称、命令或对话内容。默认关闭,只提醒你勾选的几类。", en: "When something on your computer is waiting for you, this phone gets a notification. It carries only the kind of occasion and a count, never a session name, command or conversation text. Off by default, and only the occasions you tick are sent." },
|
||||
"remote.which_machines_providers_and_keys": { zh: "远端会话使用哪台机器上配置的 Provider 与 Key", en: "Which machine's providers and keys a remote session uses" },
|
||||
"remote.will_not_run": { zh: "无法运行", en: "will not run" },
|
||||
"remote.write_what_to_tell_the": { zh: "写下要告诉另一个会话的话。对方的用户批准后,它才会交给那边的模型。", en: "Write what to tell the other session. It reaches that session’s model only after its user approves." },
|
||||
"remote.write_what_to_tell_the": { zh: "写下要告诉另一个会话的话。派发开关开着时会直接交给那边的模型,关着时要对方的用户批准。", en: "Write what to tell the other session. It goes straight to that session’s model while the dispatch switch is on; otherwise its user approves first." },
|
||||
"remote.wsl_distribution": { zh: "WSL 发行版", en: "WSL distribution" },
|
||||
"remote.youre_offline": { zh: "网络已断开", en: "You're offline" },
|
||||
"remote.use_studio_on_your_phone": { zh: "在手机上用 Studio", en: "Use Studio on your phone" },
|
||||
|
||||
@@ -197,4 +197,30 @@ export const WORKBENCH: Messages = {
|
||||
"workbench.copied_text": { zh: "已复制 {text}", en: "Copied {text}" },
|
||||
"workbench.browser_tab_named": { zh: "浏览器标签 · {name}", en: "Browser tab · {name}" },
|
||||
"workbench.lines_a_b": { zh: "第 {a}–{b} 行", en: "Lines {a}–{b}" },
|
||||
"workbench.pick_element": { zh: "点选页面元素", en: "Pick an element on the page" },
|
||||
"workbench.picking_element": { zh: "在页面上点一个元素,按 Esc 取消", en: "Click an element on the page; press Esc to cancel" },
|
||||
"workbench.pick_ended_timeout": { zh: "等待太久,点选已取消", en: "Picking timed out and was cancelled" },
|
||||
"workbench.pick_ended_hidden": { zh: "页面不在眼前,点选已取消", en: "The page is not on screen, so picking was cancelled" },
|
||||
"workbench.pick_ended_navigation": { zh: "页面已跳转或重新加载,点选已取消", en: "The page navigated or reloaded, so picking was cancelled" },
|
||||
"workbench.pick_ended_gone": { zh: "页面里已经找不到这个元素", en: "The element can no longer be found on the page" },
|
||||
"workbench.edit_picked_element": { zh: "修改所选元素", en: "Edit the picked element" },
|
||||
"workbench.edit_text": { zh: "文字", en: "Text" },
|
||||
"workbench.edit_color": { zh: "文字颜色", en: "Text color" },
|
||||
"workbench.edit_background": { zh: "背景色", en: "Background" },
|
||||
"workbench.edit_font_size": { zh: "字号", en: "Font size" },
|
||||
"workbench.edit_save": { zh: "保存到文件", en: "Save to file" },
|
||||
"workbench.edit_saving": { zh: "正在保存…", en: "Saving…" },
|
||||
"workbench.edit_close": { zh: "关闭", en: "Close" },
|
||||
"workbench.edit_saved": { zh: "已保存到 {path}", en: "Saved to {path}" },
|
||||
"workbench.edit_not_file": { zh: "只有本地网页文件可以在这里修改并保存", en: "Only a page opened from a local file can be edited and saved here" },
|
||||
"workbench.edit_outside": { zh: "这个文件不在当前工作区里,不能保存", en: "This file is outside the current workspace and cannot be saved" },
|
||||
"workbench.edit_conflict": { zh: "文件在你修改期间已变化,请重新点选后再改", en: "The file changed in the meantime; pick the element again and retry" },
|
||||
"workbench.edit_failed": { zh: "保存失败,文件没有被改动", en: "Saving failed; the file was not changed" },
|
||||
"workbench.edit_not_found": { zh: "在文件源码里找不到这个元素,它可能是脚本生成的", en: "The element is not in the file's source; a script may have made it" },
|
||||
"workbench.edit_ambiguous": { zh: "文件里有多个元素匹配,无法确定改哪一个", en: "More than one element in the file matches, so it is unclear which to change" },
|
||||
"workbench.edit_unmappable": { zh: "这个文件的结构无法可靠对应到页面,未做修改", en: "The file's structure cannot be matched to the page reliably; nothing was changed" },
|
||||
"workbench.edit_mismatch": { zh: "文件里的文字和页面上显示的不一致,未做修改", en: "The file's text differs from what the page shows; nothing was changed" },
|
||||
"workbench.edit_not_plain_text": { zh: "这个元素里还有别的元素,只能改颜色和字号,不能改文字", en: "This element contains other elements; only its colors and size can be changed, not its text" },
|
||||
"workbench.edit_bad_value": { zh: "这个值不能写进样式", en: "That value cannot be written into a style" },
|
||||
"workbench.edit_nothing": { zh: "没有改动", en: "Nothing changed" },
|
||||
};
|
||||
@@ -123,6 +123,11 @@ export interface HostPort {
|
||||
answerBrowserLogin(id: string, username: string, password: string): void;
|
||||
/** Proceed past the certificate this page was refused for, for this run. */
|
||||
trustBrowserCertificate(target: string): Promise<boolean>;
|
||||
/** Whether this shell can let a person point at an element of a page. */
|
||||
picksBrowserElements(): boolean;
|
||||
/** Arm the page's picker and wait for the element pointed at, or for why none was. */
|
||||
pickBrowserElement(target: string, labels: BrowserPickLabels): Promise<BrowserPick>;
|
||||
cancelBrowserPick(target: string): void;
|
||||
}
|
||||
|
||||
/** Why the shell will not load a typed address: a scheme it never opens, or a
|
||||
@@ -178,6 +183,22 @@ export interface ViewRect {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** What the shell reports of an element the person pointed at. */
|
||||
export interface PickedElement {
|
||||
selector: string;
|
||||
tagName: string;
|
||||
text?: string;
|
||||
pageUrl?: string;
|
||||
style?: Partial<Record<"color" | "backgroundColor" | "fontSize" | "fontWeight", string>>;
|
||||
}
|
||||
|
||||
/** The words the page-side hover card draws; the window owns them. */
|
||||
export type BrowserPickLabels = Record<"color" | "background" | "font" | "frame" | "shadow", string>;
|
||||
|
||||
export type BrowserPickEnd = "escape" | "dismissed" | "timeout" | "hidden" | "navigation" | "gone";
|
||||
|
||||
export type BrowserPick = { cancelled: false; element: PickedElement } | { cancelled: true; reason: BrowserPickEnd };
|
||||
|
||||
export type BrowserControl = "back" | "forward" | "reload" | "stop";
|
||||
|
||||
// The preload bridge. Verbs only: the origin the page was loaded from and the
|
||||
@@ -217,6 +238,8 @@ interface ElectronBridge {
|
||||
onBrowserLogin?(listener: (ask: BrowserLogin) => void): () => void;
|
||||
answerBrowserLogin?(id: string, username: string, password: string): Promise<void>;
|
||||
trustBrowserCertificate?(target: string): Promise<boolean>;
|
||||
pickBrowserElement?(target: string, labels: BrowserPickLabels): Promise<BrowserPick>;
|
||||
cancelBrowserPick?(target: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
const bridge = () => (window as unknown as { reasonixHost?: ElectronBridge }).reasonixHost;
|
||||
@@ -354,6 +377,16 @@ class ElectronHost implements HostPort {
|
||||
trustBrowserCertificate(target: string) {
|
||||
return this.api.trustBrowserCertificate?.(target) ?? Promise.resolve(false);
|
||||
}
|
||||
picksBrowserElements() {
|
||||
return typeof this.api.pickBrowserElement === "function";
|
||||
}
|
||||
pickBrowserElement(target: string, labels: BrowserPickLabels): Promise<BrowserPick> {
|
||||
const gone: BrowserPick = { cancelled: true, reason: "gone" };
|
||||
return this.api.pickBrowserElement?.(target, labels).catch(() => gone) ?? Promise.resolve(gone);
|
||||
}
|
||||
cancelBrowserPick(target: string) {
|
||||
void this.api.cancelBrowserPick?.(target);
|
||||
}
|
||||
}
|
||||
|
||||
class BrowserHost implements HostPort {
|
||||
@@ -454,6 +487,13 @@ class BrowserHost implements HostPort {
|
||||
trustBrowserCertificate() {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
picksBrowserElements() {
|
||||
return false;
|
||||
}
|
||||
pickBrowserElement(): Promise<BrowserPick> {
|
||||
return Promise.resolve({ cancelled: true, reason: "gone" });
|
||||
}
|
||||
cancelBrowserPick() {}
|
||||
}
|
||||
|
||||
function pick(): HostPort {
|
||||
|
||||
@@ -324,6 +324,9 @@ the picker and keyboard navigation.
|
||||
1 CSS px in both themes at 100% and 125% device scale.
|
||||
- The compact account button has no row divider; the footer owns the divider
|
||||
above all three controls.
|
||||
- Inside the workbench the file tree is the left column and the open file the right, in the dock as
|
||||
well as full width. Choosing a file leaves the tree open; choosing any other surface takes the
|
||||
whole column and folds it. Below 760px the same rule holds with the tree folded by default.
|
||||
- The docked workbench uses the divider's remembered width. Pointer dragging and
|
||||
keyboard resizing share that width; there is no intermediate width preset.
|
||||
- Full-workbench view preserves the divider width and keeps the conversation and
|
||||
|
||||
@@ -649,8 +649,7 @@
|
||||
.app[data-browser="off"] .pane:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] { visibility: hidden; transition-property: visibility; transition-duration: 0s; transition-delay: var(--t-panel) }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) { grid-template-columns: minmax(0, 1fr) }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) .workbench-explorer { display: none }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body[data-files] { grid-template-columns: minmax(0, 1fr) }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body[data-files] .workbench-canvas { display: none }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) .workbench-canvas { grid-column: 1 }
|
||||
.bpanel { flex: 1; display: flex; flex-direction: column; min-height: 0 }
|
||||
.bbar { flex: none; display: flex; flex-direction: column; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--hair) }
|
||||
.bbar .vtabs { overflow-x: auto }
|
||||
|
||||
@@ -1142,6 +1142,17 @@ body { background: var(--page) !important; }
|
||||
.studio-browser-note > button { flex: none; margin-left: auto; padding: 3px 8px; border: 1px solid var(--border); border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer; font: var(--w-med) var(--fs-micro)/1.45 var(--ui); }
|
||||
.studio-browser-note > button:hover { color: var(--text); background: var(--overlay); }
|
||||
.studio-browser-error { border-top: 0; border-bottom: 1px solid var(--border); color: #c78a82; }
|
||||
.belement { flex: none; display: grid; gap: 6px; padding: 8px 12px; border-top: 1px solid var(--hair); background: var(--surface); color: var(--muted); font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); }
|
||||
.belement-head { display: flex; align-items: center; gap: 10px; }
|
||||
.belement-head code { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
.belement-head button, .belement-foot button { flex: none; padding: 3px 8px; border: 1px solid var(--border); border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer; font: var(--w-med) var(--fs-micro)/1.45 var(--ui); }
|
||||
.belement-head button:hover, .belement-foot button:hover:not(:disabled) { color: var(--text); background: var(--overlay); }
|
||||
.belement-foot button:disabled { cursor: default; opacity: .5; }
|
||||
.belement-field { display: grid; grid-template-columns: 72px minmax(0, 1fr); align-items: center; gap: 8px; }
|
||||
.belement-field input { min-width: 0; padding: 3px 6px; border: 1px solid var(--border); border-radius: var(--r-xs); background: var(--page); color: var(--text); font: inherit; }
|
||||
.belement-field input:disabled { opacity: .6; }
|
||||
.belement-foot { display: flex; align-items: center; gap: 10px; }
|
||||
.belement-foot > p { padding: 0; border: 0; background: transparent; }
|
||||
.workbench { width: 100%; height: 100%; min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--surface); color: var(--text); }
|
||||
.pane:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] { padding: 8px 8px 8px 2px; border-left: 0; background: transparent; }
|
||||
.pane:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] .workbench-tabs { height: calc(var(--pane-bar-h) - 9px); }
|
||||
@@ -1177,12 +1188,13 @@ body { background: var(--page) !important; }
|
||||
.workbench-tab:hover .workbench-tab-close, .workbench-tab[aria-selected="true"] .workbench-tab-close { opacity: 1; }
|
||||
.workbench-tab-close:hover { color: var(--text); }
|
||||
.workbench-tabs svg { width: 13px; height: 13px; flex: none; }
|
||||
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) clamp(190px, 25%, 300px); grid-template-rows: minmax(0, 1fr); }
|
||||
.workbench-canvas { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: clamp(190px, 36%, 260px) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
|
||||
.workbench-canvas { grid-column: 2; grid-row: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.workbench-content > .studio-browser, .workbench-content > .bpanel { width: 100%; height: 100%; min-width: 0; }
|
||||
.workbench-canvas .bbar > .vtabs { display: none; }
|
||||
.workbench-explorer { min-width: 0; display: flex; flex-direction: column; border-left: 1px solid var(--hair); background: var(--page); }
|
||||
.workbench-explorer { grid-column: 1; grid-row: 1; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--hair); background: var(--page); }
|
||||
.workbench-explorer-head { height: 36px; padding: 0 12px; flex: none; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--hair); color: var(--muted); font: var(--w-med) var(--fs-meta)/1 var(--ui); text-transform: uppercase; }
|
||||
.workbench-explorer-head > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workbench-explorer-head small { color: var(--faint); font-size: var(--fs-micro); }
|
||||
.workbench-explorer-head small { margin-left: auto; }
|
||||
.workbench-open-editor {
|
||||
@@ -1251,9 +1263,9 @@ body { background: var(--page) !important; }
|
||||
.workbench-error.workbench-close-error { flex: none; font-size: var(--fs-meta); white-space: pre-line; overflow-wrap: anywhere; }
|
||||
.workbench-error { padding: 8px 12px; border-bottom: 1px solid var(--err); color: var(--err-ink); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
|
||||
@media (max-width: 760px) {
|
||||
.workbench-body { grid-template-columns: minmax(0, 1fr); }
|
||||
.workbench-body:not([data-files]) { grid-template-columns: minmax(0, 1fr); }
|
||||
.workbench-body:not([data-files]) .workbench-explorer { display: none; }
|
||||
.workbench-body[data-files] .workbench-canvas { display: none; }
|
||||
.workbench-body:not([data-files]) .workbench-canvas { grid-column: 1; }
|
||||
}
|
||||
|
||||
/* Settings is one stable layer in the current window. */
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { PickedElement } from "../port/host";
|
||||
import type { SourceEdit, StyleProperty } from "./htmlsource";
|
||||
import type { PageEditFailure, PageEditor } from "./pageedit";
|
||||
|
||||
const FAILED: Record<PageEditFailure, () => string> = {
|
||||
not_found: () => t("workbench.edit_not_found"),
|
||||
ambiguous: () => t("workbench.edit_ambiguous"),
|
||||
unmappable: () => t("workbench.edit_unmappable"),
|
||||
mismatch: () => t("workbench.edit_mismatch"),
|
||||
not_plain_text: () => t("workbench.edit_not_plain_text"),
|
||||
bad_value: () => t("workbench.edit_bad_value"),
|
||||
outside: () => t("workbench.edit_outside"),
|
||||
conflict: () => t("workbench.edit_conflict"),
|
||||
failed: () => t("workbench.edit_failed"),
|
||||
};
|
||||
|
||||
const STYLES: { property: StyleProperty; label: () => string }[] = [
|
||||
{ property: "color", label: () => t("workbench.edit_color") },
|
||||
{ property: "backgroundColor", label: () => t("workbench.edit_background") },
|
||||
{ property: "fontSize", label: () => t("workbench.edit_font_size") },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
element: PickedElement;
|
||||
/** The disk path of the file the page was opened from, or "" for any other page. */
|
||||
filePath: string;
|
||||
editor?: PageEditor;
|
||||
onSaved: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BrowserElementEdit({ element: picked, filePath, editor, onSaved, onClose }: Props) {
|
||||
// What the file holds now: a save moves it, so the next one is measured from there.
|
||||
const [element, setElement] = useState(picked);
|
||||
const [text, setText] = useState(picked.text ?? "");
|
||||
const [styles, setStyles] = useState<Partial<Record<StyleProperty, string>>>(picked.style ?? {});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
|
||||
const editable = !!filePath && !!editor;
|
||||
|
||||
const changes = (): SourceEdit[] => {
|
||||
const out: SourceEdit[] = [];
|
||||
if (element.text && text !== element.text) out.push({ kind: "text", before: element.text, after: text });
|
||||
for (const { property } of STYLES) {
|
||||
const value = (styles[property] ?? "").trim();
|
||||
if (value && value !== element.style?.[property]) out.push({ kind: "style", property, value });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const edits = changes();
|
||||
if (!editor || !edits.length) {
|
||||
setNote({ kind: "error", text: t("workbench.edit_nothing") });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
const out = await editor(filePath, { selector: element.selector, tagName: element.tagName }, edits);
|
||||
setBusy(false);
|
||||
if (out.saved) {
|
||||
setElement({ ...element, text: element.text ? text : element.text, style: { ...element.style, ...Object.fromEntries(edits.flatMap((e) => (e.kind === "style" ? [[e.property, e.value]] : []))) } });
|
||||
setNote({ kind: "ok", text: t("workbench.edit_saved", { path: out.path }) });
|
||||
onSaved();
|
||||
} else {
|
||||
setNote({ kind: "error", text: FAILED[out.code]() });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="belement" aria-label={t("workbench.edit_picked_element")} data-action-submit="browser.edit-save" onSubmit={(event) => { event.preventDefault(); void save(); }}>
|
||||
<header className="belement-head">
|
||||
<code title={element.selector}>{element.selector}</code>
|
||||
<button type="button" data-action="browser.edit-close" onClick={onClose}>{t("workbench.edit_close")}</button>
|
||||
</header>
|
||||
{!editable && <p className="studio-browser-note">{t("workbench.edit_not_file")}</p>}
|
||||
{element.text !== undefined && element.text !== "" && (
|
||||
<label className="belement-field">
|
||||
<span>{t("workbench.edit_text")}</span>
|
||||
<input value={text} disabled={!editable || busy} onChange={(event) => setText(event.target.value)} spellCheck={false} />
|
||||
</label>
|
||||
)}
|
||||
{STYLES.map(({ property, label }) => (
|
||||
<label className="belement-field" key={property}>
|
||||
<span>{label()}</span>
|
||||
<input value={styles[property] ?? ""} disabled={!editable || busy} onChange={(event) => setStyles((v) => ({ ...v, [property]: event.target.value }))} spellCheck={false} />
|
||||
</label>
|
||||
))}
|
||||
<div className="belement-foot">
|
||||
<button type="submit" data-action="browser.edit-save" disabled={!editable || busy}>{busy ? t("workbench.edit_saving") : t("workbench.edit_save")}</button>
|
||||
{note && <p role={note.kind === "error" ? "alert" : "status"} className={note.kind === "error" ? "studio-browser-error" : "studio-browser-note"}>{note.text}</p>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from "lucide-react";
|
||||
import { ArrowLeft, ArrowRight, ExternalLink, Globe, MousePointerClick, RotateCw } from "lucide-react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { AgentPort, BrowserTab } from "../port/port";
|
||||
import { host, type BrowserControl, type BrowserLoadFailure, type BrowserRefusal } from "../port/host";
|
||||
import { host, type BrowserControl, type BrowserLoadFailure, type BrowserPickEnd, type BrowserRefusal, type PickedElement } from "../port/host";
|
||||
import { SWAP_MARK } from "./swap";
|
||||
import { occluded, visibleBox } from "./occlusion";
|
||||
import { BrowserFailure } from "./BrowserFailure";
|
||||
import { AgentPageNote } from "./AgentPageNote";
|
||||
import { readPageTabs } from "./browser_open";
|
||||
import { BrowserElementEdit } from "./BrowserElementEdit";
|
||||
import type { PageEditor } from "./pageedit";
|
||||
|
||||
const START = "reasonix://start";
|
||||
|
||||
@@ -181,13 +183,20 @@ export function localFilePath(url: string): string {
|
||||
|
||||
type OpenDocument = (path: string) => Promise<void>;
|
||||
|
||||
export function AgentBrowserPanel(props: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument }) {
|
||||
export function AgentBrowserPanel(props: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument; editPage?: PageEditor }) {
|
||||
if (host().drawsBrowserViews()) return <AgentBrowserView {...props} />;
|
||||
const current = props.tabs.find((tab) => tab.active) ?? props.tabs[0];
|
||||
return <div className="bpanel">{current && <AgentPageNote tab={current} />}</div>;
|
||||
}
|
||||
|
||||
function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument }) {
|
||||
const PICK_ENDED: Partial<Record<BrowserPickEnd, () => string>> = {
|
||||
timeout: () => t("workbench.pick_ended_timeout"),
|
||||
hidden: () => t("workbench.pick_ended_hidden"),
|
||||
navigation: () => t("workbench.pick_ended_navigation"),
|
||||
gone: () => t("workbench.pick_ended_gone"),
|
||||
};
|
||||
|
||||
function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument, editPage }: { tabs: BrowserTab[]; shown: boolean; showTabs?: boolean; onOpenDocument?: OpenDocument; editPage?: PageEditor }) {
|
||||
const slot = useRef<HTMLDivElement>(null);
|
||||
const [picked, setPicked] = useState("");
|
||||
const current = tabs.find((tab) => tab.target === picked) ?? tabs.find((tab) => tab.active) ?? tabs[0];
|
||||
@@ -196,6 +205,9 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
|
||||
const [refused, setRefused] = useState<BrowserRefusal>("");
|
||||
const [docFailed, setDocFailed] = useState("");
|
||||
const [frame, setFrame] = useState("");
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [element, setElement] = useState<PickedElement | null>(null);
|
||||
const [pickEnd, setPickEnd] = useState("");
|
||||
const target = current?.target ?? "";
|
||||
const failure = failures[target];
|
||||
|
||||
@@ -276,6 +288,41 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
|
||||
};
|
||||
}, [shown, target, failure]);
|
||||
|
||||
// A pick belongs to the page it was armed on: another page, or another
|
||||
// address on the same one, leaves it nothing to point at.
|
||||
useEffect(() => {
|
||||
setElement(null);
|
||||
setPickEnd("");
|
||||
if (!picking) return;
|
||||
setPicking(false);
|
||||
if (target) host().cancelBrowserPick(target);
|
||||
}, [target, current?.url]);
|
||||
const pick = () => {
|
||||
if (!target) return;
|
||||
if (picking) {
|
||||
host().cancelBrowserPick(target);
|
||||
return;
|
||||
}
|
||||
setPicking(true);
|
||||
setPickEnd("");
|
||||
const armed = target;
|
||||
void host()
|
||||
.pickBrowserElement(armed, {
|
||||
color: t("workbench.edit_color"),
|
||||
background: t("workbench.edit_background"),
|
||||
font: t("workbench.edit_font_size"),
|
||||
frame: t("workbench.pick_ended_gone"),
|
||||
shadow: t("workbench.pick_ended_gone"),
|
||||
})
|
||||
.then((answer) => {
|
||||
setPicking(false);
|
||||
if (answer.cancelled) {
|
||||
setPickEnd(PICK_ENDED[answer.reason]?.() ?? "");
|
||||
return;
|
||||
}
|
||||
setElement(answer.element);
|
||||
});
|
||||
};
|
||||
const control = (action: BrowserControl) => target && host().controlBrowserView(target, action);
|
||||
const open = (to: string) => {
|
||||
if (!target) return;
|
||||
@@ -308,6 +355,9 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
|
||||
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="back" aria-label={t("workbench.back")} title={t("workbench.back")} onClick={() => control("back")}><ArrowLeft size={16} /></button>
|
||||
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="forward" aria-label={t("workbench.forward")} title={t("workbench.forward")} onClick={() => control("forward")}><ArrowRight size={16} /></button>
|
||||
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="reload" aria-label={t("common.reload")} title={t("common.reload")} onClick={() => control("reload")}><RotateCw size={16} /></button>
|
||||
{host().picksBrowserElements() && (
|
||||
<button className="bbtn" data-action="browser.pick" aria-pressed={picking} disabled={!target || !!failure} aria-label={t("workbench.pick_element")} title={t("workbench.pick_element")} onClick={pick}><MousePointerClick size={16} /></button>
|
||||
)}
|
||||
<button className="bbtn" data-action="browser.external" disabled={!documentPath && !/^https?:\/\//i.test(current?.url ?? "")} aria-label={documentPath ? t("workbench.open_this_file_with_the") : t("workbench.open_this_page_in_the")} title={documentPath ? t("workbench.open_this_file_with_the") : t("workbench.open_this_page_in_the")} onClick={() => (documentPath ? openDocument() : current && void host().openExternal(current.url))}><ExternalLink size={16} /></button>
|
||||
<form className="baddr" data-action="browser.navigate" data-target={current?.id} onSubmit={go}>
|
||||
<input data-action="browser.navigate" data-target={current?.id} value={address} spellCheck={false} aria-label={t("workbench.address")} onChange={(event) => { setAddress(event.target.value); setRefused(""); }} />
|
||||
@@ -316,6 +366,18 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
|
||||
</div>
|
||||
{documentPath && <p className="studio-browser-note" data-action="browser.document-hint">{t("workbench.opens_this_file_with_the")}</p>}
|
||||
{docFailed && <p className="studio-browser-error" role="alert">{docFailed}</p>}
|
||||
{picking && <p className="studio-browser-note" role="status">{t("workbench.picking_element")}</p>}
|
||||
{pickEnd && <p className="studio-browser-note" role="status">{pickEnd}</p>}
|
||||
{element && (
|
||||
<BrowserElementEdit
|
||||
key={element.selector}
|
||||
element={element}
|
||||
filePath={localFilePath(current?.url ?? "")}
|
||||
editor={editPage}
|
||||
onSaved={() => control("reload")}
|
||||
onClose={() => setElement(null)}
|
||||
/>
|
||||
)}
|
||||
{refused && (
|
||||
<p className="studio-browser-error" role="alert">
|
||||
{refused === "network_file" ? t("workbench.a_network_share_cannot_be") : t("workbench.enter_a_valid_http_or")}
|
||||
@@ -337,6 +399,6 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
|
||||
);
|
||||
}
|
||||
|
||||
export function BrowserPanel(props: ManualProps | { tabs: BrowserTab[]; shown: boolean; onOpenDocument?: OpenDocument }) {
|
||||
export function BrowserPanel(props: ManualProps | { tabs: BrowserTab[]; shown: boolean; onOpenDocument?: OpenDocument; editPage?: PageEditor }) {
|
||||
return "tabs" in props ? <AgentBrowserPanel {...props} /> : <ManualBrowserPanel {...props} />;
|
||||
}
|
||||
@@ -43,7 +43,7 @@ describe("messages between sessions", () => {
|
||||
await userEvent.type(box, "迁移完成");
|
||||
await userEvent.click(button);
|
||||
expect(send).toHaveBeenCalledWith("s-2", "迁移完成");
|
||||
expect(await screen.findByText(/对方的用户批准后才会读到/)).toBeTruthy();
|
||||
expect(await screen.findByText(/对方的模型会直接读到/)).toBeTruthy();
|
||||
expect((box as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
|
||||
@@ -405,12 +405,9 @@ describe("WorkbenchPanel", () => {
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
});
|
||||
|
||||
// A file opened from the explorer steps the list aside rather than closing it,
|
||||
// so closing that file comes back to the list. Folding the column instead
|
||||
// leaves someone who opened one file with no workbench at all. With no tabs
|
||||
// the launcher draws a "文件" card as well as the toolbar toggle, so the
|
||||
// The launcher draws a "文件" card as well as the toolbar toggle, so the
|
||||
// toolbar button is named by its action rather than its label.
|
||||
it("returns to the explorer when a file opened from it is closed", async () => {
|
||||
it("keeps the explorer beside a file opened from it, and after that file is closed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseManual = vi.fn();
|
||||
const { container } = render(<WorkbenchPanel port={new MockPort()} tabs={[]} manual={false} shown scheme="light" changes={[]} onCloseManual={onCloseManual} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
|
||||
@@ -420,18 +417,14 @@ describe("WorkbenchPanel", () => {
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "README.md" }));
|
||||
expect(screen.getByRole("tab", { name: "README.md" })).toBeTruthy();
|
||||
// The list stepped aside for the file.
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "关闭 README.md" }));
|
||||
expect(onCloseManual).not.toHaveBeenCalled();
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
});
|
||||
|
||||
// The list was stepped aside for that file, not closed on behalf of the one
|
||||
// behind it: closing the file leaves the other tab selected and alone, so the
|
||||
// list must stay out of its way and the column must not fold.
|
||||
it("does not bring the explorer back over a tab that is still open", async () => {
|
||||
it("leaves the explorer as it was when the file picked from it is closed over another tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseManual = vi.fn();
|
||||
const page = { id: "b1", target: "t1", url: "https://example.com", title: "My page", active: false };
|
||||
@@ -439,17 +432,14 @@ describe("WorkbenchPanel", () => {
|
||||
|
||||
await user.click(container.querySelector<HTMLElement>('[data-action="workbench.files"]')!);
|
||||
await user.click(await screen.findByRole("button", { name: "README.md" }));
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "关闭 README.md" }));
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
expect(onCloseManual).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("tab", { name: "My page" }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
// Closing every browser tab at once while a file from the explorer is still
|
||||
// open leaves that file behind, so the column stays and the list stays away.
|
||||
// The batch goes through the same rule as a single close, which is the point.
|
||||
it("keeps the column when every browser tab is closed at once beside an explorer file", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseManual = vi.fn();
|
||||
@@ -470,7 +460,7 @@ describe("WorkbenchPanel", () => {
|
||||
expect(screen.queryByRole("tab", { name: "Agent page" })).toBeNull();
|
||||
expect(screen.getByRole("tab", { name: "README.md" })).toBeTruthy();
|
||||
expect(onCloseManual).not.toHaveBeenCalled();
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a page the person opened beside the agent's, while the agent's tab stays active", () => {
|
||||
@@ -518,13 +508,13 @@ describe("WorkbenchPanel", () => {
|
||||
localStorage.removeItem("rx-show-hidden-files");
|
||||
});
|
||||
|
||||
it("shows the file that was picked from the list rather than leaving the list over it", async () => {
|
||||
it("shows the file picked from the list and keeps the list beside it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<WorkbenchPanel port={new MockPort()} tabs={[]} manual={false} shown scheme="light" changes={[]} onCloseManual={vi.fn()} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
|
||||
await user.click(screen.getAllByRole("button", { name: "文件" })[0]);
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
await user.click(await screen.findByRole("button", { name: "README.md" }));
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
expect(screen.getByRole("tab", { name: "README.md" }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
|
||||
@@ -137,9 +137,6 @@ export function WorkbenchPanel({
|
||||
[directories, setDirectories] = useState<string[]>([]),
|
||||
[openFiles, setOpenFiles] = useState<string[]>([]);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
// The file picked out of the explorer, if that is how it was opened: closing
|
||||
// it comes back to the list it was stepped aside from.
|
||||
const fromExplorer = useRef("");
|
||||
const [launcher, setLauncher] = useState(false);
|
||||
const newTab = useRef<HTMLButtonElement>(null);
|
||||
const toggleLauncher = () => { supersede(); setLauncher((open) => !open); };
|
||||
@@ -160,10 +157,10 @@ export function WorkbenchPanel({
|
||||
useOnBump(toggleFiles, toggleExplorer);
|
||||
const [query, setQuery] = useState(""),
|
||||
[selected, setSelected] = useState("");
|
||||
// Docked, the file list hides the canvas a page is drawn in, so choosing any
|
||||
// surface steps the list aside.
|
||||
// The list and the canvas sit side by side, so a file stays beside its tree;
|
||||
// any other surface takes the whole column and steps the list aside.
|
||||
const select = useCallback((key: string) => {
|
||||
setShowFiles(false);
|
||||
if (!key.startsWith("file:")) setShowFiles(false);
|
||||
setLauncher(false);
|
||||
setFailed("");
|
||||
setSelected(key);
|
||||
@@ -333,11 +330,11 @@ export function WorkbenchPanel({
|
||||
// source view is one click away.
|
||||
const filePath = active?.kind === "file" ? active.path : "";
|
||||
useEffect(() => {
|
||||
onActiveFile?.(shown && !launcher && !showFiles && filePath ? filePath : undefined);
|
||||
onActiveFile?.(shown && !launcher && filePath ? filePath : undefined);
|
||||
return () => onActiveFile?.(undefined);
|
||||
}, [filePath, shown, launcher, showFiles, onActiveFile]);
|
||||
}, [filePath, shown, launcher, onActiveFile]);
|
||||
const textMenu = useWorkbenchTextMenu({
|
||||
path: filePath, mode, scope, shown: shown && !launcher && !showFiles,
|
||||
path: filePath, mode, scope, shown: shown && !launcher,
|
||||
onQuote: onQuoteSelection, onError: setFailed,
|
||||
});
|
||||
const grantedFile = parseGrantedFile(filePath);
|
||||
@@ -359,7 +356,7 @@ export function WorkbenchPanel({
|
||||
const unpreviewableText = !hasDocument && errorCode === "workspace.file_unreadable";
|
||||
const revision = file?.revision;
|
||||
useEffect(() => {
|
||||
if (mode !== "preview" || !previewable || !shown || showFiles || launcher || previewTab || (!revision && !fileFailed && !failed)) return;
|
||||
if (mode !== "preview" || !previewable || !shown || launcher || previewTab || (!revision && !fileFailed && !failed)) return;
|
||||
if (missing) {
|
||||
setMode("file");
|
||||
return;
|
||||
@@ -368,7 +365,7 @@ export function WorkbenchPanel({
|
||||
setFailed(reason(e));
|
||||
setMode("file");
|
||||
});
|
||||
}, [mode, previewable, shown, showFiles, launcher, previewTab, revision, fileFailed, missing, failed, filePath, openPreview, tabs]);
|
||||
}, [mode, previewable, shown, launcher, previewTab, revision, fileFailed, missing, failed, filePath, openPreview, tabs]);
|
||||
useEffect(() => {
|
||||
if (mode === "preview" && revision) syncPreview(filePath, revision);
|
||||
}, [mode, filePath, revision, syncPreview]);
|
||||
@@ -391,8 +388,7 @@ export function WorkbenchPanel({
|
||||
useEffect(() => {
|
||||
strip.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||||
}, [activeKey, surfaces.length]);
|
||||
// Picking a file is asking to read it. Docked, the list and the file share one
|
||||
// column, so the list steps aside; side by side it stays where it is.
|
||||
// Picking a file is asking to read it; the list stays where it is.
|
||||
const [recentFiles, setRecentFiles] = useState(() => readRecentFiles(workspaceRoot ?? ""));
|
||||
const openFile = useCallback((path: string) => {
|
||||
setRecentFiles(rememberFile(workspaceRoot ?? "", path));
|
||||
@@ -402,11 +398,8 @@ export function WorkbenchPanel({
|
||||
n.delete(`file:${path}`);
|
||||
return n;
|
||||
});
|
||||
// A file picked while the explorer is shown leaves that list stepped aside
|
||||
// rather than closed, so closing this file comes back to the list.
|
||||
fromExplorer.current = showFiles ? path : "";
|
||||
pick(`file:${path}`);
|
||||
}, [pick, showFiles, workspaceRoot]);
|
||||
}, [pick, workspaceRoot]);
|
||||
useEffect(() => { if (fileRequest) { openFile(fileRequest.path); fileRequest.consumed?.(); } }, [fileRequest, openFile]);
|
||||
// A rendered document's own links open beside it and its images load from the
|
||||
// tree. Held stable per document, so typing re-renders the text alone.
|
||||
@@ -459,7 +452,7 @@ export function WorkbenchPanel({
|
||||
if (openingBrowser.current === page) openingBrowser.current = null;
|
||||
}
|
||||
};
|
||||
const { close: closeTabs, closing, closeFailure } = useTabClose({ port, surfaces, selected, showFiles: showFiles || launcher, fromExplorer, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, setShowFiles, setLauncher, onCloseManual, onCloseSelected: () => setFailed("") });
|
||||
const { close: closeTabs, closing, closeFailure } = useTabClose({ port, surfaces, selected, showFiles: showFiles || launcher, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, setLauncher, onCloseManual, onCloseSelected: () => setFailed("") });
|
||||
const close = (targets: Surface[]) => { supersede(); return terminals.guard(targets, closeTabs); };
|
||||
const dismissTabMenu = useCallback(() => setTabMenu(null), []);
|
||||
const [tabMenu, setTabMenu] = useState<{ surface: Surface; x: number; y: number; anchor: HTMLElement } | null>(null);
|
||||
@@ -628,22 +621,22 @@ export function WorkbenchPanel({
|
||||
)}
|
||||
<div className="workbench-content" ref={canvas} hidden={launcher || !active} data-action-contextmenu="workbench.quote-selection" {...textMenu.readerProps}>
|
||||
<ManualBrowsers ids={browsers} active={active?.kind === "manual" ? active.id : ""} onAddress={noteHost} onExternal={onExternal} scheme={scheme} />
|
||||
<WorkbenchTerminals port={port} hook={terminals} active={active?.kind === "terminal" ? active.id : ""} shown={shown && !launcher && !showFiles}
|
||||
<WorkbenchTerminals port={port} hook={terminals} active={active?.kind === "terminal" ? active.id : ""} shown={shown && !launcher}
|
||||
scheme={scheme} onQuote={onQuoteSelection} onClose={(targets) => void close(targets)} />
|
||||
{active?.kind === "browser" && (
|
||||
<AgentBrowserPanel
|
||||
tabs={[active.tab]}
|
||||
shown={shown && !launcher}
|
||||
showTabs={false}
|
||||
showTabs={false} editPage={preview.edit}
|
||||
onOpenDocument={(path) => port.openDocument(path)}
|
||||
/>
|
||||
)}
|
||||
{active?.kind === "changes" && (
|
||||
<WorkbenchChangesPage port={port} changes={changes} branch={gitRepos.repos.find((r) => r.dir === "")?.branch} shown={shown && !launcher && !showFiles}
|
||||
<WorkbenchChangesPage port={port} changes={changes} branch={gitRepos.repos.find((r) => r.dir === "")?.branch} shown={shown && !launcher}
|
||||
remote={remote} onOpen={(path) => { openFile(path); setMode("diff"); }} onChanged={onTreeChanged} />
|
||||
)}
|
||||
{active?.kind === "job" && <JobRunView key={active.id} port={port} id={active.id} name={active.name} shown={shown && !launcher && !showFiles} />}
|
||||
{active?.kind === "agent" && <AgentRunView key={active.ref} port={port} run={active} shown={shown && !launcher && !showFiles} onShowCall={onShowCall} />}
|
||||
{active?.kind === "job" && <JobRunView key={active.id} port={port} id={active.id} name={active.name} shown={shown && !launcher} />}
|
||||
{active?.kind === "agent" && <AgentRunView key={active.ref} port={port} run={active} shown={shown && !launcher} onShowCall={onShowCall} />}
|
||||
{active?.kind === "file" && (
|
||||
<>
|
||||
<WorkbenchFileBar
|
||||
@@ -673,7 +666,7 @@ export function WorkbenchPanel({
|
||||
)}
|
||||
{mode === "document" || unpreviewableText ? (
|
||||
<Suspense fallback={<div className="workbench-empty">{t("common.loading")}</div>}>
|
||||
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !showFiles && !launcher}
|
||||
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !launcher}
|
||||
view={documentState(documentStates, filePath)}
|
||||
refresh={JSON.stringify([changeKey, glance, running, wrote])}
|
||||
onOpenExternal={!remote && port.opensFiles() && /\.(pdf|html?|svg|docx?|xlsx?|pptx?)$/i.test(filePath) ? () => {
|
||||
@@ -684,7 +677,7 @@ export function WorkbenchPanel({
|
||||
</Suspense>
|
||||
) : mode === "preview" ? (
|
||||
previewTab ? (
|
||||
<AgentBrowserPanel tabs={[previewTab]} shown={shown && !launcher} showTabs={false} onOpenDocument={(path) => port.openDocument(path)} />
|
||||
<AgentBrowserPanel tabs={[previewTab]} shown={shown && !launcher} showTabs={false} onOpenDocument={(path) => port.openDocument(path)} editPage={preview.edit} />
|
||||
) : (
|
||||
<div className="workbench-empty">{t("common.loading")}</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import "./testkit";
|
||||
import { host, type BrowserControl, type BrowserPick, type BrowserPickLabels } from "../port/host";
|
||||
import type { BrowserTab } from "../port/port";
|
||||
import { AgentBrowserPanel } from "./BrowserPanel";
|
||||
import type { PageEditor } from "./pageedit";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
const file: BrowserTab = { id: "b1", target: "v1", title: "Demo", url: "file:///work/site/a.html", active: true };
|
||||
const web: BrowserTab = { id: "b2", target: "v2", title: "Web", url: "https://example.com/", active: false };
|
||||
const picked: BrowserPick = {
|
||||
cancelled: false,
|
||||
element: { selector: "#note", tagName: "p", text: "hello", style: { color: "#111111", backgroundColor: "#FFFFFF", fontSize: "16px" } },
|
||||
};
|
||||
|
||||
let pick: Mock<(target: string, labels: BrowserPickLabels) => Promise<BrowserPick>>;
|
||||
let cancel: Mock<(target: string) => void>;
|
||||
let control: Mock<(target: string, action: BrowserControl) => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
const shell = host();
|
||||
vi.spyOn(shell, "drawsBrowserViews").mockReturnValue(true);
|
||||
vi.spyOn(shell, "picksBrowserElements").mockReturnValue(true);
|
||||
vi.spyOn(shell, "showBrowserView").mockImplementation(() => {});
|
||||
vi.spyOn(shell, "hideBrowserView").mockImplementation(() => {});
|
||||
pick = vi.fn(async () => picked);
|
||||
cancel = vi.fn();
|
||||
control = vi.fn();
|
||||
vi.spyOn(shell, "pickBrowserElement").mockImplementation(pick);
|
||||
vi.spyOn(shell, "cancelBrowserPick").mockImplementation(cancel);
|
||||
vi.spyOn(shell, "controlBrowserView").mockImplementation(control);
|
||||
});
|
||||
|
||||
const pickButton = () => screen.getByRole("button", { name: "点选页面元素" });
|
||||
|
||||
describe("editing a picked element of a local page", () => {
|
||||
it("offers the picker only where the shell can pick", () => {
|
||||
vi.spyOn(host(), "picksBrowserElements").mockReturnValue(false);
|
||||
render(<AgentBrowserPanel tabs={[file]} shown />);
|
||||
expect(screen.queryByRole("button", { name: "点选页面元素" })).toBeNull();
|
||||
});
|
||||
|
||||
it("saves the changed fields only, and reloads the page that was edited", async () => {
|
||||
const editor: PageEditor = vi.fn(async () => ({ saved: true as const, path: "a.html" }));
|
||||
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
|
||||
fireEvent.click(pickButton());
|
||||
expect(pick).toHaveBeenCalledWith("v1", expect.objectContaining({ color: expect.any(String) }));
|
||||
const form = await screen.findByRole("form", { name: "修改所选元素" });
|
||||
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "bye" } });
|
||||
fireEvent.change(screen.getByLabelText("文字颜色"), { target: { value: "#ff0000" } });
|
||||
fireEvent.submit(form);
|
||||
await waitFor(() => expect(editor).toHaveBeenCalledTimes(1));
|
||||
expect(editor).toHaveBeenCalledWith("/work/site/a.html", { selector: "#note", tagName: "p" }, [
|
||||
{ kind: "text", before: "hello", after: "bye" },
|
||||
{ kind: "style", property: "color", value: "#ff0000" },
|
||||
]);
|
||||
expect(await screen.findByText("已保存到 a.html")).toBeTruthy();
|
||||
expect(control).toHaveBeenCalledWith("v1", "reload");
|
||||
});
|
||||
|
||||
it("measures a second save from what the first one wrote", async () => {
|
||||
const editor: PageEditor = vi.fn(async () => ({ saved: true as const, path: "a.html" }));
|
||||
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
|
||||
fireEvent.click(pickButton());
|
||||
const form = await screen.findByRole("form", { name: "修改所选元素" });
|
||||
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "bye" } });
|
||||
fireEvent.submit(form);
|
||||
await screen.findByText("已保存到 a.html");
|
||||
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "again" } });
|
||||
fireEvent.submit(form);
|
||||
await waitFor(() => expect(editor).toHaveBeenCalledTimes(2));
|
||||
expect((editor as ReturnType<typeof vi.fn>).mock.calls[1][2]).toEqual([{ kind: "text", before: "bye", after: "again" }]);
|
||||
});
|
||||
|
||||
it("says why a save was refused and leaves the page alone", async () => {
|
||||
const editor: PageEditor = vi.fn(async () => ({ saved: false as const, code: "mismatch" as const }));
|
||||
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
|
||||
fireEvent.click(pickButton());
|
||||
const form = await screen.findByRole("form", { name: "修改所选元素" });
|
||||
fireEvent.change(screen.getByLabelText("文字"), { target: { value: "bye" } });
|
||||
fireEvent.submit(form);
|
||||
expect((await screen.findByRole("alert")).textContent).toBe("文件里的文字和页面上显示的不一致,未做修改");
|
||||
expect(control).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call the editor when nothing was changed", async () => {
|
||||
const editor: PageEditor = vi.fn();
|
||||
render(<AgentBrowserPanel tabs={[file]} shown editPage={editor} />);
|
||||
fireEvent.click(pickButton());
|
||||
fireEvent.submit(await screen.findByRole("form", { name: "修改所选元素" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toBe("没有改动");
|
||||
expect(editor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a page that is not a local file but will not save to it", async () => {
|
||||
const editor: PageEditor = vi.fn();
|
||||
render(<AgentBrowserPanel tabs={[web]} shown editPage={editor} />);
|
||||
fireEvent.click(pickButton());
|
||||
await screen.findByRole("form", { name: "修改所选元素" });
|
||||
expect(screen.getByText("只有本地网页文件可以在这里修改并保存")).toBeTruthy();
|
||||
expect((screen.getByRole("button", { name: "保存到文件" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("reports why a pick ended without an element, and nothing for Escape", async () => {
|
||||
pick.mockResolvedValueOnce({ cancelled: true, reason: "navigation" });
|
||||
render(<AgentBrowserPanel tabs={[file]} shown />);
|
||||
fireEvent.click(pickButton());
|
||||
expect(await screen.findByText("页面已跳转或重新加载,点选已取消")).toBeTruthy();
|
||||
pick.mockResolvedValueOnce({ cancelled: true, reason: "escape" });
|
||||
fireEvent.click(pickButton());
|
||||
await waitFor(() => expect(screen.queryByText("页面已跳转或重新加载,点选已取消")).toBeNull());
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts the picker away when the button is pressed again", async () => {
|
||||
let end: (answer: BrowserPick) => void = () => {};
|
||||
pick.mockImplementationOnce(() => new Promise<BrowserPick>((done) => { end = done; }));
|
||||
render(<AgentBrowserPanel tabs={[file]} shown />);
|
||||
fireEvent.click(pickButton());
|
||||
expect(await screen.findByText("在页面上点一个元素,按 Esc 取消")).toBeTruthy();
|
||||
fireEvent.click(pickButton());
|
||||
expect(cancel).toHaveBeenCalledWith("v1");
|
||||
await act(async () => end({ cancelled: true, reason: "dismissed" }));
|
||||
expect(screen.queryByText("在页面上点一个元素,按 Esc 取消")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the picked element when another page is shown", async () => {
|
||||
render(<AgentBrowserPanel tabs={[file, web]} shown />);
|
||||
fireEvent.click(pickButton());
|
||||
await screen.findByRole("form", { name: "修改所选元素" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Web" }));
|
||||
await waitFor(() => expect(screen.queryByRole("form", { name: "修改所选元素" })).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,10 @@ it("reports actual tab selection and clears it when the canvas is hidden or its
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "one.md" })[0]);
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "文件" }));
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "新建标签" }));
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith(undefined));
|
||||
fireEvent.click(screen.getByRole("button", { name: "文件" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "新建标签" }));
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "关闭 one.md" })[0]);
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("other/one.md"));
|
||||
|
||||
@@ -68,6 +68,8 @@ it.each(["rail", "sidebar", "palette"])("opens only the focused pane's files fro
|
||||
expect(filesShown(background)).toBe(false);
|
||||
expect(focused().querySelectorAll(".workbench-tab")).toHaveLength(0);
|
||||
await userEvent.click(within(focused()).getByRole("button", { name: "README.md" }));
|
||||
expect(filesShown()).toBe(true);
|
||||
await openFiles();
|
||||
expect(filesShown()).toBe(false);
|
||||
await openFiles();
|
||||
expect(filesShown()).toBe(true);
|
||||
@@ -111,14 +113,15 @@ it("does not replay a Files request into a newly focused session", async () => {
|
||||
await palette("文件");
|
||||
await userEvent.click(within(focused()).getByRole("button", { name: "README.md" }));
|
||||
const previous = focused();
|
||||
const kept = filesShown(previous);
|
||||
fireEvent.click(document.querySelector('.ptab[data-pane="r1"]')!);
|
||||
expect(focused()).not.toBe(previous);
|
||||
expect(filesShown()).toBe(false);
|
||||
await palette("文件");
|
||||
expect(filesShown()).toBe(true);
|
||||
expect(filesShown(previous)).toBe(false);
|
||||
expect(filesShown(previous)).toBe(kept);
|
||||
fireEvent.click(document.querySelector('.ptab[data-pane="r2"]')!);
|
||||
expect(filesShown()).toBe(false);
|
||||
expect(filesShown()).toBe(kept);
|
||||
});
|
||||
|
||||
const railFiles = () => within(document.querySelector<HTMLElement>(".nav")!).getByRole("button", { name: "文件" });
|
||||
|
||||
@@ -101,7 +101,7 @@ it("keeps a superseded successful user tab owned while a separate preview claim
|
||||
});
|
||||
|
||||
describe("opening an HTML file from the explorer", () => {
|
||||
it.each(["Files", "launcher"])("does not start a hidden preview after a late file read and newer %s choice", async (choice) => {
|
||||
it.each(["Files", "launcher"])("starts a preview only while its file is in view after a late read and newer %s choice", async (choice) => {
|
||||
const user = userEvent.setup();
|
||||
const { port, open } = setup();
|
||||
let read!: (file: Awaited<ReturnType<MockPort["workspaceFile"]>>) => void;
|
||||
@@ -113,11 +113,14 @@ describe("opening an HTML file from the explorer", () => {
|
||||
if (choice === "Files") await user.click(container.querySelector('[data-action="workbench.files"]')!);
|
||||
else await user.click(screen.getByRole("button", { name: "新建标签" }));
|
||||
await act(async () => read({ path: "index.html", content: "<h1>Late</h1>", revision: "r1" }));
|
||||
if (choice === "Files") {
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||
return;
|
||||
}
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
if (choice === "Files") expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
else expect(screen.getByRole("group", { name: "开始" })).toBeTruthy();
|
||||
if (choice === "Files") await user.click(screen.getByRole("tab", { name: "index.html" }).querySelector(".workbench-tab-pick")!);
|
||||
else await user.keyboard("{Escape}");
|
||||
expect(screen.getByRole("group", { name: "开始" })).toBeTruthy();
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { patchSource } from "./htmlsource";
|
||||
|
||||
const PAGE = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Demo</title>
|
||||
<style>h1 > b { color: red }</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- <h1 id="ghost">not an element</h1> -->
|
||||
<h1 id="title" class="big">Hello <b>world</b></h1>
|
||||
<p id="note">Plain & simple</p>
|
||||
<p data-testid="styled" style="color: red; margin: 0">Styled</p>
|
||||
<ul><li>one<li>two</ul>
|
||||
<script>if (1 < 2) document.title = "<h1 id='x'>"</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
describe("patchSource", () => {
|
||||
it("replaces the text of an element and leaves every other byte alone", () => {
|
||||
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "text", before: "Plain & simple", after: "Fish & <chips>" });
|
||||
expect(out).toEqual({ ok: true, content: PAGE.replace("Plain & simple", "Fish & <chips>") });
|
||||
});
|
||||
|
||||
it("refuses text whose current words are not the ones the person saw", () => {
|
||||
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "text", before: "something else", after: "x" });
|
||||
expect(out).toEqual({ ok: false, code: "mismatch" });
|
||||
});
|
||||
|
||||
it("refuses text edits on an element that has children of its own", () => {
|
||||
const out = patchSource(PAGE, { selector: "#title", tagName: "h1" }, { kind: "text", before: "Hello world", after: "Bye" });
|
||||
expect(out).toEqual({ ok: false, code: "not_plain_text" });
|
||||
});
|
||||
|
||||
it("refuses an element whose end tag is implied", () => {
|
||||
const out = patchSource(PAGE, { selector: "li:first-child", tagName: "li" }, { kind: "text", before: "one", after: "1" });
|
||||
expect(out).toEqual({ ok: false, code: "unmappable" });
|
||||
});
|
||||
|
||||
it("adds an inline style where the element has none", () => {
|
||||
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "style", property: "color", value: "#336699" });
|
||||
expect(out).toEqual({ ok: true, content: PAGE.replace('<p id="note">', '<p style="color: #336699" id="note">') });
|
||||
});
|
||||
|
||||
it("replaces one declaration of an existing style and keeps the rest", () => {
|
||||
const out = patchSource(PAGE, { selector: '[data-testid="styled"]', tagName: "p" }, { kind: "style", property: "color", value: "blue" });
|
||||
expect(out).toEqual({ ok: true, content: PAGE.replace('style="color: red; margin: 0"', 'style="color: blue; margin: 0"') });
|
||||
});
|
||||
|
||||
it("appends a declaration the style did not have, writing camelCase as CSS", () => {
|
||||
const out = patchSource(PAGE, { selector: '[data-testid="styled"]', tagName: "p" }, { kind: "style", property: "backgroundColor", value: "#fff" });
|
||||
expect(out).toEqual({ ok: true, content: PAGE.replace('style="color: red; margin: 0"', 'style="color: red; margin: 0; background-color: #fff"') });
|
||||
});
|
||||
|
||||
it("refuses a value that could leave the declaration", () => {
|
||||
for (const value of ['red"; x="', "red; display: none", "a{b}", "<b>", "x\ny"]) {
|
||||
const out = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "style", property: "color", value });
|
||||
expect(out).toEqual({ ok: false, code: "bad_value" });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not read markup inside comments or scripts as elements", () => {
|
||||
const out = patchSource(PAGE, { selector: "#ghost", tagName: "h1" }, { kind: "style", property: "color", value: "red" });
|
||||
expect(out).toEqual({ ok: false, code: "not_found" });
|
||||
const next = patchSource(PAGE, { selector: "#note", tagName: "p" }, { kind: "style", property: "color", value: "red" });
|
||||
expect(next.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a selector naming a different tag than the pick reported", () => {
|
||||
const out = patchSource(PAGE, { selector: "#note", tagName: "div" }, { kind: "style", property: "color", value: "red" });
|
||||
expect(out).toEqual({ ok: false, code: "not_found" });
|
||||
});
|
||||
|
||||
it("refuses a selector that names more than one element", () => {
|
||||
const out = patchSource(PAGE, { selector: "p", tagName: "p" }, { kind: "style", property: "color", value: "red" });
|
||||
expect(out).toEqual({ ok: false, code: "ambiguous" });
|
||||
});
|
||||
|
||||
it("refuses a page the parser rebuilt beyond what the source says", () => {
|
||||
const table = "<table><tr><td id=c>x</td></tr></table>";
|
||||
const ok = patchSource(table, { selector: "#c", tagName: "td" }, { kind: "style", property: "color", value: "red" });
|
||||
expect(ok).toEqual({ ok: true, content: '<table><tr><td style="color: red" id=c>x</td></tr></table>' });
|
||||
const broken = patchSource("<b><p id=q>y</b>", { selector: "#q", tagName: "p" }, { kind: "style", property: "color", value: "red" });
|
||||
expect(broken).toEqual({ ok: false, code: "unmappable" });
|
||||
});
|
||||
|
||||
it("refuses an invalid selector instead of throwing", () => {
|
||||
const out = patchSource(PAGE, { selector: "###", tagName: "p" }, { kind: "style", property: "color", value: "red" });
|
||||
expect(out).toEqual({ ok: false, code: "not_found" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/** What a person can change on a picked element of a page that is a file. */
|
||||
export type SourceEdit =
|
||||
| { kind: "text"; before: string; after: string }
|
||||
| { kind: "style"; property: StyleProperty; value: string };
|
||||
|
||||
export type StyleProperty = "color" | "backgroundColor" | "fontSize" | "fontWeight";
|
||||
|
||||
/** Why a source could not take the edit. Each answer is the file's, not the page's:
|
||||
* the page may look fine while the file cannot say where an element lives. */
|
||||
export type PatchFailure = "not_found" | "ambiguous" | "unmappable" | "mismatch" | "not_plain_text" | "bad_value";
|
||||
|
||||
export type Patched = { ok: true; content: string } | { ok: false; code: PatchFailure };
|
||||
|
||||
export interface Target {
|
||||
selector: string;
|
||||
tagName: string;
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
name: string;
|
||||
start: number;
|
||||
end: number;
|
||||
/** The first end tag or start tag after this one, which closes its text. */
|
||||
next?: Token;
|
||||
}
|
||||
|
||||
interface Token {
|
||||
closing: boolean;
|
||||
name: string;
|
||||
start: number;
|
||||
}
|
||||
|
||||
const RAW_TEXT = new Set(["script", "style", "textarea", "title"]);
|
||||
// Elements the parser makes without a tag in the source.
|
||||
const IMPLIED = new Set(["html", "head", "body", "tbody"]);
|
||||
const CSS_NAME: Record<StyleProperty, string> = {
|
||||
color: "color",
|
||||
backgroundColor: "background-color",
|
||||
fontSize: "font-size",
|
||||
fontWeight: "font-weight",
|
||||
};
|
||||
|
||||
// The source's start tags in document order, found without building a tree: a
|
||||
// comment, a doctype and the body of a raw-text element hold no tags of the
|
||||
// page's. A scan that cannot tell is reported as null, never guessed at.
|
||||
function scan(source: string): { starts: Tag[]; tokens: Token[] } | null {
|
||||
const starts: Tag[] = [];
|
||||
const tokens: Token[] = [];
|
||||
let at = 0;
|
||||
while (at < source.length) {
|
||||
const open = source.indexOf("<", at);
|
||||
if (open < 0) break;
|
||||
if (source.startsWith("<!--", open)) {
|
||||
const close = source.indexOf("-->", open + 4);
|
||||
if (close < 0) return null;
|
||||
at = close + 3;
|
||||
continue;
|
||||
}
|
||||
if (source[open + 1] === "!" || source[open + 1] === "?") {
|
||||
const close = source.indexOf(">", open);
|
||||
if (close < 0) return null;
|
||||
at = close + 1;
|
||||
continue;
|
||||
}
|
||||
const match = /^<(\/?)([A-Za-z][^\s/>]*)/.exec(source.slice(open, open + 64));
|
||||
if (!match) {
|
||||
at = open + 1;
|
||||
continue;
|
||||
}
|
||||
const end = tagEnd(source, open + match[0].length);
|
||||
if (end < 0) return null;
|
||||
const name = match[2].toLowerCase();
|
||||
const token: Token = { closing: match[1] === "/", name, start: open };
|
||||
tokens.push(token);
|
||||
if (!token.closing) starts.push({ name, start: open, end });
|
||||
at = end;
|
||||
if (!token.closing && RAW_TEXT.has(name)) {
|
||||
const close = new RegExp(`</${name}[\\s/>]`, "i").exec(source.slice(at));
|
||||
if (!close) return null;
|
||||
at += close.index;
|
||||
}
|
||||
}
|
||||
for (const tag of starts) {
|
||||
const index = tokens.findIndex((token) => token.start === tag.start);
|
||||
tag.next = tokens[index + 1];
|
||||
}
|
||||
return { starts, tokens };
|
||||
}
|
||||
|
||||
// The offset just past the ">" that ends a tag, honouring quoted values.
|
||||
function tagEnd(source: string, from: number): number {
|
||||
let quote = "";
|
||||
for (let i = from; i < source.length; i++) {
|
||||
const c = source[i];
|
||||
if (quote) {
|
||||
if (c === quote) quote = "";
|
||||
} else if (c === '"' || c === "'") {
|
||||
quote = c;
|
||||
} else if (c === ">") {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Which source tag made each element of the parsed page. The parser may add
|
||||
// elements the source never wrote (html, head, body, tbody); anything else it
|
||||
// added, or dropped, means the source cannot be read off the page by position.
|
||||
function align(elements: Element[], starts: Tag[]): Map<Element, Tag> | null {
|
||||
const out = new Map<Element, Tag>();
|
||||
let i = 0;
|
||||
for (const el of elements) {
|
||||
const name = el.localName.toLowerCase();
|
||||
if (starts[i] && starts[i].name === name) {
|
||||
out.set(el, starts[i++]);
|
||||
} else if (!IMPLIED.has(name)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return i === starts.length ? out : null;
|
||||
}
|
||||
|
||||
const collapse = (text: string) => text.replace(/\s+/g, " ").trim();
|
||||
|
||||
const escapeText = (text: string) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const escapeAttr = (text: string) => text.replace(/&/g, "&").replace(/"/g, """);
|
||||
|
||||
// A value that stays inside one declaration. A rejected one is refused rather
|
||||
// than cleaned: what was typed is not what would be saved.
|
||||
const SAFE_VALUE = /^[^;{}<>"\r\n\\]{1,80}$/;
|
||||
|
||||
function declarations(style: string): [string, string][] {
|
||||
const out: [string, string][] = [];
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
let from = 0;
|
||||
const flush = (to: number) => {
|
||||
const part = style.slice(from, to);
|
||||
const colon = part.indexOf(":");
|
||||
if (colon > 0) out.push([part.slice(0, colon).trim(), part.slice(colon + 1).trim()]);
|
||||
};
|
||||
for (let i = 0; i < style.length; i++) {
|
||||
const c = style[i];
|
||||
if (quote) {
|
||||
if (c === quote) quote = "";
|
||||
} else if (c === '"' || c === "'") {
|
||||
quote = c;
|
||||
} else if (c === "(") {
|
||||
depth++;
|
||||
} else if (c === ")") {
|
||||
depth = Math.max(0, depth - 1);
|
||||
} else if (c === ";" && depth === 0) {
|
||||
flush(i);
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
flush(style.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function withStyle(el: Element, tag: Tag, source: string, property: StyleProperty, value: string): Patched {
|
||||
const name = CSS_NAME[property];
|
||||
const had = declarations(el.getAttribute("style") ?? "");
|
||||
const index = had.findIndex(([key]) => key.toLowerCase() === name);
|
||||
const next: [string, string][] = index >= 0 ? had.map((d, i) => (i === index ? [d[0], value] : d)) : [...had, [name, value]];
|
||||
const written = ` style="${escapeAttr(next.map(([k, v]) => `${k}: ${v}`).join("; "))}"`;
|
||||
const body = source.slice(tag.start, tag.end);
|
||||
const attr = /\sstyle\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i.exec(body);
|
||||
if (attr) {
|
||||
const from = tag.start + attr.index;
|
||||
return { ok: true, content: source.slice(0, from) + written + source.slice(from + attr[0].length) };
|
||||
}
|
||||
const at = tag.start + 1 + tag.name.length;
|
||||
return { ok: true, content: source.slice(0, at) + written + source.slice(at) };
|
||||
}
|
||||
|
||||
function withText(el: Element, tag: Tag, source: string, edit: { before: string; after: string }): Patched {
|
||||
if (el.children.length > 0 || RAW_TEXT.has(tag.name)) return { ok: false, code: "not_plain_text" };
|
||||
if (collapse(el.textContent ?? "") !== collapse(edit.before)) return { ok: false, code: "mismatch" };
|
||||
const close = tag.next;
|
||||
if (!close || !close.closing || close.name !== tag.name) return { ok: false, code: "unmappable" };
|
||||
return { ok: true, content: source.slice(0, tag.end) + escapeText(edit.after) + source.slice(close.start) };
|
||||
}
|
||||
|
||||
/** Applies one edit to the source of a page. The element the person picked is
|
||||
* found again in the file, not trusted from the page: the page may have been
|
||||
* changed by its own scripts since the file was written. */
|
||||
export function patchSource(source: string, target: Target, edit: SourceEdit): Patched {
|
||||
if (edit.kind === "style" && !SAFE_VALUE.test(edit.value)) return { ok: false, code: "bad_value" };
|
||||
const scanned = scan(source);
|
||||
if (!scanned) return { ok: false, code: "unmappable" };
|
||||
const doc = new DOMParser().parseFromString(source, "text/html");
|
||||
let found: Element[];
|
||||
try {
|
||||
found = [...doc.querySelectorAll(target.selector)];
|
||||
} catch {
|
||||
return { ok: false, code: "not_found" };
|
||||
}
|
||||
const el = found[0];
|
||||
if (!el || el.localName.toLowerCase() !== target.tagName.toLowerCase()) return { ok: false, code: "not_found" };
|
||||
if (found.length > 1) return { ok: false, code: "ambiguous" };
|
||||
const tags = align([...doc.querySelectorAll("*")], scanned.starts);
|
||||
const tag = tags?.get(el);
|
||||
if (!tag) return { ok: false, code: "unmappable" };
|
||||
return edit.kind === "text" ? withText(el, tag, source, edit) : withStyle(el, tag, source, edit.property, edit.value);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { insideWorkspace, pageEditor } from "./pageedit";
|
||||
|
||||
const PAGE = '<!doctype html><html><body><p id="a">hello</p></body></html>';
|
||||
const target = { selector: "#a", tagName: "p" };
|
||||
|
||||
function portWith(over: Partial<Record<"workspaceFile" | "saveWorkspaceFile", unknown>> = {}) {
|
||||
const save = vi.fn(async (file: { content: string }) => file);
|
||||
const port = {
|
||||
workspaces: async () => ({ current: "/work/site" }),
|
||||
workspaceFile: vi.fn(async (path: string) => ({ path, content: PAGE, revision: "r1" })),
|
||||
saveWorkspaceFile: save,
|
||||
...over,
|
||||
} as unknown as AgentPort;
|
||||
return { port, save };
|
||||
}
|
||||
|
||||
describe("insideWorkspace", () => {
|
||||
it("names a file under the workspace by its relative path", () => {
|
||||
expect(insideWorkspace("/work/site", "/work/site/pages/a.html")).toBe("pages/a.html");
|
||||
expect(insideWorkspace("C:\\work\\site\\", "c:/Work/Site/a.html")).toBe("a.html");
|
||||
});
|
||||
it("answers nothing for a path outside, beside, or escaping the workspace", () => {
|
||||
expect(insideWorkspace("/work/site", "/work/site2/a.html")).toBe("");
|
||||
expect(insideWorkspace("/work/site", "/elsewhere/a.html")).toBe("");
|
||||
expect(insideWorkspace("/work/site", "/work/site/../x/a.html")).toBe("");
|
||||
expect(insideWorkspace("/work/site", "/work/site")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pageEditor", () => {
|
||||
it("writes every edit in one save, against the revision it read", async () => {
|
||||
const { port, save } = portWith();
|
||||
const out = await pageEditor(port)("/work/site/a.html", target, [
|
||||
{ kind: "text", before: "hello", after: "bye" },
|
||||
{ kind: "style", property: "color", value: "red" },
|
||||
]);
|
||||
expect(out).toEqual({ saved: true, path: "a.html" });
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save.mock.calls[0][0]).toEqual({
|
||||
path: "a.html",
|
||||
revision: "r1",
|
||||
content: '<!doctype html><html><body><p style="color: red" id="a">bye</p></body></html>',
|
||||
});
|
||||
});
|
||||
|
||||
it("saves nothing when any one edit cannot be placed", async () => {
|
||||
const { port, save } = portWith();
|
||||
const out = await pageEditor(port)("/work/site/a.html", target, [
|
||||
{ kind: "style", property: "color", value: "red" },
|
||||
{ kind: "text", before: "not what is there", after: "x" },
|
||||
]);
|
||||
expect(out).toEqual({ saved: false, code: "mismatch" });
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch a file outside the workspace", async () => {
|
||||
const { port, save } = portWith();
|
||||
const out = await pageEditor(port)("/tmp/a.html", target, [{ kind: "style", property: "color", value: "red" }]);
|
||||
expect(out).toEqual({ saved: false, code: "outside" });
|
||||
expect(port.workspaceFile).not.toHaveBeenCalled();
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells a file that changed under the page apart from a write that failed", async () => {
|
||||
const changed = new HttpError(409, "changed", { code: "workspace.file_changed" }, true);
|
||||
const a = portWith({ saveWorkspaceFile: vi.fn().mockRejectedValue(changed) });
|
||||
expect(await pageEditor(a.port)("/work/site/a.html", target, [{ kind: "style", property: "color", value: "red" }])).toEqual({ saved: false, code: "conflict" });
|
||||
const b = portWith({ saveWorkspaceFile: vi.fn().mockRejectedValue(new Error("disk")) });
|
||||
expect(await pageEditor(b.port)("/work/site/a.html", target, [{ kind: "style", property: "color", value: "red" }])).toEqual({ saved: false, code: "failed" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { HttpError } from "../port/http_error";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { patchSource, type PatchFailure, type SourceEdit, type Target } from "./htmlsource";
|
||||
|
||||
export type PageEditFailure = PatchFailure | "outside" | "conflict" | "failed";
|
||||
|
||||
export type PageEditOutcome = { saved: true; path: string } | { saved: false; code: PageEditFailure };
|
||||
|
||||
/** Writes edits made on a page back to the file the page was opened from. */
|
||||
export type PageEditor = (diskPath: string, target: Target, edits: SourceEdit[]) => Promise<PageEditOutcome>;
|
||||
|
||||
const flat = (path: string) => path.replaceAll("\\", "/");
|
||||
|
||||
/** The workspace-relative path of a disk path inside the workspace, or "". */
|
||||
export function insideWorkspace(root: string, disk: string): string {
|
||||
const base = flat(root).replace(/\/+$/, "");
|
||||
const path = flat(disk);
|
||||
const windows = /^[A-Za-z]:/.test(base);
|
||||
const head = `${base}/`;
|
||||
const inside = windows ? path.toLowerCase().startsWith(head.toLowerCase()) : path.startsWith(head);
|
||||
if (!inside) return "";
|
||||
const rest = path.slice(head.length);
|
||||
return rest && !rest.split("/").some((part) => part === ".." || part === "") ? rest : "";
|
||||
}
|
||||
|
||||
export function pageEditor(port: AgentPort): PageEditor {
|
||||
return async (disk, target, edits) => {
|
||||
try {
|
||||
const root = (await port.workspaces()).current;
|
||||
const path = root ? insideWorkspace(root, disk) : "";
|
||||
if (!path) return { saved: false, code: "outside" };
|
||||
const file = await port.workspaceFile(path);
|
||||
let content = file.content;
|
||||
for (const edit of edits) {
|
||||
const next = patchSource(content, target, edit);
|
||||
if (!next.ok) return { saved: false, code: next.code };
|
||||
content = next.content;
|
||||
}
|
||||
if (content === file.content) return { saved: true, path };
|
||||
await port.saveWorkspaceFile({ ...file, content });
|
||||
return { saved: true, path };
|
||||
} catch (e) {
|
||||
const code = e instanceof HttpError ? e.reason?.code : "";
|
||||
return { saved: false, code: code === "workspace.file_changed" ? "conflict" : "failed" };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { host } from "../port/host";
|
||||
import type { AgentPort, BrowserTab } from "../port/port";
|
||||
import { preparePage } from "./browser_open";
|
||||
import { pageEditor, type PageEditor } from "./pageedit";
|
||||
import { UNREAD_TABS } from "./BrowserPanel";
|
||||
|
||||
const HTML = /\.html?$/i;
|
||||
@@ -40,6 +41,8 @@ export interface HtmlPreview {
|
||||
tabFor(path: string): BrowserTab | undefined;
|
||||
/** Puts the file in a page beside the agent's, or finds the one already there. */
|
||||
open(path: string, revision: string | undefined): Promise<void>;
|
||||
/** Writes an edit made on a previewed page back to its file; none for a remote workspace. */
|
||||
edit: PageEditor | undefined;
|
||||
/** Reloads the page when the file on disk is no longer the one it loaded. */
|
||||
sync(path: string, revision: string): void;
|
||||
}
|
||||
@@ -112,5 +115,7 @@ export function useHtmlPreview(port: AgentPort, all: BrowserTab[], remote: boole
|
||||
[owned],
|
||||
);
|
||||
|
||||
return { tabs, pending: claim !== null, can, tabFor, open, sync };
|
||||
const edit = useMemo(() => (remote ? undefined : pageEditor(port)), [port, remote]);
|
||||
|
||||
return { tabs, pending: claim !== null, can, tabFor, open, sync, edit };
|
||||
}
|
||||
@@ -10,10 +10,10 @@ interface Props {
|
||||
port: AgentPort; surfaces: Surface[]; selected: string; showFiles: boolean;
|
||||
setBrowsers: Setter<string[]>; setOpenFiles: Setter<string[]>; setAgents: Setter<RunTab[]>; setDismissed: Setter<Set<string>>;
|
||||
setSelected: Setter<string>; onCloseManual: () => void; onCloseSelected: () => void;
|
||||
fromExplorer?: { current: string }; setShowFiles?: Setter<boolean>; setLauncher?: Setter<boolean>;
|
||||
setLauncher?: Setter<boolean>;
|
||||
}
|
||||
|
||||
export function useTabClose({ port, surfaces, selected, showFiles, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, onCloseManual, onCloseSelected, fromExplorer, setShowFiles, setLauncher }: Props) {
|
||||
export function useTabClose({ port, surfaces, selected, showFiles, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, onCloseManual, onCloseSelected, setLauncher }: Props) {
|
||||
const current = useRef({ surfaces, selected, showFiles });
|
||||
current.current = { surfaces, selected, showFiles };
|
||||
const pending = useRef(false);
|
||||
@@ -42,19 +42,10 @@ export function useTabClose({ port, surfaces, selected, showFiles, setBrowsers,
|
||||
if (closed.has(current.current.selected)) onCloseSelected();
|
||||
setSelected((v) => closed.has(v) ? "" : v);
|
||||
setCloseFailure(failures.join("\n"));
|
||||
// A file picked out of the explorer leaves that list stepped aside, not
|
||||
// closed. When that close is the one that would fold the column — nothing
|
||||
// left behind, the list not open — the list comes back instead. Any other
|
||||
// close keeps the previous rule, so the list never lands over a tab that
|
||||
// is still open.
|
||||
// Closing the last page with no list open leaves the column with something
|
||||
// to offer rather than folding it away.
|
||||
if (!current.current.showFiles && current.current.surfaces.every((s) => closed.has(keyOf(s)))) {
|
||||
if (fromExplorer?.current && closed.has(`file:${fromExplorer.current}`)) {
|
||||
fromExplorer.current = "";
|
||||
setLauncher?.(false);
|
||||
setShowFiles?.(true);
|
||||
} else if (targets.some((s) => s.kind !== "file")) {
|
||||
// Closing the last page leaves the column with something to offer
|
||||
// rather than folding it away: the same place a new tab starts from.
|
||||
if (targets.some((s) => s.kind !== "file")) {
|
||||
setLauncher?.(true);
|
||||
} else {
|
||||
onCloseManual();
|
||||
|
||||
@@ -49,7 +49,7 @@ it('retires an opening whose tab was observed then closed before the response',
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not start a preview after its file read finishes behind a newer Files selection', async () => {
|
||||
it('starts a preview once its file read finishes with the file still in view beside the tree', async () => {
|
||||
vi.spyOn(host(), 'drawsBrowserViews').mockReturnValue(true);
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, 'workspaceFiles').mockResolvedValue({files:['index.html'],directories:[]});
|
||||
@@ -62,9 +62,7 @@ it('does not start a preview after its file read finishes behind a newer Files s
|
||||
await waitFor(()=>expect(reading).toHaveBeenCalled());
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
await act(async()=>read({path:'index.html',content:'hello',revision:'r1'}));
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(filesShown()).toBe(true);
|
||||
fireEvent.click(document.querySelector('[data-action-click="workbench.tab"][data-target="file:index.html"]')!);
|
||||
await waitFor(()=>expect(open).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
|
||||
@@ -36,12 +36,19 @@ it("offers a terminal in a new tab and opens it as a tab in front", async () =>
|
||||
expect((await screen.findByTestId("screen")).getAttribute("data-shown")).toBe("yes");
|
||||
});
|
||||
|
||||
it("keeps a terminal's screen mounted but not in front when another tab is chosen", async () => {
|
||||
it("keeps a terminal's screen mounted but not in front while the new-tab launcher is up", async () => {
|
||||
const port = new MockPort();
|
||||
await openTerminalFromLauncher(port);
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.new"]')!);
|
||||
expect((await screen.findByTestId("screen")).getAttribute("data-shown")).toBe("no");
|
||||
});
|
||||
|
||||
it("keeps a terminal's screen in front beside the file list", async () => {
|
||||
const port = new MockPort();
|
||||
await openTerminalFromLauncher(port);
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.new"]')!);
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.launch-files"]')!);
|
||||
expect((await screen.findByTestId("screen")).getAttribute("data-shown")).toBe("no");
|
||||
expect((await screen.findByTestId("screen")).getAttribute("data-shown")).toBe("yes");
|
||||
});
|
||||
|
||||
it("numbers terminals once there are two, and names a busy one for the command it runs with a dot", async () => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import "./testkit";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
|
||||
beforeEach(() => { localStorage.setItem(STORAGE, "zh"); boot(); });
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.clear(); });
|
||||
|
||||
const mount = (port = new MockPort()) =>
|
||||
render(<WorkbenchPanel port={port} tabs={[]} manual shown scheme="light" changes={[]} onCloseManual={vi.fn()} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
|
||||
const filesShown = (c: HTMLElement) => c.querySelector(".workbench-body")!.hasAttribute("data-files");
|
||||
|
||||
async function openTree(c: HTMLElement, user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(c.querySelector('[data-action="workbench.files"]')!);
|
||||
await waitFor(() => expect(c.querySelector('[data-action-click="workbench.file"]')).toBeTruthy());
|
||||
}
|
||||
|
||||
it("keeps the file tree in view when a file is picked from it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = mount();
|
||||
await openTree(container, user);
|
||||
const row = container.querySelector<HTMLElement>('[data-action-click="workbench.file"]')!;
|
||||
const path = row.dataset.target!;
|
||||
await user.click(row);
|
||||
expect(screen.getAllByRole("tab").length).toBeGreaterThan(0);
|
||||
expect(filesShown(container)).toBe(true);
|
||||
expect(container.querySelector<HTMLElement>(`[data-action-click="workbench.file"][data-target="${path}"]`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lets the next file be picked from the same tree without reopening it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "workspaceFiles").mockResolvedValue({ files: ["a.md", "b.md"], directories: [] });
|
||||
const { container } = mount(port);
|
||||
await openTree(container, user);
|
||||
await user.click(container.querySelector<HTMLElement>('[data-target="a.md"]')!);
|
||||
await user.click(container.querySelector<HTMLElement>('[data-target="b.md"]')!);
|
||||
expect(filesShown(container)).toBe(true);
|
||||
expect(screen.getAllByRole("tab")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("leaves the tree where the user put it when its file is closed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = mount();
|
||||
await openTree(container, user);
|
||||
await user.click(container.querySelector<HTMLElement>('[data-action-click="workbench.file"]')!);
|
||||
await user.click(screen.getAllByRole("tab")[0].querySelector(".workbench-tab-close")!);
|
||||
expect(filesShown(container)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not bring the tree back after the user folded it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = mount();
|
||||
await openTree(container, user);
|
||||
await user.click(container.querySelector<HTMLElement>('[data-action-click="workbench.file"]')!);
|
||||
await user.click(container.querySelector('[data-action="workbench.files"]')!);
|
||||
expect(filesShown(container)).toBe(false);
|
||||
await user.click(screen.getAllByRole("tab")[0].querySelector(".workbench-tab-close")!);
|
||||
expect(filesShown(container)).toBe(false);
|
||||
});
|
||||
@@ -38,7 +38,7 @@ Boot 效果测试覆盖关闭时缺席、启用时精确的 schema、读取拒
|
||||
|
||||
| Tool | Read-only | Description |
|
||||
| --- | --- | --- |
|
||||
| `bash` | false | Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors. |
|
||||
| `bash` | false | Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors. To search, call tool:grep (contents) or tool:glob (names) through use_capability — not grep, rg or find in the shell; the built-ins honor .gitignore and cap their output. A pipeline over another command's output (git log \| grep) is the exception. |
|
||||
| `bash_output` | true | Read new output from a background job started with bash(run_in_background=true) or task(run_in_background=true). Returns the output produced since the last bash_output call for that job, plus its status (running/done/failed/killed). Does not block. Omit job_id to list the jobs still running, with their kind, label and elapsed time. |
|
||||
| `browser_act` | false | Operate a page with real input. Ordered steps stop at the first failure; target by ref, or screenshot x/y. Returns results, page errors and changes. fill replaces, type inserts; press takes keys like Enter or Control+a; select sets a <select>; scroll takes delta_y or delta_x; drag ends at to_ref or to_x/to_y; upload gives a file input workspace files; dialog answers prompts; eval executes arbitrary page JavaScript from script and awaits promises; resize sets the viewport to width×height; back, forward and reload move this tab. |
|
||||
| `browser_open` | false | Load a page and return its snapshot, one element per line with [eN] refs. url: http(s), about:blank or a workspace file; no url switches to tab; close closes. The user sees tabs: web_fetch reads static text, close tabs when done. |
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/safety/sandbox"
|
||||
)
|
||||
@@ -60,7 +61,7 @@ func TestProjectEnvCannotRedirectAuthorityEndpoints(t *testing.T) {
|
||||
host := shortSocket(t, "HOST-DAEMON")
|
||||
attacker := shortSocket(t, "ATTACKER")
|
||||
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
t.Setenv("HOME", filepath.Join(root, "home"))
|
||||
t.Setenv("USERPROFILE", filepath.Join(root, "home"))
|
||||
t.Setenv("DOCKER_HOST", "unix://"+host)
|
||||
@@ -152,7 +153,7 @@ while time.time() < deadline:
|
||||
t.Fatalf("the descriptor scan cannot detect an inherited socket, so the check below is vacuous: %q", out)
|
||||
}
|
||||
|
||||
spec := sandbox.Spec{Mode: "enforce", WriteRoots: []string{t.TempDir()}, Network: true}
|
||||
spec := sandbox.Spec{Mode: "enforce", WriteRoots: []string{testenv.TempDir(t)}, Network: true}
|
||||
argv, wrapped := sandbox.CommandArgs(spec, []string{"python3", "-c", scan})
|
||||
if !wrapped {
|
||||
t.Fatal("spec did not wrap")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/agentcomposition"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/state/agentpreset"
|
||||
@@ -15,7 +16,7 @@ func TestCompositionRetainsLinkedSkillAcrossRestart(t *testing.T) {
|
||||
for _, layout := range []string{"directory", "file"} {
|
||||
t.Run(layout, func(t *testing.T) {
|
||||
home, root := compositionTestWorkspace(t, "openai")
|
||||
source := t.TempDir()
|
||||
source := testenv.TempDir(t)
|
||||
writeFile(t, source, "SKILL.md", "---\nname: linked\ndescription: linked skill\n---\nORIGINAL SKILL")
|
||||
writeFile(t, source, "references/guide.md", "ORIGINAL REFERENCE")
|
||||
target, link := source, filepath.Join(home, "skills", "linked")
|
||||
@@ -83,7 +84,7 @@ func TestCompositionRetainsLinkedSkillAcrossRestart(t *testing.T) {
|
||||
|
||||
func TestCompositionLinkedSkillRejectsLinkedResources(t *testing.T) {
|
||||
home, root := compositionTestWorkspace(t, "openai")
|
||||
source, outside := t.TempDir(), t.TempDir()
|
||||
source, outside := testenv.TempDir(t), testenv.TempDir(t)
|
||||
writeFile(t, source, "SKILL.md", "---\nname: linked\ndescription: linked skill\n---\nSKILL")
|
||||
writeFile(t, outside, "private.txt", "must not be retained")
|
||||
if err := os.Symlink(filepath.Join(outside, "private.txt"), filepath.Join(source, "reference.md")); err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/agentcomposition"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/plugin"
|
||||
@@ -21,7 +22,7 @@ func TestLiveCompositionDoesNotCaptureUnusedSkillAssets(t *testing.T) {
|
||||
home, root := compositionTestWorkspace(t, "openai")
|
||||
writeFile(t, home, "skills/templates/SKILL.md", "---\nname: templates\ndescription: template library\n---\nRead templates when needed.")
|
||||
// An unused link cannot participate in assembling a capability grant.
|
||||
if err := os.Symlink(t.TempDir(), filepath.Join(home, "skills/templates/unused")); err != nil {
|
||||
if err := os.Symlink(testenv.TempDir(t), filepath.Join(home, "skills/templates/unused")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
d := agentcomposition.Builtins()[0]
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/safety/sandbox"
|
||||
)
|
||||
@@ -26,11 +27,11 @@ const probeSecret = "REASONIX-PROBE-PRIVATE-KEY-a7f3c1"
|
||||
// fakeHome plants files under a temporary HOME, returning the write root that
|
||||
// must be bound for them to be visible inside the sandbox, a workspace under
|
||||
// it, and the forbid-read roots. The HOME has to sit under the bound root:
|
||||
// bubblewrap swaps /tmp for a fresh tmpfs, so a t.TempDir() home is absent
|
||||
// bubblewrap swaps /tmp for a fresh tmpfs, so a testenv.TempDir(t) home is absent
|
||||
// inside the sandbox and every deny assertion would pass without denying.
|
||||
func fakeHome(t *testing.T, files map[string]string) (bound, ws string, forbid []string) {
|
||||
t.Helper()
|
||||
bound = t.TempDir()
|
||||
bound = testenv.TempDir(t)
|
||||
home := filepath.Join(bound, "home")
|
||||
ws = filepath.Join(bound, "ws")
|
||||
if err := os.MkdirAll(ws, 0o700); err != nil {
|
||||
@@ -222,7 +223,7 @@ func TestOrdinaryDevWorkIsUnaffected(t *testing.T) {
|
||||
// invocation fails at startup, and npm silently relocates its global prefix —
|
||||
// so they are the broker's problem, not the denylist's.
|
||||
func TestMixedConfigCredentialFilesStayReadable(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
for _, rel := range []string{".config/gh/hosts.yml", ".npmrc", ".docker/config.json"} {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/pricing"
|
||||
"reasonix/internal/contract/provider"
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
func TestEffectAnthropicPartialUsageRecovery(t *testing.T) {
|
||||
for _, mode := range []string{"upstream_error", "premature_eof", "complete_control"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
home, root := statedBootHome(t), t.TempDir()
|
||||
home, root := statedBootHome(t), testenv.TempDir(t)
|
||||
writeFile(t, root, "fixture.txt", "partial usage fixture")
|
||||
partial := mode != "complete_control"
|
||||
var calls, reads atomic.Int32
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/model/billing"
|
||||
@@ -26,7 +27,7 @@ func buildWithWallet(t *testing.T, balanceFormat string) (*atomic.Int64, billing
|
||||
t.Cleanup(wallet.Close)
|
||||
|
||||
home := statedBootHome(t)
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
kind := "boot-wallet-" + strings.ReplaceAll(t.Name(), "/", "-") + "-" + balanceFormat + "-fmt"
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return &peerRecorder{}, nil })
|
||||
format := ""
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
@@ -29,7 +30,7 @@ func (s *budgetCompletenessSink) RecordRunBudget(sample event.RunBudgetSample) {
|
||||
func TestEffectBudgetCompletenessFromOpenAIStream(t *testing.T) {
|
||||
for _, missing := range []int32{0, 1, 2} {
|
||||
t.Run(fmt.Sprintf("missing_%d", missing), func(t *testing.T) {
|
||||
home, root := statedBootHome(t), t.TempDir()
|
||||
home, root := statedBootHome(t), testenv.TempDir(t)
|
||||
writeFile(t, root, "fixture.txt", "fixture content")
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/ext/hook"
|
||||
"reasonix/internal/session/control"
|
||||
@@ -40,7 +41,7 @@ func TestEffectCancelledCheckpointAdmissionStopsUserCode(t *testing.T) {
|
||||
scenario = name + "/capability"
|
||||
}
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
home, root := statedBootHome(t), t.TempDir()
|
||||
home, root := statedBootHome(t), testenv.TempDir(t)
|
||||
writeFile(t, root, "note.txt", "user bytes")
|
||||
marker := filepath.Join(root, "hook.txt")
|
||||
args := `{"path":"note.txt"}`
|
||||
|
||||
@@ -3,6 +3,7 @@ package boot
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/platform/computer"
|
||||
"strings"
|
||||
@@ -16,7 +17,7 @@ func TestEffectComputerLaunchKeepsItsApprovalSeparateFromCanonicalRead(t *testin
|
||||
}
|
||||
t.Setenv(fakeComputerHelperEnv, "1")
|
||||
t.Setenv("REASONIX_BOOT_CUA_INSTALLED_APP", exe)
|
||||
log := filepath.Join(t.TempDir(), "requests.jsonl")
|
||||
log := filepath.Join(testenv.TempDir(t), "requests.jsonl")
|
||||
t.Setenv("REASONIX_BOOT_CUA_REQUEST_LOG", log)
|
||||
driver := computer.NewDriver(exe, os.Environ())
|
||||
t.Cleanup(driver.Close)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/platform/computer"
|
||||
"reasonix/internal/safety/evidence"
|
||||
@@ -93,7 +94,7 @@ func TestEffectComputerUseThroughTheRealAssembly(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(fakeComputerHelperEnv, "1")
|
||||
log := filepath.Join(t.TempDir(), "native-requests.jsonl")
|
||||
log := filepath.Join(testenv.TempDir(t), "native-requests.jsonl")
|
||||
t.Setenv("REASONIX_BOOT_CUA_REQUEST_LOG", log)
|
||||
driver := computer.NewDriver(exe, os.Environ())
|
||||
t.Cleanup(driver.Close)
|
||||
|
||||
@@ -22,61 +22,60 @@ func (p *peerToolProvider) sawInMessages(needle string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// While the user's dispatch switch is on, a task from the session that opened
|
||||
// this one reaches the model with no card, framed as data from that session and
|
||||
// not as something the user read; the same words from a session the host never
|
||||
// paired it with, or signed with another secret, wait for the user as before.
|
||||
func TestEffectADispatchedTaskReachesTheModelWithoutACardAndAStrangersDoesNot(t *testing.T) {
|
||||
// With the dispatch switch on, a task from the session that opened this one
|
||||
// reaches the model with no card, framed as data the user did not read. The same
|
||||
// words from an unpaired or wrongly signed sender arrive the same way but are not
|
||||
// called paired. Each case is its own session: the stub provider's turn waits on a
|
||||
// card of its own.
|
||||
func TestEffectADispatchedTaskReachesTheModelWithoutACardAndAStrangerIsNotCalledPaired(t *testing.T) {
|
||||
const parent = "s-00000000000000bb"
|
||||
secret := []byte("0123456789abcdef0123456789abcdef")
|
||||
rec, ctrl, peersDir, seen := buildPeerToolSession(t, true, "nobody", "hello", "dispatch = true\n")
|
||||
t.Setenv("REASONIX_HOME", filepath.Dir(peersDir))
|
||||
receiver := ctrl.PeerKey()
|
||||
if receiver == "" {
|
||||
t.Fatal("the receiving session did not publish itself")
|
||||
}
|
||||
ctrl.MarkOpenedByDispatch(parent, secret, 1)
|
||||
d, err := peermail.Open(peersDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var seq uint64
|
||||
send := func(id, body string, signWith []byte) {
|
||||
t.Helper()
|
||||
seq++
|
||||
m := peermail.Message{ID: id, SentAt: time.Now().UTC(), Body: body, Seq: seq,
|
||||
From: peermail.Party{SessionID: parent, Title: "planner", Project: "alpha"}, To: peermail.Party{SessionID: receiver}}
|
||||
m.Proof = peermail.Proof(signWith, m)
|
||||
if err := d.Send(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
send("m-forged", "FORGED-TASK-WITH-ANOTHER-SECRET", []byte("another secret another secret!!!"))
|
||||
ctrl.RefreshPeers()
|
||||
if held := ctrl.HeldPeerMessages(); len(held) != 1 || held[0].Body != "FORGED-TASK-WITH-ANOTHER-SECRET" {
|
||||
t.Fatalf("held = %+v, want the badly signed task waiting for a card", held)
|
||||
}
|
||||
|
||||
send("m-real", "ROTATE-THE-KEY-TASK", secret)
|
||||
ctrl.RefreshPeers()
|
||||
waitFor(t, "the dispatched task to reach the provider", func() bool { return rec.sawInMessages("ROTATE-THE-KEY-TASK") })
|
||||
if rec.sawInMessages("FORGED-TASK-WITH-ANOTHER-SECRET") {
|
||||
t.Fatal("a task signed with another secret reached the model")
|
||||
}
|
||||
if !rec.sawInMessages("has not read this message") || !rec.sawInMessages("not an instruction from the user") {
|
||||
t.Fatal("the dispatched task reached the model without saying the user did not read it and that it is data")
|
||||
}
|
||||
// The stub provider's own send_peer_message call is the only card there may be:
|
||||
// nothing asked the user to let the dispatched task in.
|
||||
seen.mu.Lock()
|
||||
for _, card := range seen.approvals {
|
||||
if card.Tool != "send_peer_message" {
|
||||
t.Errorf("unexpected approval card: %+v", card)
|
||||
}
|
||||
}
|
||||
seen.mu.Unlock()
|
||||
if got := ctrl.HeldPeerMessages(); len(got) != 1 {
|
||||
t.Fatalf("held = %+v, want only the badly signed one still waiting", got)
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
signWith []byte
|
||||
paired bool
|
||||
}{
|
||||
{"forged", "FORGED-TASK-WITH-ANOTHER-SECRET", []byte("another secret another secret!!!"), false},
|
||||
{"paired", "ROTATE-THE-KEY-TASK", secret, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec, ctrl, peersDir, seen := buildPeerToolSession(t, true, "nobody", "hello", "dispatch = true\n")
|
||||
t.Setenv("REASONIX_HOME", filepath.Dir(peersDir))
|
||||
receiver := ctrl.PeerKey()
|
||||
if receiver == "" {
|
||||
t.Fatal("the receiving session did not publish itself")
|
||||
}
|
||||
ctrl.MarkOpenedByDispatch(parent, secret, 1)
|
||||
d, err := peermail.Open(peersDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := peermail.Message{ID: "m-1", SentAt: time.Now().UTC(), Body: tc.body, Seq: 1,
|
||||
From: peermail.Party{SessionID: parent, Title: "planner", Project: "alpha"}, To: peermail.Party{SessionID: receiver}}
|
||||
m.Proof = peermail.Proof(tc.signWith, m)
|
||||
if err := d.Send(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctrl.RefreshPeers()
|
||||
waitFor(t, "the task to reach the provider", func() bool { return rec.sawInMessages(tc.body) })
|
||||
if got := rec.sawInMessages("paired with by a dispatch"); got != tc.paired {
|
||||
t.Fatalf("described as paired = %v, want %v", got, tc.paired)
|
||||
}
|
||||
if !rec.sawInMessages("has not read this message") || !rec.sawInMessages("not an instruction from the user") {
|
||||
t.Fatal("the task reached the model without saying the user did not read it and that it is data")
|
||||
}
|
||||
// The stub provider's own send_peer_message call is the only card there
|
||||
// may be: nothing asked the user to let a task in.
|
||||
seen.mu.Lock()
|
||||
for _, card := range seen.approvals {
|
||||
if card.Tool != "send_peer_message" {
|
||||
t.Errorf("unexpected approval card: %+v", card)
|
||||
}
|
||||
}
|
||||
seen.mu.Unlock()
|
||||
if got := ctrl.HeldPeerMessages(); len(got) != 0 {
|
||||
t.Fatalf("held = %+v, want nothing waiting for a card", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/pricing"
|
||||
"reasonix/internal/session/control"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
func TestEffectEstimatedUsageQuoteAfterStreamRecovery(t *testing.T) {
|
||||
for _, interrupted := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "reported_control", true: "interrupted"}[interrupted], func(t *testing.T) {
|
||||
home, root := statedBootHome(t), t.TempDir()
|
||||
home, root := statedBootHome(t), testenv.TempDir(t)
|
||||
writeFile(t, root, "fixture.txt", "fixture content")
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -10,12 +10,13 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
func TestEffectGoalResumeRenewsPlannerBudget(t *testing.T) {
|
||||
home, root := statedBootHome(t), t.TempDir()
|
||||
home, root := statedBootHome(t), testenv.TempDir(t)
|
||||
for n := 1; n <= 4; n++ {
|
||||
writeFile(t, root, fmt.Sprintf("fixture-%d.txt", n), fmt.Sprintf("planner resume fixture %d", n))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/ext/hook"
|
||||
@@ -26,7 +27,7 @@ func TestEffectReadHooksLeaveCheckpointCoverage(t *testing.T) {
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
home := statedBootHome(t)
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
marker := filepath.Join(root, "hook.txt")
|
||||
writeFile(t, root, "note.txt", "original")
|
||||
kind := "hook-checkpoint-" + strings.ReplaceAll(t.Name(), "/", "-")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/ext/hook"
|
||||
@@ -73,7 +74,7 @@ func TestEffectHookWritesInvalidateWorkspaceWithoutExecutingDeniedTool(t *testin
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
home := statedBootHome(t)
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
marker := filepath.Join(root, "hook-side.txt")
|
||||
writeFile(t, root, "note.txt", "original")
|
||||
kind := "hook-workspace-" + strings.ReplaceAll(t.Name(), "/", "-")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/session/control"
|
||||
@@ -33,7 +34,7 @@ func TestEffectBackgroundJobNoLaunchFinalizesResources(t *testing.T) {
|
||||
mode = "capability"
|
||||
}
|
||||
t.Run(name+"/"+mode, func(t *testing.T) {
|
||||
home, root := statedBootHome(t), t.TempDir()
|
||||
home, root := statedBootHome(t), testenv.TempDir(t)
|
||||
child := &noLaunchProvider{}
|
||||
var ctrl *control.Controller
|
||||
parent := &browserScriptProvider{rounds: []func(string) *provider.ToolCall{
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/session/control"
|
||||
@@ -107,7 +108,7 @@ func TestEffectAModQueuesAPromptThatReachesTheModelLabelledAsTheMods(t *testing.
|
||||
done <- struct{}{}
|
||||
}
|
||||
})
|
||||
res, err := BuildRuntime(t.Context(), Options{Sink: sink, SessionDir: t.TempDir()})
|
||||
res, err := BuildRuntime(t.Context(), Options{Sink: sink, SessionDir: testenv.TempDir(t)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/state/peermail"
|
||||
)
|
||||
|
||||
@@ -74,7 +75,7 @@ func TestEffectAReleasedPeerMessageStartsATurnOnAnIdleSession(t *testing.T) {
|
||||
// nothing into the prompt and does not hold the queue.
|
||||
func TestEffectPeerMessageTextIsNeverReadForFileReferences(t *testing.T) {
|
||||
rec, peersDir, ctrl, held := buildPeerSession(t, true)
|
||||
home := t.TempDir()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("REASONIX_HOME", filepath.Dir(peersDir))
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestEffectPeerMessageAgainstAPausedQueue(t *testing.T) {
|
||||
rec, peersDir, ctrl, held := buildPeerSession(t, true)
|
||||
home := filepath.Dir(peersDir)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
config := "[peers]\nenabled = true\n"
|
||||
config := "[peers]\nenabled = true\ndispatch = false\n"
|
||||
if tc.consent == "release" {
|
||||
config += fmt.Sprintf("release_projects = [%q]\n", ctrl.WorkspaceRoot())
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ name = "test-model"
|
||||
kind = "`+kind+`"
|
||||
model = "x"
|
||||
`)
|
||||
writeFile(t, home, "config.toml", fmt.Sprintf("[peers]\nenabled = %t\n", enabled))
|
||||
writeFile(t, home, "config.toml", fmt.Sprintf("[peers]\nenabled = %t\ndispatch = false\n", enabled))
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard})
|
||||
if err != nil {
|
||||
@@ -111,8 +111,8 @@ func TestEffectExplicitPeerOptOutStaysUnpublished(t *testing.T) {
|
||||
// then as one host-labelled block in the turn's tail, and the cache-stable
|
||||
// prefix is the one every other turn of the session sends.
|
||||
func TestEffectAHeldPeerMessageReachesTheProviderOnlyAfterApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec, peersDir, ctrl, held := buildPeerSession(t, true)
|
||||
t.Setenv("REASONIX_HOME", filepath.Dir(peersDir))
|
||||
if err := ctrl.Run(context.Background(), "an ordinary turn"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
@@ -37,7 +38,7 @@ func slowShellBuild(t *testing.T, kind string, tune func(*sandbox.ShellDiscovery
|
||||
|
||||
func fakeBashOnPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
bin := t.TempDir()
|
||||
bin := testenv.TempDir(t)
|
||||
name := "bash"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "bash.exe"
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
@@ -16,7 +17,7 @@ var tariffKinds atomic.Int64
|
||||
func tariffOf(t *testing.T, baseURL string, price string) bool {
|
||||
t.Helper()
|
||||
home := statedBootHome(t)
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
kind := fmt.Sprintf("boot-tariff-%s-%d", strings.ReplaceAll(t.Name(), "/", "-"), tariffKinds.Add(1))
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return &peerRecorder{}, nil })
|
||||
body := `
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/ext/extension"
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
// notice naming the extension, the point and the reason — and it does so once.
|
||||
func TestMissingSidecarReachesTheSinkAsATypedNotice(t *testing.T) {
|
||||
sink := ¬iceSink{}
|
||||
stage, err := startExtensions(context.Background(), Options{}, config.RootsForHome(t.TempDir()), t.TempDir(), nil, sink)
|
||||
stage, err := startExtensions(context.Background(), Options{}, config.RootsForHome(testenv.TempDir(t)), testenv.TempDir(t), nil, sink)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -48,7 +49,7 @@ func TestMissingSidecarReachesTheSinkAsATypedNotice(t *testing.T) {
|
||||
// sidecar missing right after an extension was installed still reaches the sink.
|
||||
func TestRebuiltDispatcherKeepsTheMissingSidecarNotice(t *testing.T) {
|
||||
sink := ¬iceSink{}
|
||||
stage, err := startExtensions(context.Background(), Options{}, config.RootsForHome(t.TempDir()), t.TempDir(), nil, sink)
|
||||
stage, err := startExtensions(context.Background(), Options{}, config.RootsForHome(testenv.TempDir(t)), testenv.TempDir(t), nil, sink)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/tool"
|
||||
@@ -26,7 +27,7 @@ func TestRejectedMCPCacheDiscoversWithoutPinning(t *testing.T) {
|
||||
{"always_after_handshake", true, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
|
||||
t.Setenv("REASONIX_CACHE_HOME", testenv.TempDir(t))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
listed, release := make(chan struct{}), make(chan struct{})
|
||||
|
||||
@@ -10,14 +10,15 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/ext/skill"
|
||||
)
|
||||
|
||||
func TestSkillDiagnosticsReachRendererAcrossRediscovery(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
home := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
var raw bytes.Buffer
|
||||
var notices []event.Event
|
||||
@@ -72,8 +73,8 @@ func TestSkillDiagnosticDestinationsAtStartup(t *testing.T) {
|
||||
for _, interactive := range []bool{false, true} {
|
||||
for _, suppressed := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("renderer=%t/suppressed=%t", interactive, suppressed), func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
root := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
dir := filepath.Join(root, ".reasonix", "skills", "startup-warning")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/session/control"
|
||||
@@ -49,7 +50,7 @@ model = "x"
|
||||
// a read that succeeded counts; a read that failed, and a file outside the
|
||||
// workspace, do not.
|
||||
func TestEffectCompletedReadsFeedTheSkillPathSet(t *testing.T) {
|
||||
outside := filepath.Join(t.TempDir(), "elsewhere.go")
|
||||
outside := filepath.Join(testenv.TempDir(t), "elsewhere.go")
|
||||
if err := os.WriteFile(outside, []byte("package x\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -161,7 +162,7 @@ func TestEffectGrepRecordsOnlyAFileItWasPointedAt(t *testing.T) {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer fresh.Close()
|
||||
if err := fresh.Resume(saved, filepath.Join(t.TempDir(), "resumed.jsonl")); err != nil {
|
||||
if err := fresh.Resume(saved, filepath.Join(testenv.TempDir(t), "resumed.jsonl")); err != nil {
|
||||
t.Fatalf("Resume: %v", err)
|
||||
}
|
||||
if got := fresh.TouchedPaths(); !reflect.DeepEqual(got, want) {
|
||||
@@ -191,7 +192,7 @@ func TestEffectResumeResolvesSymlinksAgainstTheCurrentDisk(t *testing.T) {
|
||||
for _, m := range ctrl.History() {
|
||||
saved.Add(m)
|
||||
}
|
||||
elsewhere := t.TempDir()
|
||||
elsewhere := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(elsewhere, "a.go"), []byte("package x\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"SystemHash": "bc08dbc97c837bba",
|
||||
"ToolsHash": "8e997d80438fe9b9",
|
||||
"PrefixHash": "1c79eb3c43c306ba",
|
||||
"ToolsHash": "6a63bb25e421685d",
|
||||
"PrefixHash": "4c8bc5e578932c06",
|
||||
"LogRewriteVersion": 0,
|
||||
"ToolSchemaTokens": 4997
|
||||
"ToolSchemaTokens": 5076
|
||||
}
|
||||
@@ -121,7 +121,7 @@
|
||||
},
|
||||
{
|
||||
"name": "bash",
|
||||
"description": "Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors.",
|
||||
"description": "Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors. To search, call tool:grep (contents) or tool:glob (names) through use_capability — not grep, rg or find in the shell; the built-ins honor .gitignore and cap their output. A pipeline over another command's output (git log | grep) is the exception.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
@@ -362,7 +362,7 @@
|
||||
},
|
||||
{
|
||||
"name": "send_peer_message",
|
||||
"description": "Send text to another Reasonix session on this machine, named by list_peers. The message waits in that session until its own user approves it; only then does its model read it, labelled as coming from you. Sending grants no permission and is not a way to run anything there: it is for passing information. Do not include secrets.",
|
||||
"description": "Send text to another Reasonix session on this machine, named by list_peers. While the user's dispatch switch is on (the default) its model reads it as soon as that session is free, labelled as coming from you; with the switch off it waits there for its user to approve it. Sending grants no permission and is not a way to run anything there: it is for passing information. Do not include secrets.",
|
||||
"parameters": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
},
|
||||
{
|
||||
"Name": "bash",
|
||||
"Description": "Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors.",
|
||||
"Description": "Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors. To search, call tool:grep (contents) or tool:glob (names) through use_capability — not grep, rg or find in the shell; the built-ins honor .gitignore and cap their output. A pipeline over another command's output (git log | grep) is the exception.",
|
||||
"ReadOnly": false,
|
||||
"Schema": {
|
||||
"properties": {
|
||||
@@ -344,7 +344,7 @@
|
||||
},
|
||||
{
|
||||
"Name": "send_peer_message",
|
||||
"Description": "Send text to another Reasonix session on this machine, named by list_peers. The message waits in that session until its own user approves it; only then does its model read it, labelled as coming from you. Sending grants no permission and is not a way to run anything there: it is for passing information. Do not include secrets.",
|
||||
"Description": "Send text to another Reasonix session on this machine, named by list_peers. While the user's dispatch switch is on (the default) its model reads it as soon as that session is free, labelled as coming from you; with the switch off it waits there for its user to approve it. Sending grants no permission and is not a way to run anything there: it is for passing information. Do not include secrets.",
|
||||
"ReadOnly": false,
|
||||
"Schema": {
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/hook"
|
||||
"reasonix/internal/safety/sandbox"
|
||||
@@ -18,7 +19,7 @@ import (
|
||||
// checkout's [tools.shell] must never reach the resolver or the runtime.
|
||||
func TestUserHookRunnerIgnoresCheckoutShell(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
checkout := t.TempDir()
|
||||
checkout := testenv.TempDir(t)
|
||||
// An installed-looking path: one where sandboxed commands write is refused
|
||||
// before it could reach the merged config this test starts from.
|
||||
evil := filepath.Join(filepath.VolumeName(os.TempDir())+string(filepath.Separator), "opt", "reasonix-test", "evil-bash")
|
||||
@@ -65,7 +66,7 @@ func TestUserHookRunnerIgnoresCheckoutShell(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("user shell is honoured", func(t *testing.T) {
|
||||
userShell := filepath.Join(t.TempDir(), "user-bash")
|
||||
userShell := filepath.Join(testenv.TempDir(t), "user-bash")
|
||||
userConfig := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -85,7 +86,7 @@ func TestUserHookRunnerIgnoresCheckoutShell(t *testing.T) {
|
||||
// python.exe the branch ships before the user's own interpreter.
|
||||
func TestUserHookRunnerDisablesCwdExeSearch(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
checkout := t.TempDir()
|
||||
checkout := testenv.TempDir(t)
|
||||
if err := hook.Save(hook.ScopeGlobal, "", hook.Settings{Hooks: map[hook.Event][]hook.HookConfig{
|
||||
hook.PreToolUse: {{Match: "read_file", Command: `python C:\hooks\guard.py`}},
|
||||
}}); err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ func TestWorkspaceBranchSummaryReadsOverlapThroughBuild(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("git wrapper uses POSIX shell")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Skip("git unavailable")
|
||||
@@ -33,7 +34,7 @@ func TestWorkspaceBranchSummaryReadsOverlapThroughBuild(t *testing.T) {
|
||||
t.Fatalf("git %v: %v %s", args, err, out)
|
||||
}
|
||||
}
|
||||
home := t.TempDir()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Setenv("REASONIX_STATE_HOME", home)
|
||||
ctrl, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard, Stderr: io.Discard})
|
||||
@@ -41,7 +42,7 @@ func TestWorkspaceBranchSummaryReadsOverlapThroughBuild(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
bin, signals := t.TempDir(), t.TempDir()
|
||||
bin, signals := testenv.TempDir(t), testenv.TempDir(t)
|
||||
wrapper := `#!/bin/sh
|
||||
probe=
|
||||
for arg in "$@"; do
|
||||
|
||||
@@ -6,13 +6,14 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/platform/gitcmd"
|
||||
)
|
||||
|
||||
// A folder that carries a .git git will not open keeps an empty identity, and
|
||||
// says that git refused it rather than that there is no repository.
|
||||
func TestWorkspaceRepoSaysWhyAMarkedFolderHasNoIdentity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: ./nowhere\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -20,7 +21,7 @@ func TestWorkspaceRepoSaysWhyAMarkedFolderHasNoIdentity(t *testing.T) {
|
||||
if repo.Valid() || repo.Absent != gitcmd.AbsenceRefused || repo.AbsentDetail == "" {
|
||||
t.Fatalf("repo = %+v, want an empty identity that says git refused it", repo)
|
||||
}
|
||||
plain := workspaceRepo(context.Background(), gitcmd.Repo{}, t.TempDir())
|
||||
plain := workspaceRepo(context.Background(), gitcmd.Repo{}, testenv.TempDir(t))
|
||||
if plain.Valid() || plain.Absent != "" {
|
||||
t.Fatalf("a folder with no marker = %+v, want an empty identity with no absence", plain)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package fileutil
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestUnderExcludesTheRootItself(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
if Under(root, root) {
|
||||
t.Error("Under(root, root) = true; the root is not below itself")
|
||||
}
|
||||
@@ -16,7 +18,7 @@ func TestUnderExcludesTheRootItself(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAtOrUnderIncludesTheRootItself(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
for _, path := range []string{root, filepath.Join(root, "child"), filepath.Join(root, "a", "b")} {
|
||||
if !AtOrUnder(path, root) {
|
||||
t.Errorf("AtOrUnder(%q, root) = false", path)
|
||||
@@ -25,7 +27,7 @@ func TestAtOrUnderIncludesTheRootItself(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContainmentRejectsEscapes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
outside := filepath.Join(filepath.Dir(root), "sibling")
|
||||
for _, path := range []string{outside, filepath.Join(root, "..", "sibling"), filepath.Dir(root)} {
|
||||
if AtOrUnder(path, root) {
|
||||
@@ -40,7 +42,7 @@ func TestContainmentRejectsEscapes(t *testing.T) {
|
||||
// An interior ".." must be resolved before the comparison, or a path spells
|
||||
// its way inside a root it never reaches.
|
||||
func TestContainmentResolvesInteriorDotDot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
if !AtOrUnder(filepath.Join(root, "a", "..", "b"), root) {
|
||||
t.Error("a path that cleans to root/b is inside root")
|
||||
}
|
||||
@@ -50,7 +52,7 @@ func TestContainmentResolvesInteriorDotDot(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContainmentRejectsEmptySides(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
if AtOrUnder("", root) || AtOrUnder(root, "") || Under("", root) || Under(root, "") {
|
||||
t.Error("an empty side answers no question and is not containment")
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestContentVersionTracksBytesKindAndAbsence(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "file")
|
||||
path := filepath.Join(testenv.TempDir(t), "file")
|
||||
absent, err := ReadContentVersion(t.Context(), path)
|
||||
if err != nil || absent.Kind != "absent" {
|
||||
t.Fatalf("absent: %+v %v", absent, err)
|
||||
@@ -43,13 +45,13 @@ func TestContentVersionTracksBytesKindAndAbsence(t *testing.T) {
|
||||
func TestContentVersionCancellationPrecedesFilesystemAccess(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if _, err := ReadContentVersion(ctx, filepath.Join(t.TempDir(), "missing")); !errors.Is(err, context.Canceled) {
|
||||
if _, err := ReadContentVersion(ctx, filepath.Join(testenv.TempDir(t), "missing")); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled read: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentVersionHashesSymlinkTextWithoutReadingTarget(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "link")
|
||||
path := filepath.Join(testenv.TempDir(t), "link")
|
||||
if err := os.Symlink("missing-target", path); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// A workspace opened at a drive root was listed as "\": filepath.Base answers
|
||||
@@ -25,7 +27,7 @@ func TestRootNameOfAVolumeIsTheVolume(t *testing.T) {
|
||||
|
||||
// An ordinary directory keeps the name it already had.
|
||||
func TestRootNameOfADirectoryIsItsBase(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
if got, want := RootName(dir), filepath.Base(dir); got != want {
|
||||
t.Fatalf("RootName(%q) = %q, want %q", dir, got, want)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestMarkUntrackedKeepsAnExistingMarker(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
marker := filepath.Join(dir, ".gitignore")
|
||||
if err := os.WriteFile(marker, []byte("keep.me\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -22,7 +24,7 @@ func TestMarkUntrackedKeepsAnExistingMarker(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMarkUntrackedIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
for range 2 {
|
||||
if err := MarkUntracked(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -34,7 +36,7 @@ func TestMarkUntrackedIsIdempotent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMarkUntrackedLeavesNothingWhenTheWriteFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
orig := writeMarker
|
||||
writeMarker = func(*os.File) error { return errors.New("disk full") }
|
||||
err := MarkUntracked(dir)
|
||||
@@ -54,7 +56,7 @@ func TestMarkUntrackedLeavesNothingWhenTheWriteFails(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMarkUntrackedDoesNotFollowASymlinkedMarker(t *testing.T) {
|
||||
dir, outside := t.TempDir(), t.TempDir()
|
||||
dir, outside := testenv.TempDir(t), testenv.TempDir(t)
|
||||
target := filepath.Join(outside, "victim")
|
||||
if err := os.Symlink(target, filepath.Join(dir, ".gitignore")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// A launcher (cmd.exe) that spawns a long-lived grandchild (ping) which inherits
|
||||
@@ -99,7 +101,7 @@ func TestStartTrackedResumesSuspendedChild(t *testing.T) {
|
||||
// wedged system tool (a busy WMI, security software holding the call) behaves.
|
||||
func slowTaskkill(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
src := filepath.Join(dir, "main.go")
|
||||
if err := os.WriteFile(src, []byte("package main\nimport \"time\"\nfunc main() { time.Sleep(10 * time.Minute) }\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestCommandNamesFollowsPATHEXTOnlyOnWindows(t *testing.T) {
|
||||
@@ -23,7 +25,7 @@ func TestCommandNamesFollowsPATHEXTOnlyOnWindows(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLookPathInSkipsRelativeEntriesAndFindsTheSibling(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "mcp.cmd"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
const publishedJobMarker = "REASONIX_PUBLISHED_JOB_TASKKILL_MARKER"
|
||||
@@ -51,7 +53,7 @@ func TestPublishedJobRecorderAlias(t *testing.T) {
|
||||
|
||||
func publishedJobStub(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func assertRootWaitClosed(t *testing.T, done <-chan struct{}) {
|
||||
@@ -177,7 +179,7 @@ func TestRootWaitDoesNotRegisterBeforeStartOrForUntrackedCommands(t *testing.T)
|
||||
cancel()
|
||||
}
|
||||
if scenario == "missing_executable" {
|
||||
cmd.Path = t.TempDir() + string(os.PathSeparator) + "missing"
|
||||
cmd.Path = testenv.TempDir(t) + string(os.PathSeparator) + "missing"
|
||||
}
|
||||
tracked, err := RunCommand(ctx, cmd, RunOptions{Track: scenario != "untracked"})
|
||||
if scenario != "untracked" && err == nil {
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
const holderEnv = "REASONIX_SCRATCH_HOLDER"
|
||||
@@ -33,7 +35,7 @@ func TestMain(m *testing.M) {
|
||||
|
||||
func isolatedTemp(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
t.Setenv("TMPDIR", root)
|
||||
t.Setenv("TMP", root)
|
||||
t.Setenv("TEMP", root)
|
||||
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func credHome(t *testing.T, files ...string) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
for _, rel := range files {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
const sentinel = "late-write.txt"
|
||||
@@ -17,7 +19,7 @@ const sentinel = "late-write.txt"
|
||||
// raced, because a producer driven by a sleep would sometimes lose and make
|
||||
// this test the flaky one.
|
||||
func TestContentThatOutlivesTheDeadlineIsReported(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, sentinel), []byte("still running"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -34,7 +36,7 @@ func TestContentThatOutlivesTheDeadlineIsReported(t *testing.T) {
|
||||
// And the behaviour that motivated the wait: a refusal that stops is not a
|
||||
// leak. Reporting the first frame of one is what made the gate flap.
|
||||
func TestARefusalThatStopsLetsTheDirectoryQuiesce(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "settling")
|
||||
dir := filepath.Join(testenv.TempDir(t), "settling")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -58,7 +60,7 @@ func TestARefusalThatStopsLetsTheDirectoryQuiesce(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAnOrdinaryTreeIsRemovedWithoutWaiting(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "root")
|
||||
root := filepath.Join(testenv.TempDir(t), "root")
|
||||
if err := os.MkdirAll(filepath.Join(root, "nested"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -75,7 +77,7 @@ func TestAnOrdinaryTreeIsRemovedWithoutWaiting(t *testing.T) {
|
||||
|
||||
// A directory already gone is not a failure: the test may have removed it.
|
||||
func TestARemovalOfSomethingAlreadyGoneIsNotReported(t *testing.T) {
|
||||
if err := removeDir(filepath.Join(t.TempDir(), "never-existed")); err != nil {
|
||||
if err := removeDir(filepath.Join(testenv.TempDir(t), "never-existed")); err != nil {
|
||||
t.Fatalf("removeDir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestPreviewReadsBytesAndRefusesEscape(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
data := []byte("%PDF-1.7\n\x00binary")
|
||||
if err := os.WriteFile(filepath.Join(root, "报告.pdf"), data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -23,12 +25,12 @@ func TestPreviewReadsBytesAndRefusesEscape(t *testing.T) {
|
||||
if err != nil || string(got) != string(data) {
|
||||
t.Fatalf("read = %q, %v", got, err)
|
||||
}
|
||||
for _, ref := range []Ref{{Path: "../secret"}, {Root: t.TempDir(), Path: "x"}} {
|
||||
for _, ref := range []Ref{{Path: "../secret"}, {Root: testenv.TempDir(t), Path: "x"}} {
|
||||
if _, err := s.Info(context.Background(), ref); !errors.Is(err, ErrDenied) {
|
||||
t.Fatalf("escape = %v", err)
|
||||
}
|
||||
}
|
||||
outside := t.TempDir()
|
||||
outside := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -40,7 +42,7 @@ func TestPreviewReadsBytesAndRefusesEscape(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPreviewLimitsAndUnknownBinary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
s := Service{Primary: root}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
@@ -64,11 +66,11 @@ func TestPreviewLimitsAndUnknownBinary(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGrantedRootAndCancellation(t *testing.T) {
|
||||
extra := t.TempDir()
|
||||
extra := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(extra, "notes.md"), []byte("# Notes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := Service{Primary: t.TempDir(), Extra: []string{extra}}
|
||||
s := Service{Primary: testenv.TempDir(t), Extra: []string{extra}}
|
||||
ref, err := s.Resolve(filepath.Join(extra, "notes.md"))
|
||||
if err != nil || ref.Root != extra || ref.Path != "notes.md" {
|
||||
t.Fatalf("resolve = %+v, %v", ref, err)
|
||||
@@ -89,7 +91,7 @@ func TestGrantedRootAndCancellation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOfficeDocumentsPreviewAsBinaryWithinTheirLimit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
zip := []byte("PK\x03\x04\x00binary office package")
|
||||
s := Service{Primary: root}
|
||||
for _, name := range []string{"报告.docx", "budget.XLSX"} {
|
||||
|
||||
@@ -6,18 +6,20 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func bindingHome(t *testing.T) (*ActivationStore, string) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", home)
|
||||
t.Setenv("BINDING_FLAG", "")
|
||||
_ = os.Unsetenv("BINDING_FLAG")
|
||||
return DefaultActivationStore(), t.TempDir()
|
||||
return DefaultActivationStore(), testenv.TempDir(t)
|
||||
}
|
||||
|
||||
func writeProjectFiles(t *testing.T, root string, files map[string]string) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"errors"
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// An enable on a repository-declared server is the user's answer for the
|
||||
@@ -76,7 +78,7 @@ func TestEnableHoldsOnlyForTheDeclarationItWasGivenFor(t *testing.T) {
|
||||
// upgrade must not make every enabled project server ask again.
|
||||
func TestEnableSurvivesChangesTheRepositoryDoesNotControl(t *testing.T) {
|
||||
store, root := bindingHome(t)
|
||||
userBin := t.TempDir()
|
||||
userBin := testenv.TempDir(t)
|
||||
writeProjectFiles(t, userBin, map[string]string{"rx-fake-node": "v1"})
|
||||
t.Setenv("PATH", userBin)
|
||||
writeProjectFiles(t, root, map[string]string{
|
||||
@@ -136,7 +138,7 @@ func TestEnableRecordedWithoutADeclarationAsksAgain(t *testing.T) {
|
||||
// depend on where the checkout sits.
|
||||
func TestDeclarationDigestDoesNotDependOnTheCheckoutPath(t *testing.T) {
|
||||
_, first := bindingHome(t)
|
||||
second := t.TempDir()
|
||||
second := testenv.TempDir(t)
|
||||
files := map[string]string{
|
||||
".mcp.json": mcpJSON(t, map[string]any{"command": "./bin/server.sh"}),
|
||||
"bin/server.sh": "#!/bin/sh\n",
|
||||
|
||||
@@ -3,13 +3,15 @@ package config
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func trustStore(t *testing.T) (*ActivationStore, string) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
return DefaultActivationStore(), t.TempDir()
|
||||
return DefaultActivationStore(), testenv.TempDir(t)
|
||||
}
|
||||
|
||||
// A .mcp.json is checked in, so whoever wrote the repo chose the command. It
|
||||
@@ -88,7 +90,7 @@ func TestApprovalDoesNotTravelToAnotherProject(t *testing.T) {
|
||||
if err := store.SetServerEnabled(entry, root, ActivationProject, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elsewhere := filepath.Join(t.TempDir(), "other")
|
||||
elsewhere := filepath.Join(testenv.TempDir(t), "other")
|
||||
if enabled, _ := store.IsEnabled(entry, elsewhere); enabled {
|
||||
t.Fatal("an approval in one project started a same-named server in another")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestAttachmentLimitIsHeldAtUserScope(t *testing.T) {
|
||||
@@ -48,7 +50,7 @@ func TestAttachmentLimitUserRenderAndSaveRoundTrip(t *testing.T) {
|
||||
if strings.Contains(RenderTOMLForScope(cfg, RenderScopeProject), "max_stream_bytes") || strings.Contains(RenderTOMLProjectDelta(cfg), "max_stream_bytes") {
|
||||
t.Fatal("user-only limit leaked into project config")
|
||||
}
|
||||
home := t.TempDir()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
path := filepath.Join(home, "config.toml")
|
||||
if err := os.WriteFile(path, []byte("# user's comment\n[future_feature]\nkeep=42\n"), 0o600); err != nil {
|
||||
@@ -69,7 +71,7 @@ func TestAttachmentLimitUserRenderAndSaveRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAttachmentLimitStrictReadRejectsMalformedUserFile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
if got, err := LoadAttachmentStreamLimit(); err != nil || got.Bytes != DefaultAttachmentStreamBytes || got.Source != "default" {
|
||||
t.Fatalf("missing user config: %+v %v", got, err)
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func writeClaudeJSON(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, claudeJSONFile), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -20,7 +22,7 @@ func writeClaudeJSON(t *testing.T, body string) string {
|
||||
// reading only .mcp.json found none of what a machine had configured: three
|
||||
// servers on the reporting user's machine, zero of them in a file Reasonix read.
|
||||
func TestClaudeConfigSuppliesTheServersItsOwnDefaultScopeHolds(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
path := writeClaudeJSON(t, `{
|
||||
"mcpServers": {"shared": {"type": "stdio", "command": "user-node"}},
|
||||
"projects": {
|
||||
@@ -79,13 +81,13 @@ func TestReasonixConfigurationOutranksTheCompatibilityRead(t *testing.T) {
|
||||
// The file belongs to another tool. Reasonix refusing to start over a typo in it
|
||||
// would be a worse answer than starting without it.
|
||||
func TestAnUnreadableClaudeConfigIsNotAnError(t *testing.T) {
|
||||
if got := loadClaudeMCP(filepath.Join(t.TempDir(), "absent.json"), t.TempDir()); got != nil {
|
||||
if got := loadClaudeMCP(filepath.Join(testenv.TempDir(t), "absent.json"), testenv.TempDir(t)); got != nil {
|
||||
t.Fatalf("absent file returned %+v", got)
|
||||
}
|
||||
if got := loadClaudeMCP(writeClaudeJSON(t, `{ not json`), t.TempDir()); got != nil {
|
||||
if got := loadClaudeMCP(writeClaudeJSON(t, `{ not json`), testenv.TempDir(t)); got != nil {
|
||||
t.Fatalf("malformed file returned %+v", got)
|
||||
}
|
||||
if got := loadClaudeMCP("", t.TempDir()); got != nil {
|
||||
if got := loadClaudeMCP("", testenv.TempDir(t)); got != nil {
|
||||
t.Fatalf("empty path returned %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -93,7 +95,7 @@ func TestAnUnreadableClaudeConfigIsNotAnError(t *testing.T) {
|
||||
// Claude Code writes those keys with forward slashes on Windows, where the same
|
||||
// directory is also spelled with backslashes.
|
||||
func TestTheProjectKeyIsComparedAsAPathNotAString(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
path := writeClaudeJSON(t, `{"projects": {"`+filepath.ToSlash(root)+`": {
|
||||
"mcpServers": {"here": {"type": "stdio", "command": "node"}}}}}`)
|
||||
if got := loadClaudeMCP(path, filepath.FromSlash(root)); len(got) != 1 {
|
||||
@@ -107,7 +109,7 @@ func TestTheProjectKeyIsComparedAsAPathNotAString(t *testing.T) {
|
||||
// The effect at the boundary a session actually goes through: a workspace whose
|
||||
// only MCP configuration is Claude's own default scope still comes up with it.
|
||||
func TestLoadingAWorkspaceFindsWhatClaudeConfiguredForIt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
writeClaudeJSON(t, `{"projects": {"`+filepath.ToSlash(root)+`": {
|
||||
"mcpServers": {"unity-editor-mcp": {"type": "stdio", "command": "node", "args": ["server.js"]}}}}}`)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// The [desktop] table is written by hand, one line per field, so a field added
|
||||
@@ -114,7 +116,7 @@ func TestEveryDesktopSettingSurvivesASave(t *testing.T) {
|
||||
}
|
||||
want := set.Interface()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
path := filepath.Join(testenv.TempDir(t), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(RenderTOMLForScope(cfg, RenderScopeUser)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestDisabledToolsSurviveEveryTOMLRenderScope(t *testing.T) {
|
||||
@@ -38,7 +40,7 @@ func TestDisabledToolsSurviveEveryTOMLRenderScope(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDisabledToolsSurviveMCPJSONEdits(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".mcp.json")
|
||||
path := filepath.Join(testenv.TempDir(t), ".mcp.json")
|
||||
entry := PluginEntry{Name: "server", Command: "mcp", DisabledTools: []string{"write_file", "delete_file"}}
|
||||
for _, names := range [][]string{entry.DisabledTools, {}} {
|
||||
entry.DisabledTools = names
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func labelOf(e ProviderEntry, model string) string { return e.ModelShort(model) }
|
||||
@@ -51,7 +53,7 @@ func TestHideModelPrefixRoundTripsThroughTheConfigFile(t *testing.T) {
|
||||
if !strings.Contains(out, "hide_model_prefix = true") {
|
||||
t.Fatalf("not rendered:\n%s", out)
|
||||
}
|
||||
path := t.TempDir() + "/config.toml"
|
||||
path := testenv.TempDir(t) + "/config.toml"
|
||||
if err := os.WriteFile(path, []byte(out), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestTheModCeilingsHaveDefaultsAndAZeroIsAnExplicitChoice(t *testing.T) {
|
||||
@@ -33,7 +35,7 @@ func TestAProjectFileCannotRaiseWhatAModMayAdd(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSavingTheUserFileKeepsACustomModCeiling(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -557,7 +557,7 @@ func TestAcquireConfigEditLockPathRefusesLockDirOwnedBySomeoneElse(t *testing.T)
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("directory ownership is not checked on Windows")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "locks")
|
||||
dir := filepath.Join(testenv.TempDir(t), "locks")
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -594,7 +594,7 @@ func TestAcquireConfigEditLockPathTightensOwnLockDir(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX modes are not enforced on Windows")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "locks")
|
||||
dir := filepath.Join(testenv.TempDir(t), "locks")
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestOTLPExportIsOffUnlessTheUserFileSwitchesItOnAndNamesACollector(t *testing.T) {
|
||||
@@ -43,7 +45,7 @@ func TestAProjectFileCannotTurnOnOrRedirectTraceExport(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSavingTheUserFileKeepsTheTraceExportSettings(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestPeersAndDispatchDefaultOnAndRespectExplicitOptOut(t *testing.T) {
|
||||
@@ -51,7 +53,7 @@ func TestAProjectFileCannotSwitchOffWhatTheUserChose(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSavingTheUserFileKeepsEveryPeersSetting(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -91,7 +93,7 @@ func TestPeersDispatchNeedsPeersAndAProjectFileCannotGrantIt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSavingPeersSettingsWritesThemAndReadsThemBack(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := UserConfigPath()
|
||||
cfg := LoadForEdit(path)
|
||||
cfg.Peers = PeersConfig{Enabled: true, Dispatch: true, ReleaseProjects: []string{"/work/b"}}
|
||||
@@ -113,7 +115,7 @@ func TestSavingPeersSettingsWritesThemAndReadsThemBack(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSavingAnOptOutDoesNotRestorePeerDefaults(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := UserConfigPath()
|
||||
for _, values := range [][2]bool{{false, false}, {true, false}, {false, true}} {
|
||||
cfg := Default()
|
||||
@@ -151,7 +153,7 @@ func TestTheDispatchCeilingsDefaultToTheNamedValuesAndOnlyTheUserFileMovesThem(t
|
||||
}
|
||||
|
||||
func TestCeilingsTheUserSetSurviveAnUnrelatedSave(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func withPackageSource(t *testing.T, src func(home string) []InstalledPackage) {
|
||||
@@ -20,7 +22,7 @@ func TestEnabledPackagesConcurrentCallersShareParses(t *testing.T) {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
return []InstalledPackage{{Name: "ecc"}}
|
||||
})
|
||||
r := RootsForHome(t.TempDir())
|
||||
r := RootsForHome(testenv.TempDir(t))
|
||||
|
||||
const callers = 16
|
||||
var wg sync.WaitGroup
|
||||
@@ -51,7 +53,7 @@ func TestEnabledPackagesCallerNeverReadsStateOlderThanItsArrival(t *testing.T) {
|
||||
<-release
|
||||
return []InstalledPackage{{Name: "gen", Version: string(rune('0' + seen))}}
|
||||
})
|
||||
r := RootsForHome(t.TempDir())
|
||||
r := RootsForHome(testenv.TempDir(t))
|
||||
|
||||
first := make(chan []InstalledPackage, 1)
|
||||
go func() { first <- r.enabledPackages() }()
|
||||
@@ -81,7 +83,7 @@ func TestEnabledPackagesCallersDoNotShareMutableResults(t *testing.T) {
|
||||
MCPServers: map[string]PackageMCPServer{"a": {Args: []string{"x"}}},
|
||||
}}
|
||||
})
|
||||
r := RootsForHome(t.TempDir())
|
||||
r := RootsForHome(testenv.TempDir(t))
|
||||
|
||||
one := r.enabledPackages()
|
||||
one[0].SkillRoots[0] = "changed"
|
||||
|
||||
@@ -4,13 +4,15 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// Serve authentication is user-global: a repository or the agent writing
|
||||
// inside the workspace cannot choose the launch token or the auth mode.
|
||||
func TestProjectConfigCannotSetServeAuthentication(t *testing.T) {
|
||||
isolateUserConfigHome(t)
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
body := "[serve]\nauth_mode = \"none\"\ntoken = \"attacker-known\"\nbehind_proxy = true\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestSharePortRange(t *testing.T) {
|
||||
@@ -64,7 +66,7 @@ func TestSharePortSurvivesASaveAndKeepsTheRestOfServe(t *testing.T) {
|
||||
|
||||
func TestProjectConfigCannotPinTheSharePort(t *testing.T) {
|
||||
isolateUserConfigHome(t)
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[serve]\nshare_port = 41234\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// A directory another test creates and removes while the guard walks the tree
|
||||
// must not fail the walk, and a join in a stable file is still found.
|
||||
func TestStateRootJoinsSurvivesDirectoriesVanishingMidWalk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
src := "package x\n\nfunc f() { _ = filepath.Join(userSupportDir(), \"kept\") }\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "kept.go"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
Loaded 100 of 465 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user