服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
535 lines
16 KiB
Go
535 lines
16 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"pvfine/internal/mcpserver"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"pvfine/internal/pvf"
|
|
)
|
|
|
|
func newMCPFixture(t *testing.T) *MCPService {
|
|
t.Helper()
|
|
c, script, _, _ := newScriptTestService(t)
|
|
s := newMCPService(c, NewArchiveService(c), NewEditorService(c), script, NewExportService(c), filepath.Join(t.TempDir(), "mcp.json"))
|
|
s.workspace()
|
|
t.Cleanup(func() { _ = s.stop() })
|
|
return s
|
|
}
|
|
func mcpCurrent(s *MCPService) mcpArgs {
|
|
s.workspace()
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
return mcpArgs{WorkspaceID: s.c.mcpWorkspaceID, ExpectedRevision: s.c.batchRevision, ExpectedWorkspaceEpoch: s.c.mcpEpoch, Limit: 50}
|
|
}
|
|
func mcpCall(t *testing.T, s *MCPService, name string, a mcpArgs) MCPTask {
|
|
t.Helper()
|
|
if a.RequestID == "" {
|
|
a.RequestID = mcpID()
|
|
}
|
|
raw, _ := json.Marshal(a)
|
|
value, err := s.call(context.Background(), name, raw, a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := value.(map[string]any)["taskId"].(string)
|
|
return waitMCPTask(t, s, id)
|
|
}
|
|
func waitMCPTask(t *testing.T, s *MCPService, id string) MCPTask {
|
|
t.Helper()
|
|
deadline := time.Now().Add(8 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
value, err := s.task(id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task := value.(MCPTask)
|
|
if task.Status != "running" {
|
|
return task
|
|
}
|
|
time.Sleep(time.Millisecond * 5)
|
|
}
|
|
t.Fatal("MCP task timed out")
|
|
return MCPTask{}
|
|
}
|
|
func requireMCPCompleted(t *testing.T, task MCPTask) {
|
|
t.Helper()
|
|
if task.Status != "completed" {
|
|
t.Fatalf("task %s: %s", task.Kind, task.Error)
|
|
}
|
|
}
|
|
func TestMCPScriptPreviewApplyAndWorkspaceConflict(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
a := mcpCurrent(s)
|
|
a.Source = "const f=pvf.find('equipment/first.equ');const d=f.parse();d.set('price',7);f.write(d);"
|
|
task := mcpCall(t, s, "script_run", a)
|
|
requireMCPCompleted(t, task)
|
|
r := task.Result.(ScriptRunResult)
|
|
if r.PlanID == "" {
|
|
t.Fatal("missing plan")
|
|
}
|
|
p, err := s.script.PreviewPage(r.PlanID, "", 0, 50)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = s.script.Run(context.Background(), ScriptRunRequest{Source: "pvf.log('other');"}); err == nil || !strings.Contains(err.Error(), "PLAN_PENDING") {
|
|
t.Fatal("UI run overwrote AI preview")
|
|
}
|
|
apply := mcpCurrent(s)
|
|
apply.PlanID = r.PlanID
|
|
apply.ChangeKeys = []string{p.Rows[0].ChangeKey}
|
|
done := mcpCall(t, s, "script_apply", apply)
|
|
requireMCPCompleted(t, done)
|
|
s.c.mu.Lock()
|
|
index, _ := s.c.archive.Find("equipment/first.equ")
|
|
text, _ := s.c.archive.Text(index)
|
|
s.c.mu.Unlock()
|
|
if !strings.Contains(text, "7") {
|
|
t.Fatal("change missing")
|
|
}
|
|
if s.check(a) == nil {
|
|
t.Fatal("old revision accepted")
|
|
}
|
|
query := mcpCurrent(s)
|
|
query.Source = "pvf.log(pvf.find('equipment/first.equ').path);"
|
|
q := mcpCall(t, s, "script_run", query)
|
|
requireMCPCompleted(t, q)
|
|
if q.Result.(ScriptRunResult).PlanID != "" {
|
|
t.Fatal("query retained empty plan")
|
|
}
|
|
}
|
|
func TestMCPImportFrozenSelectionAndOverwrite(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
root := t.TempDir()
|
|
file := filepath.Join(root, "first.equ")
|
|
if err := os.WriteFile(file, []byte("[price]\n99"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := mcpCurrent(s)
|
|
a.SourcePaths = []string{file}
|
|
a.TargetDir = "equipment"
|
|
a.Mode = "text"
|
|
prep := mcpCall(t, s, "import_prepare", a)
|
|
requireMCPCompleted(t, prep)
|
|
id := prep.Result.(map[string]any)["importPlanId"].(string)
|
|
if err := os.WriteFile(file, []byte("[price]\n1000"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a = mcpCurrent(s)
|
|
a.ImportPlanID = id
|
|
a.SelectedKeys = []string{"equipment/first.equ"}
|
|
rejected := mcpCall(t, s, "import_apply", a)
|
|
if !strings.Contains(rejected.Error, "OVERWRITE_REQUIRED") {
|
|
t.Fatal("implicit overwrite accepted")
|
|
}
|
|
a.OverwriteKeys = a.SelectedKeys
|
|
done := mcpCall(t, s, "import_apply", a)
|
|
requireMCPCompleted(t, done)
|
|
s.c.mu.Lock()
|
|
i, _ := s.c.archive.Find("equipment/first.equ")
|
|
text, _ := s.c.archive.Text(i)
|
|
s.c.mu.Unlock()
|
|
if strings.Contains(text, "1000") || !strings.Contains(text, "99") {
|
|
t.Fatal("import did not use frozen bytes")
|
|
}
|
|
}
|
|
func TestMCPExportDirectAndMod(t *testing.T) {
|
|
for _, mode := range []string{"direct", "mod"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
a := mcpCurrent(s)
|
|
a.Mode = mode
|
|
a.Source = "selection"
|
|
a.Scopes = []string{"equipment"}
|
|
a.Format = "110USextend"
|
|
a.Name = "AI-test"
|
|
a.Version = "1.0"
|
|
prep := mcpCall(t, s, "export_prepare", a)
|
|
requireMCPCompleted(t, prep)
|
|
id := prep.Result.(map[string]any)["exportPlanId"].(string)
|
|
if _, err := s.export.Prepare(ExportRequest{Mode: "direct", Source: "selection", Scopes: []string{"equipment"}}); err == nil {
|
|
t.Fatal("UI replaced AI export")
|
|
}
|
|
page, err := s.exportPreview(mcpArgs{ExportPlanID: id, Limit: 100})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
exec := mcpArgs{ExportPlanID: id, OutputParent: t.TempDir(), OutputName: "files"}
|
|
for _, row := range page.(map[string]any)["rows"].([]map[string]any) {
|
|
if !row["required"].(bool) {
|
|
exec.SelectedEntryKeys = append(exec.SelectedEntryKeys, row["entryKey"].(string))
|
|
}
|
|
}
|
|
done := mcpCall(t, s, "export_execute", exec)
|
|
requireMCPCompleted(t, done)
|
|
out := done.Result.(map[string]any)["outputPath"].(string)
|
|
target := filepath.Join(out, "equipment", "first.equ")
|
|
if mode == "mod" {
|
|
target = filepath.Join(out, "pack.json")
|
|
}
|
|
if _, err := os.Stat(target); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = s.exportPreview(mcpArgs{ExportPlanID: id, Limit: 100}); err == nil {
|
|
t.Fatal("export plan not consumed")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestMCPSaveCopyPreservesSourceAndDirty(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
dir := t.TempDir()
|
|
source := filepath.Join(dir, "source.pvf")
|
|
s.c.mu.Lock()
|
|
if err := s.c.archive.SaveAs(source); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.c.mcpSourceHash = s.c.archive.SourceHash()
|
|
s.c.mu.Unlock()
|
|
if err := s.editor.SetText(0, "[price]\n42"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := mcpCurrent(s)
|
|
a.Path = filepath.Join(dir, "copy.pvf")
|
|
copyTask := mcpCall(t, s, "archive_save_as", a)
|
|
requireMCPCompleted(t, copyTask)
|
|
s.c.mu.RLock()
|
|
same := s.c.archive.SourcePath() == source
|
|
dirty := s.c.archive.Modified()
|
|
s.c.mu.RUnlock()
|
|
if !same || !dirty {
|
|
t.Fatal("copy changed source baseline")
|
|
}
|
|
copied, err := pvf.Open(a.Path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer copied.Release()
|
|
text, _ := copied.Text(0)
|
|
if !strings.Contains(text, "42") {
|
|
t.Fatal("copy missing edit")
|
|
}
|
|
a = mcpCurrent(s)
|
|
done := mcpCall(t, s, "archive_save", a)
|
|
requireMCPCompleted(t, done)
|
|
s.c.mu.RLock()
|
|
dirty = s.c.archive.Modified()
|
|
s.c.mu.RUnlock()
|
|
if dirty {
|
|
t.Fatal("save left archive dirty")
|
|
}
|
|
if err := os.WriteFile(source, []byte("external"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conflict := mcpCall(t, s, "archive_save", mcpCurrent(s))
|
|
if !strings.Contains(conflict.Error, "SOURCE_CHANGED") {
|
|
t.Fatal("external change not detected")
|
|
}
|
|
}
|
|
func TestMCPOpenFailureCloseAndDeduplication(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
a := mcpCurrent(s)
|
|
a.Path = filepath.Join(t.TempDir(), "bad.pvf")
|
|
a.DirtyPolicy = "discard"
|
|
before := a.WorkspaceID
|
|
failed := mcpCall(t, s, "archive_open", a)
|
|
if failed.Status != "failed" || mcpCurrent(s).WorkspaceID != before {
|
|
t.Fatal("open failure replaced workspace")
|
|
}
|
|
a = mcpCurrent(s)
|
|
a.DirtyPolicy = "discard"
|
|
a.RequestID = "close-once"
|
|
raw, _ := json.Marshal(a)
|
|
value, err := s.call(context.Background(), "archive_close", raw, a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := value.(map[string]any)["taskId"].(string)
|
|
requireMCPCompleted(t, waitMCPTask(t, s, id))
|
|
retry, err := s.call(context.Background(), "archive_close", raw, a)
|
|
if err != nil || retry.(map[string]any)["taskId"] != id {
|
|
t.Fatal("close retry not deduplicated")
|
|
}
|
|
a.DirtyPolicy = "save"
|
|
raw, _ = json.Marshal(a)
|
|
if _, err = s.call(context.Background(), "archive_close", raw, a); err == nil {
|
|
t.Fatal("request ID conflict accepted")
|
|
}
|
|
if s.workspace()["open"] != false {
|
|
t.Fatal("close failed")
|
|
}
|
|
}
|
|
func TestMCPUIPreparationProtectsDraftAndBusy(t *testing.T) {
|
|
for _, draft := range []bool{false, true} {
|
|
t.Run(fmt.Sprint(draft), func(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
s.RegisterWindow("main")
|
|
a := mcpCurrent(s)
|
|
a.Source = "pvf.log('query');"
|
|
a.RequestID = mcpID()
|
|
raw, _ := json.Marshal(a)
|
|
value, err := s.call(context.Background(), "script_run", raw, a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deadline := time.Now().Add(time.Second)
|
|
for s.WindowState()["operationId"] == "" && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
s.Acknowledge("main", s.WindowState()["operationId"], draft, false)
|
|
task := waitMCPTask(t, s, value.(map[string]any)["taskId"].(string))
|
|
if draft {
|
|
if !strings.Contains(task.Error, "UI_DRAFT_PENDING") {
|
|
t.Fatal("draft not protected")
|
|
}
|
|
} else {
|
|
requireMCPCompleted(t, task)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMCPCancelBusyAndDiscardFileSetMutation(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
a := mcpCurrent(s)
|
|
a.Source = "while(true){}"
|
|
a.RequestID = mcpID()
|
|
raw, _ := json.Marshal(a)
|
|
value, err := s.call(context.Background(), "script_run", raw, a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := value.(map[string]any)["taskId"].(string)
|
|
deadline := time.Now().Add(time.Second)
|
|
for !s.script.IsRunning() && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
other := a
|
|
other.RequestID = mcpID()
|
|
raw2, _ := json.Marshal(other)
|
|
if _, err = s.call(context.Background(), "script_run", raw2, other); err == nil {
|
|
t.Fatal("concurrent task accepted")
|
|
}
|
|
if !s.CancelActiveScript() {
|
|
t.Fatal("UI cancel failed")
|
|
}
|
|
done := waitMCPTask(t, s, id)
|
|
requireMCPCompleted(t, done)
|
|
if done.Result.(ScriptRunResult).Status != "cancelled" {
|
|
t.Fatal("script not cancelled")
|
|
}
|
|
a = mcpCurrent(s)
|
|
a.Source = "pvf.createFileset('ai-set', ['equipment/first.equ']);"
|
|
failed := mcpCall(t, s, "script_run", a)
|
|
if !strings.Contains(failed.Error, "UNSUPPORTED_CHANGE") {
|
|
t.Fatal("file set mutation exposed")
|
|
}
|
|
s.c.mu.RLock()
|
|
pending := s.c.scriptPlan != nil
|
|
s.c.mu.RUnlock()
|
|
if pending {
|
|
t.Fatal("unsupported plan retained")
|
|
}
|
|
}
|
|
func TestMCPImportProtectedSourceAndPlanConflict(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
a := mcpCurrent(s)
|
|
a.SourcePaths = []string{filepath.Join(t.TempDir(), ".env")}
|
|
a.Mode = "text"
|
|
failed := mcpCall(t, s, "import_prepare", a)
|
|
if failed.Status != "failed" {
|
|
t.Fatal("protected source accepted")
|
|
}
|
|
}
|
|
func TestMCPOpenValidArchiveAndDirtyPolicy(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
path := filepath.Join(t.TempDir(), "next.pvf")
|
|
next := pvf.New()
|
|
if _, err := next.AddFileText("next.equ", "[price]\n123", pvf.TypeScript); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := next.SaveAs(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := mcpCurrent(s)
|
|
old := a.WorkspaceID
|
|
a.Path = path
|
|
failed := mcpCall(t, s, "archive_open", a)
|
|
if !strings.Contains(failed.Error, "UNSAVED_CHANGES") {
|
|
t.Fatal("dirty switch accepted")
|
|
}
|
|
a.DirtyPolicy = "discard"
|
|
task := mcpCall(t, s, "archive_open", a)
|
|
requireMCPCompleted(t, task)
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
s.c.mu.RLock()
|
|
loading := s.c.versionLoading
|
|
s.c.mu.RUnlock()
|
|
if !loading {
|
|
break
|
|
}
|
|
time.Sleep(time.Millisecond * 10)
|
|
}
|
|
current := mcpCurrent(s)
|
|
if current.WorkspaceID == old {
|
|
t.Fatal("workspace identity reused")
|
|
}
|
|
current.Path = "next.equ"
|
|
result, err := s.readFile(current)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(result.(map[string]any)["text"].(string), "123") {
|
|
t.Fatal("opened incorrect archive")
|
|
}
|
|
}
|
|
|
|
func TestMCPActualToolsOverHTTP(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
server := httptest.NewServer(mcpserver.Handler(s.tools()))
|
|
defer server.Close()
|
|
s.server = server.Config
|
|
configuration, err := s.ConnectionConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var connection map[string]map[string]map[string]any
|
|
if json.Unmarshal([]byte(configuration), &connection) != nil || len(connection["mcpServers"]["pvfine"]) != 1 || connection["mcpServers"]["pvfine"]["url"] == nil {
|
|
t.Fatal("connection config must contain only URL")
|
|
}
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "integration-test", Version: "1"}, nil)
|
|
session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: server.URL + "/mcp"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer session.Close()
|
|
listed, err := session.ListTools(context.Background(), nil)
|
|
if err != nil || len(listed.Tools) < 28 {
|
|
t.Fatal("MCP discovery incomplete")
|
|
}
|
|
call := func(name string, args map[string]any) map[string]any {
|
|
t.Helper()
|
|
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.IsError {
|
|
t.Fatalf("tool %s failed", name)
|
|
}
|
|
var value map[string]any
|
|
if err = json.Unmarshal([]byte(result.Content[0].(*mcp.TextContent).Text), &value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return value
|
|
}
|
|
workspace := call("pvf_workspace_info", map[string]any{})
|
|
docs := call("pvf_annotation_search", map[string]any{"pvfVersion": "all", "limit": 1})
|
|
if docs["schemaVersion"] != float64(1) {
|
|
t.Fatal("annotation HTTP schema")
|
|
}
|
|
if len(docs["items"].([]any)) > 0 {
|
|
item := docs["items"].([]any)[0].(map[string]any)
|
|
call("pvf_annotation_get", map[string]any{"documentId": item["documentId"], "rulesRevision": docs["rulesRevision"], "valuesLimit": 1})
|
|
}
|
|
call("pvf_annotation_explain", map[string]any{"workspaceId": workspace["workspaceId"], "expectedRevision": workspace["revision"], "filePath": "equipment/first.equ", "limit": 1})
|
|
result := call("pvf_script_run", map[string]any{"workspaceId": workspace["workspaceId"], "expectedRevision": workspace["revision"], "source": "pvf.log('http query');", "requestId": "http-run-once"})
|
|
id := result["taskId"].(string)
|
|
task := waitMCPTask(t, s, id)
|
|
requireMCPCompleted(t, task)
|
|
result = call("pvf_task_status", map[string]any{"taskId": id})
|
|
if result["status"] != "completed" {
|
|
t.Fatal("MCP task result unavailable")
|
|
}
|
|
invalid, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "pvf_read_file", Arguments: map[string]any{"path": "equipment/first.equ"}})
|
|
if err != nil || !invalid.IsError {
|
|
t.Fatal("missing workspace revision accepted")
|
|
}
|
|
}
|
|
|
|
func TestMCPExportBinaryAndDestinationConflict(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
s.c.mu.Lock()
|
|
s.c.archive.AddFile("image/data.bin", []byte{1, 2, 3}, 2)
|
|
if err := s.c.rebuildArchiveIndexesLocked(s.c.archive); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.c.mu.Unlock()
|
|
a := mcpCurrent(s)
|
|
a.Mode = "direct"
|
|
a.Source = "selection"
|
|
a.Scopes = []string{"image"}
|
|
task := mcpCall(t, s, "export_prepare", a)
|
|
requireMCPCompleted(t, task)
|
|
id := task.Result.(map[string]any)["exportPlanId"].(string)
|
|
parent := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(parent, "files"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a = mcpArgs{ExportPlanID: id, OutputParent: parent, OutputName: "files", SelectedEntryKeys: []string{"image/data.bin"}}
|
|
conflict := mcpCall(t, s, "export_execute", a)
|
|
if !strings.Contains(conflict.Error, "TARGET_EXISTS") {
|
|
t.Fatal("existing output replaced")
|
|
}
|
|
a.OutputName = "new-files"
|
|
done := mcpCall(t, s, "export_execute", a)
|
|
requireMCPCompleted(t, done)
|
|
data, err := os.ReadFile(filepath.Join(parent, "new-files", "image", "data.bin"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data) != string([]byte{1, 2, 3}) {
|
|
t.Fatal("binary bytes lost")
|
|
}
|
|
}
|
|
|
|
func TestMCPAtomicDirectoryPublishNeverReplacesExistingEmptyDirectory(t *testing.T) {
|
|
parent := t.TempDir()
|
|
temp, err := os.MkdirTemp(parent, "staged-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = os.WriteFile(filepath.Join(temp, "data.txt"), []byte("new"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dest := filepath.Join(parent, "existing")
|
|
if err = os.Mkdir(dest, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = publishModDirectory(temp, dest); err == nil {
|
|
t.Fatal("existing empty directory was replaced")
|
|
}
|
|
entries, err := os.ReadDir(dest)
|
|
if err != nil || len(entries) != 0 {
|
|
t.Fatal("destination was changed")
|
|
}
|
|
}
|
|
|
|
func TestMCPScriptLogsAreBoundedAndReportTruncation(t *testing.T) {
|
|
s := newMCPFixture(t)
|
|
a := mcpCurrent(s)
|
|
a.Source = "for(let i=0;i<100;i++){pvf.log('x'.repeat(20000));}"
|
|
task := mcpCall(t, s, "script_run", a)
|
|
requireMCPCompleted(t, task)
|
|
result := task.Result.(ScriptRunResult)
|
|
size := 0
|
|
warned := false
|
|
for _, line := range result.Logs {
|
|
size += len(line.Message)
|
|
if strings.Contains(line.Message, "省略") {
|
|
warned = true
|
|
}
|
|
}
|
|
if size > 300<<10 || !warned {
|
|
t.Fatal("log limit or truncation notice missing")
|
|
}
|
|
}
|