Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] edf5662681 test(server): measure child startup during npm journey 2026-11-04 00:08:59 +00:00
paseo-bot[bot] eca630034d test(server): trace npm installation journey phases 2026-11-04 00:06:42 +00:00
paseo-bot[bot] 4cd23a8f17 test(server): wait for worktree observation without racing filesystem setup (#8700)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 21:30:59 +00:00
3084f32852 Show effort controls for Claude settings models (#8699)
* fix(claude): show thinking options for models from settings.json

Models appended from Claude's settings.json were pushed to the catalog
without thinkingOptions, so the composer had no effort selector for them.
Resolve them like profile and additionalModels entries: manifest options for
built-in IDs, the standard Claude effort levels otherwise.

* docs(claude): clarify effort selection for settings models

Describe explicit effort selection without promising how every client initializes an unsaved value.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: wangyahao <wangyahao@fxiaoke.com>

---------

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: wangyahao <wangyahao@fxiaoke.com>
2026-11-03 20:59:32 +00:00
paseo-bot[bot] ce8196f6c5 fix(opencode): keep reopened running conversations interruptible (#8698)
An execution adopted during session initialization emitted turn_started before the manager could subscribe. Deliver the current turn to a new subscriber so the manager owns the run and routes Stop to OpenCode.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 19:54:49 +00:00
paseo-bot[bot]andSeth Jeon 40b95f70f4 Fix Android chat mouse-wheel direction (#8693)
* fix(android): correct inverted chat wheel direction

* test(android): run wheel regressions with current React Native

Provide the native-only test context with React Native’s event dispatcher for an absent JavaScript instance, retaining real scroll handling.

Co-authored-by: Seth Jeon <paseo@sethjeon.com>

---------

Co-authored-by: Seth Jeon <paseo@sethjeon.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 19:25:40 +00:00
14 changed files with 309 additions and 127 deletions
+5 -1
View File
@@ -174,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## Inverted timeline selection
## Inverted timeline input
Android focus and selection visibility requests must not reposition inverted timelines. The
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
@@ -183,6 +183,10 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
commands still work; non-inverted scroll views keep Android's default behavior.
Mouse wheel axes also bypass the inversion transform. The same module reverses the vertical
wheel axis for inverted scroll views before delegating to Android, preserving native scroll
bounds and React Native's scroll events and disabled-scroll behavior.
Register this package before React Native's core package through its Expo config plugin. Normal
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
+2
View File
@@ -777,6 +777,8 @@ The built-in `claude` provider appends concrete model IDs from `~/.claude/settin
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
Paseo's effort selector does not inherit `effortLevel` from Claude's settings.json. Select an effort in Paseo to choose a specific value.
### Gotcha: `extends: "claude"` with third-party endpoints
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
@@ -4,11 +4,13 @@ import android.graphics.Rect
import android.text.Selection
import android.text.Spannable
import android.view.View
import android.view.InputDevice
import android.view.MotionEvent
import android.os.SystemClock
import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.BridgeReactContext
import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
@@ -24,6 +26,40 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ScrollSelectionTest {
@Test fun mouseWheelMovesInvertedViewportInVisualDirection() = withScroll(inverted = true) { scroll, _ ->
val before = scroll.scrollY
withWheel(1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue("Wheel up must move toward older messages: $before -> ${scroll.scrollY}", scroll.scrollY > before)
assertEquals("The caller's event must not change", 1f, event.getAxisValue(MotionEvent.AXIS_VSCROLL))
}
val afterUp = scroll.scrollY
withWheel(-1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue("Wheel down must move toward newer messages: $afterUp -> ${scroll.scrollY}", scroll.scrollY < afterUp)
}
}
@Test fun mouseWheelKeepsOrdinaryScrollDirection() = withScroll(inverted = false) { scroll, _ ->
val before = scroll.scrollY
withWheel(1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue(scroll.scrollY < before)
}
val afterUp = scroll.scrollY
withWheel(-1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue(scroll.scrollY > afterUp)
}
}
@Test fun mouseWheelRespectsDisabledScrolling() = withScroll(inverted = true) { scroll, _ ->
scroll.setScrollEnabled(false)
val before = scroll.scrollY
withWheel(1f) { scroll.dispatchGenericMotionEvent(it) }
assertEquals(before, scroll.scrollY)
}
@Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
val before = scroll.scrollY
assertTrue(text.requestFocus())
@@ -70,7 +106,7 @@ class ScrollSelectionTest {
val appContext = instrumentation.targetContext
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = BridgeReactContext(appContext)
val reactContext = ScrollTestContext(appContext)
val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
@@ -98,4 +134,26 @@ class ScrollSelectionTest {
}
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
private fun withWheel(axis: Float, check: (MotionEvent) -> Unit) {
val time = SystemClock.uptimeMillis()
val properties = MotionEvent.PointerProperties().apply {
id = 0
toolType = MotionEvent.TOOL_TYPE_MOUSE
}
val coordinates = MotionEvent.PointerCoords().apply {
x = 150f
y = 150f
setAxisValue(MotionEvent.AXIS_VSCROLL, axis)
}
val event = MotionEvent.obtain(
time, time, MotionEvent.ACTION_SCROLL, 1, arrayOf(properties), arrayOf(coordinates),
0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0,
)
try {
check(event)
} finally {
event.recycle()
}
}
}
@@ -0,0 +1,19 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
ScrollTestContext(Context context) {
super(context);
}
@Override
public EventDispatcher getEventDispatcher() {
return BlackHoleEventDispatcher.INSTANCE;
}
}
@@ -2,6 +2,8 @@ package sh.paseo.scroll
import android.graphics.Matrix
import android.graphics.Rect
import android.view.InputDevice
import android.view.MotionEvent
import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage
import com.facebook.react.bridge.NativeModule
@@ -30,13 +32,47 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
private val transformValues = FloatArray(9)
private fun isVerticallyInverted(): Boolean {
// Android FlatList uses scale: -1, which RN decomposes into a 180-degree
// rotation with positive scaleY. Read the resulting transform, not scaleY.
matrix.getValues(transformValues)
return transformValues[Matrix.MSCALE_Y] < 0
}
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
if (!isVerticallyInverted() || event.actionMasked != MotionEvent.ACTION_SCROLL ||
!event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
return super.onGenericMotionEvent(event)
}
if (!scrollEnabled) return false
// Android applies wheel axes directly to scrollY; the inversion transform only
// transforms pointer coordinates. Reverse the vertical axis before delegating so
// platform scroll bounds and scroll events still apply.
val properties = Array(event.pointerCount) { index ->
MotionEvent.PointerProperties().also { event.getPointerProperties(index, it) }
}
val coordinates = Array(event.pointerCount) { index ->
MotionEvent.PointerCoords().also {
event.getPointerCoords(index, it)
it.setAxisValue(MotionEvent.AXIS_VSCROLL, -it.getAxisValue(MotionEvent.AXIS_VSCROLL))
}
}
val correctedEvent = MotionEvent.obtain(
event.downTime, event.eventTime, event.action, event.pointerCount, properties, coordinates,
event.metaState, event.buttonState, event.xPrecision, event.yPrecision,
event.deviceId, event.edgeFlags, event.source, event.flags,
)
return try {
super.onGenericMotionEvent(correctedEvent)
} finally {
correctedEvent.recycle()
}
}
// Both RN focus handling and Android selection visibility requests use this calculation.
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
// movement to user gestures and explicit scroll commands.
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
matrix.getValues(transformValues)
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
}
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
if (isVerticallyInverted()) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
}
@@ -58,6 +58,9 @@ import type {
import type { PaseoToolCatalog } from "./tools/types.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { OpenCodeV2AgentClient } from "./providers/opencode/v2/agent.js";
import { V2Harness } from "./providers/opencode/test-utils/v2-harness.js";
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client");
@@ -670,6 +673,33 @@ async function startAndSteerThroughManager(
return { manager, agentId: agent.id, workdir };
}
test("stops an OpenCode execution already active when its conversation is reopened", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-opencode-resume-"));
onTestFinished(() => rmSync(workdir, { recursive: true, force: true }));
const harness = new V2Harness();
harness.info.location.directory = workdir;
harness.startExecution();
const client = new OpenCodeV2AgentClient({ logger, runtime: harness.runtime });
const manager = new AgentManager({ clients: { opencode: client }, logger });
const agent = await manager.resumeAgentFromPersistence({
provider: "opencode",
sessionId: "session",
nativeHandle: "session",
metadata: { cwd: workdir },
});
try {
expect(await manager.cancelAgentRun(agent.id)).toEqual({ status: "settled" });
expect(harness.active).toBe(false);
for await (const _event of manager.streamAgent(agent.id, "continue after restart")) {
// Drain the next turn through the same interface used by callers.
}
expect(harness.prompts).toEqual(["continue after restart"]);
} finally {
await manager.closeAgent(agent.id);
await client.shutdown();
}
});
test("uses an injected timeline store without making it a production requirement", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-store-"));
const store = new RecordingTimelineStore();
@@ -365,7 +365,21 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
);
});
it("appends concrete models from Claude settings.json", async () => {
it("appends concrete models from Claude settings.json with thinking options", async () => {
const customModelThinkingOptions = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
{ id: "max", label: "Max" },
];
const fableThinkingOptions = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
{ id: "xhigh", label: "Extra High" },
{ id: "max", label: "Max" },
{ id: CLAUDE_ULTRACODE_THINKING_OPTION_ID, label: "Ultra Code" },
];
const configDir = await createClaudeConfigDir({
model: "us.anthropic.claude-opus-4-7[1m]",
env: {
@@ -393,42 +407,49 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
id: "us.anthropic.claude-opus-4-7[1m]",
label: "us.anthropic.claude-opus-4-7[1m]",
description: "From Claude settings.json model",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "openrouter/anthropic/claude-sonnet-4.5",
label: "openrouter/anthropic/claude-sonnet-4.5",
description: "From Claude settings.json env.ANTHROPIC_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "ollama/qwen3-coder",
label: "ollama/qwen3-coder",
description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "claude-fable-5-1[1M]",
label: "claude-fable-5-1[1M]",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_FABLE_MODEL",
thinkingOptions: fableThinkingOptions,
},
{
provider: "claude",
id: "bedrock-opus-from-env",
label: "bedrock-opus-from-env",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_OPUS_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "glm-5.1",
label: "glm-5.1",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_SONNET_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "glm-5",
label: "glm-5",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL",
thinkingOptions: customModelThinkingOptions,
},
]);
expect(models.filter((model) => model.id === "claude-fable-5-1[1M]")).toHaveLength(1);
@@ -83,7 +83,7 @@ export async function getClaudeModelsWithSettings(
const manifestModel = getClaudeModels(claudeCodeVersion).find(
(candidate) => candidate.id === model.id,
);
models.push({ ...manifestModel, ...model });
models.push(resolveConfiguredClaudeModel({ ...manifestModel, ...model }));
}
return models;
@@ -242,6 +242,10 @@ export class OpenCodeV2Session implements AgentSession {
}
subscribe(callback: (event: AgentStreamEvent) => void) {
this.listeners.add(callback);
// Reopening can adopt an execution during initialization, before its caller
// can subscribe. Deliver that live turn so it remains visible and interruptible.
if (this.turns.id)
callback({ type: "turn_started", provider: "opencode", turnId: this.turns.id });
return () => {
this.listeners.delete(callback);
};
@@ -208,52 +208,6 @@ async function createSession(
return { pi, session, events };
}
test("close preserves a foreground tool's terminal update before releasing Pi", async () => {
const { pi, session, events } = await createSession();
const native = pi.latestSession();
const stopped = Promise.withResolvers<void>();
native.abortCompletion = stopped.promise;
await session.startTurn("Run a foreground command");
native.emit({
type: "tool_execution_start",
toolCallId: "foreground-bash",
toolName: "bash",
args: { command: "sleep 120" },
});
const closing = session.close();
await flushTurnScheduling();
// Pi's abort acknowledgement follows its final tool result and durable transcript write.
native.emit({
type: "tool_execution_end",
toolCallId: "foreground-bash",
toolName: "bash",
result: { content: [{ type: "text", text: "Command aborted" }] },
isError: true,
});
stopped.resolve();
await closing;
expect(
events
.timelineItems()
.findLast((item) => item.type === "tool_call" && item.callId === "foreground-bash"),
).toMatchObject({
callId: "foreground-bash",
status: "failed",
});
});
test("close releases Pi when stopping its active work fails", async () => {
const { pi, session } = await createSession();
const native = pi.latestSession();
await session.startTurn("Run a foreground command");
native.clearQueueError = new Error("Pi control plane is unavailable");
await expect(session.close()).resolves.toBeUndefined();
expect(native.closeCalls).toBe(1);
});
test("forwards launch-context env to the Pi process launch", async () => {
const pi = new FakePi();
const client = createClient(pi);
@@ -1540,7 +1540,16 @@ export class PiRpcAgentSession implements AgentSession {
this.activeTurnId || this.activeTurnStarted ? { turnId, error: null } : null;
this.interruptingTurn = interruption;
try {
await this.abortRuntimeWork();
try {
await this.runtimeSession.clearQueue();
} catch (error) {
// COMPAT(piClearQueueFallback): added in v0.5.0, remove after 2027-03-01 once
// the pi floor supports clear_queue (added in pi 0.84.4).
if (!isPiMissingClearQueueRpc(error)) {
throw error;
}
}
await this.runtimeSession.abort();
} catch (error) {
const terminalError = this.interruptingTurn === interruption ? interruption?.error : null;
if (this.interruptingTurn === interruption) {
@@ -1592,19 +1601,6 @@ export class PiRpcAgentSession implements AgentSession {
}
}
private async abortRuntimeWork(): Promise<void> {
try {
await this.runtimeSession.clearQueue();
} catch (error) {
// COMPAT(piClearQueueFallback): added in v0.5.0, remove after 2027-03-01 once
// the pi floor supports clear_queue (added in pi 0.84.4).
if (!isPiMissingClearQueueRpc(error)) {
throw error;
}
}
await this.runtimeSession.abort();
}
async revertConversation(input: { messageId: string }): Promise<void> {
if (this.activeTurnId) {
throw new Error("Cannot rewind the Pi conversation while a turn is active");
@@ -1659,13 +1655,6 @@ export class PiRpcAgentSession implements AgentSession {
this.extensionHost.close();
this.usagePoller.close();
try {
if (this.activeTurnId || this.activeTurnStarted) {
// Pi persists terminal tool results before acknowledging abort. Keep its
// event stream alive until then so restart history cannot strand the tool.
await this.abortRuntimeWork().catch((err) => {
this.logger.warn({ err }, "Failed to abort Pi work before closing its runtime");
});
}
await this.runtimeSession.close();
} finally {
await Promise.all([this.pendingOutput, ...this.pendingExtensionHydrations]);
@@ -153,7 +153,6 @@ export class FakePiSession implements PiRuntimeSession {
// Native user entries paired with the complete active-branch history.
historyUserEntries: FakePiUserEntry[] = [];
abortRequested = false;
abortCompletion: Promise<void> | null = null;
closeCalls = 0;
readonly canceledExtensionUiRequests: string[] = [];
readonly extensionUiResponses: Array<{
@@ -296,7 +295,6 @@ export class FakePiSession implements PiRuntimeSession {
async abort(): Promise<void> {
this.controlRequests.push("abort");
this.abortRequested = true;
await this.abortCompletion;
}
async getState(): Promise<PiSessionState> {
@@ -429,7 +427,6 @@ export class FakePiSession implements PiRuntimeSession {
async close(): Promise<void> {
this.closeCalls += 1;
this.subscribers.clear();
}
emit(event: PiRuntimeEvent): void {
@@ -1,4 +1,7 @@
import type { ChildProcess } from "node:child_process";
import { channel } from "node:diagnostics_channel";
import { randomUUID } from "node:crypto";
import { writeSync } from "node:fs";
import {
cp,
rename,
@@ -1612,7 +1615,44 @@ export default function contribute() {
]);
}, 120_000);
it("preserves dependencies and metadata, and reloads without npm", async () => {
it("preserves dependencies and metadata, and reloads without npm", async ({ onTestFinished }) => {
const processes = channel("child_process");
const observe = (event: unknown) => {
const child = (event as { process: ChildProcess }).process;
const began = performance.now();
child.on("message", (message: unknown) => {
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "ready"
) {
writeSync(
2,
`npm journey child: plugin ready; ${Math.round(performance.now() - began)}ms\n`,
);
}
});
child.once("close", () => {
const command = child.spawnargs.join(" ");
let kind = "other child";
if (command.includes("--version")) kind = "version probe";
else if (/\bnpm(?:\.cmd|\.exe)?(?:[" ]|$)/i.test(command)) kind = "npm command";
writeSync(2, `npm journey child: ${kind}; ${Math.round(performance.now() - began)}ms\n`);
});
};
processes.subscribe(observe);
onTestFinished(() => processes.unsubscribe(observe));
const started = performance.now();
let previous = started;
const mark = (phase: string) => {
const now = performance.now();
writeSync(
2,
`npm journey: ${phase}; phase=${Math.round(now - previous)}ms total=${Math.round(now - started)}ms\n`,
);
previous = now;
};
const home = await mkdtemp(path.join(tmpdir(), "paseo-npm-service-"));
roots.push(home);
const previousConfig = process.env.npm_config_userconfig;
@@ -1628,6 +1668,7 @@ export default function contribute() {
let service = open();
try {
await service.start();
mark("start complete");
const installed = await service.installSource({ source: "npm:paseo-fixture-plugin@^1.0.0" });
expect(installed).toMatchObject({
id: "npm-review",
@@ -1656,6 +1697,7 @@ export default function contribute() {
"node_modules/paseo-fixture-dependency"
].version,
).toBe("1.0.0");
mark("initial install complete");
for (const [source, id, version] of [
["npm:paseo-fixture-plugin@1.0.0", "exact", "1.0.0"],
["paseo-fixture-plugin@stable", "tag", "1.0.0"],
@@ -1667,6 +1709,7 @@ export default function contribute() {
installation: { currentRevision: version },
});
}
mark("selector installs complete");
const [preview] = await service.previewUpdates({ pluginId: "exact" });
expect(preview).toMatchObject({
outcome: "update",
@@ -1721,6 +1764,7 @@ export default function contribute() {
]);
expect((await service.previewUpdates({ pluginId: "exact" }))[0]?.outcome).toBe("update");
await expect(service.updateSources("exact")).rejects.toThrow("Update the client");
mark("update journey complete");
const prepared = await service.installSource({
source: "npm:paseo-prepared-plugin@1.0.0:nested",
});
@@ -1739,6 +1783,7 @@ export default function contribute() {
"already configured",
);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
mark("prepared and rejected inputs complete");
for (const name of [
"bad-manifest",
"bad-build",
@@ -1755,6 +1800,7 @@ export default function contribute() {
]);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
}
mark("failed packages complete");
await service.disablePlugin("scoped");
await service.stopAllPlugins();
const recordsPath = path.join(home, "plugins", "sources.json");
@@ -1788,6 +1834,7 @@ export default function contribute() {
service.installSource({ source: "npm:paseo-fixture-plugin", id: "missing-npm" }),
).rejects.toThrow("npm is required on the daemon host");
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
mark("restart without npm complete");
const packagePath = path.join(installed.path, "package.json");
const packageJson = await readFile(packagePath, "utf8");
await writeFile(
@@ -1803,6 +1850,7 @@ export default function contribute() {
});
expect(await service.reloadPlugin("npm-review")).toMatchObject({ status: "running" });
await writeFile(packagePath, packageJson);
mark("corrupt metadata complete");
await service.removePlugin("npm-review");
await expect(stat(installRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect(
@@ -1816,6 +1864,7 @@ export default function contribute() {
else process.env.npm_config_userconfig = previousConfig;
await service.stopAllPlugins();
await registry.close();
mark("cleanup complete");
}
}, 120_000);
});
@@ -1,6 +1,8 @@
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { existsSync } from "node:fs";
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path, { join } from "node:path";
@@ -421,20 +423,20 @@ function linkedWorktreeFacts(gitCommonDir: string) {
});
}
const SETTLE_ATTEMPTS = 1_000;
async function settle(assertion: () => void): Promise<void> {
let lastFailure: unknown = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
// Observation setup resolves real filesystem paths even while the polling clock is frozen.
// Give that I/O wall-clock time without advancing the service's polling deadlines.
const deadline = performance.now() + 5_000;
while (true) {
try {
assertion();
return;
} catch (failure) {
lastFailure = failure;
if (performance.now() >= deadline) throw failure;
}
await delay(1);
await vi.advanceTimersByTimeAsync(0);
}
throw lastFailure;
}
async function waitForRefreshesToSettle(service: WorkspaceGitServiceImpl): Promise<void> {
@@ -1743,48 +1745,65 @@ describe("WorkspaceGitServiceImpl with workspace folders that no longer exist",
await service.dispose();
});
test("a deleted worktree leaves its shared repository polling and fetch to the surviving worktree", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const deletedWorktree = await createTemporaryFolder();
const survivingWorktree = await createTemporaryFolder();
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
let survivingWorktreeEdits = 0;
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
deletions: 0,
}));
const service = createService(git.deps);
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
await waitForObservationToSettle(service, 1);
const survivingSubscription = service.registerWorkspace({ cwd: survivingWorktree }, vi.fn());
await waitForObservationToSettle(service, 2);
await rm(deletedWorktree, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(60_000);
await waitForRefreshesToSettle(service);
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
for (let tick = 0; tick < 3; tick += 1) {
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => {
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
test.each([0, 250])(
"a deleted worktree leaves its shared repository polling and fetch to the surviving worktree (watch delay: %ims)",
async (watchDelayMs) => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const deletedWorktree = await createTemporaryFolder();
const survivingWorktree = await createTemporaryFolder();
const watchReady = createDeferred<void>();
const git = createFolderBackedGit({
facts: linkedWorktreeFacts(gitCommonDir),
heldWatch: { directory: survivingWorktree, release: watchReady.promise },
});
}
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
let survivingWorktreeEdits = 0;
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
deletions: 0,
}));
const service = createService(git.deps);
try {
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
await waitForObservationToSettle(service, 1);
const survivingSubscription = service.registerWorkspace(
{ cwd: survivingWorktree },
vi.fn(),
);
const pollingTime = Date.now();
void delay(watchDelayMs).then(() => watchReady.resolve());
await waitForObservationToSettle(service, 2);
expect(Date.now()).toBe(pollingTime);
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
survivingWorktree,
survivingWorktree,
]);
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
await rm(deletedWorktree, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(60_000);
await waitForRefreshesToSettle(service);
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
deletedSubscription.unsubscribe();
survivingSubscription.unsubscribe();
await service.dispose();
});
for (let tick = 0; tick < 3; tick += 1) {
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => {
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
});
}
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
survivingWorktree,
survivingWorktree,
]);
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
deletedSubscription.unsubscribe();
survivingSubscription.unsubscribe();
} finally {
watchReady.resolve();
await service.dispose();
}
},
);
test("deleting every worktree of a repository stops its polling and background fetch", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");