@knpkv/jira-clockify 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -1
- package/dist/package.json +36 -12
- package/dist/src/agent/agentSettings.d.ts +18 -0
- package/dist/src/agent/agentSettings.d.ts.map +1 -0
- package/dist/src/agent/agentSettings.js +23 -0
- package/dist/src/agent/agentSettings.js.map +1 -0
- package/dist/src/agent/schedule.d.ts +20 -0
- package/dist/src/agent/schedule.d.ts.map +1 -0
- package/dist/src/agent/schedule.js +189 -0
- package/dist/src/agent/schedule.js.map +1 -0
- package/dist/src/agent/sessions.d.ts +427 -0
- package/dist/src/agent/sessions.d.ts.map +1 -0
- package/dist/src/agent/sessions.js +868 -0
- package/dist/src/agent/sessions.js.map +1 -0
- package/dist/src/agent/sourceConsumption.d.ts +79 -0
- package/dist/src/agent/sourceConsumption.d.ts.map +1 -0
- package/dist/src/agent/sourceConsumption.js +191 -0
- package/dist/src/agent/sourceConsumption.js.map +1 -0
- package/dist/src/agent/watch.d.ts +76 -0
- package/dist/src/agent/watch.d.ts.map +1 -0
- package/dist/src/agent/watch.js +45 -0
- package/dist/src/agent/watch.js.map +1 -0
- package/dist/src/agent/writePlanning.d.ts +154 -0
- package/dist/src/agent/writePlanning.d.ts.map +1 -0
- package/dist/src/agent/writePlanning.js +210 -0
- package/dist/src/agent/writePlanning.js.map +1 -0
- package/dist/src/bin.d.ts +3 -0
- package/dist/src/bin.d.ts.map +1 -0
- package/dist/src/bin.js +9 -3
- package/dist/src/bin.js.map +1 -1
- package/dist/src/cli/agentWrite.d.ts +164 -0
- package/dist/src/cli/agentWrite.d.ts.map +1 -0
- package/dist/src/cli/agentWrite.js +362 -0
- package/dist/src/cli/agentWrite.js.map +1 -0
- package/dist/src/cli/auth.d.ts +8 -0
- package/dist/src/cli/auth.d.ts.map +1 -0
- package/dist/src/cli/auth.js +10 -10
- package/dist/src/cli/auth.js.map +1 -1
- package/dist/src/cli/calendar.d.ts +42 -0
- package/dist/src/cli/calendar.d.ts.map +1 -0
- package/dist/src/cli/calendar.js +189 -0
- package/dist/src/cli/calendar.js.map +1 -0
- package/dist/src/cli/config.d.ts +12 -0
- package/dist/src/cli/config.d.ts.map +1 -0
- package/dist/src/cli/config.js +163 -21
- package/dist/src/cli/config.js.map +1 -1
- package/dist/src/cli/fetchTicket.d.ts +51 -0
- package/dist/src/cli/fetchTicket.d.ts.map +1 -0
- package/dist/src/cli/fetchTicket.js +18 -2
- package/dist/src/cli/fetchTicket.js.map +1 -1
- package/dist/src/cli/fuzzySelect.d.ts +24 -0
- package/dist/src/cli/fuzzySelect.d.ts.map +1 -0
- package/dist/src/cli/layers.d.ts +40 -0
- package/dist/src/cli/layers.d.ts.map +1 -0
- package/dist/src/cli/layers.js +57 -13
- package/dist/src/cli/layers.js.map +1 -1
- package/dist/src/cli/list.d.ts +8 -0
- package/dist/src/cli/list.d.ts.map +1 -0
- package/dist/src/cli/list.js +2 -2
- package/dist/src/cli/list.js.map +1 -1
- package/dist/src/cli/reconcile.d.ts +129 -0
- package/dist/src/cli/reconcile.d.ts.map +1 -0
- package/dist/src/cli/reconcile.js +635 -46
- package/dist/src/cli/reconcile.js.map +1 -1
- package/dist/src/cli/root.d.ts +15 -0
- package/dist/src/cli/root.d.ts.map +1 -0
- package/dist/src/cli/root.js +4 -3
- package/dist/src/cli/root.js.map +1 -1
- package/dist/src/cli/runtimeFailure.d.ts +10 -0
- package/dist/src/cli/runtimeFailure.d.ts.map +1 -0
- package/dist/src/cli/runtimeFailure.js +17 -0
- package/dist/src/cli/runtimeFailure.js.map +1 -0
- package/dist/src/cli/setup.d.ts +10 -0
- package/dist/src/cli/setup.d.ts.map +1 -0
- package/dist/src/cli/setup.js +7 -7
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/timer/discard.d.ts +4 -0
- package/dist/src/cli/timer/discard.d.ts.map +1 -0
- package/dist/src/cli/timer/discard.js +2 -2
- package/dist/src/cli/timer/discard.js.map +1 -1
- package/dist/src/cli/timer/edit.d.ts +22 -0
- package/dist/src/cli/timer/edit.d.ts.map +1 -0
- package/dist/src/cli/timer/edit.js +62 -42
- package/dist/src/cli/timer/edit.js.map +1 -1
- package/dist/src/cli/timer/index.d.ts +12 -0
- package/dist/src/cli/timer/index.d.ts.map +1 -0
- package/dist/src/cli/timer/log.d.ts +16 -0
- package/dist/src/cli/timer/log.d.ts.map +1 -0
- package/dist/src/cli/timer/log.js +6 -6
- package/dist/src/cli/timer/log.js.map +1 -1
- package/dist/src/cli/timer/start.d.ts +21 -0
- package/dist/src/cli/timer/start.d.ts.map +1 -0
- package/dist/src/cli/timer/start.js +9 -9
- package/dist/src/cli/timer/start.js.map +1 -1
- package/dist/src/cli/timer/status.d.ts +22 -0
- package/dist/src/cli/timer/status.d.ts.map +1 -0
- package/dist/src/cli/timer/status.js +3 -3
- package/dist/src/cli/timer/status.js.map +1 -1
- package/dist/src/cli/timer/stop.d.ts +48 -0
- package/dist/src/cli/timer/stop.d.ts.map +1 -0
- package/dist/src/cli/timer/stop.js +19 -19
- package/dist/src/cli/timer/stop.js.map +1 -1
- package/dist/src/cli/timer.d.ts +11 -0
- package/dist/src/cli/timer.d.ts.map +1 -0
- package/dist/src/cli/timer.js +1 -1
- package/dist/src/cli/timer.js.map +1 -1
- package/dist/src/cli/watch.d.ts +54 -0
- package/dist/src/cli/watch.d.ts.map +1 -0
- package/dist/src/cli/watch.js +412 -0
- package/dist/src/cli/watch.js.map +1 -0
- package/dist/src/cli/watchLease.d.ts +99 -0
- package/dist/src/cli/watchLease.d.ts.map +1 -0
- package/dist/src/cli/watchLease.js +204 -0
- package/dist/src/cli/watchLease.js.map +1 -0
- package/dist/src/cli/writerGuard.d.ts +37 -0
- package/dist/src/cli/writerGuard.d.ts.map +1 -0
- package/dist/src/cli/writerGuard.js +92 -0
- package/dist/src/cli/writerGuard.js.map +1 -0
- package/dist/src/index.d.ts +46 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +46 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/main.d.ts +4 -0
- package/dist/src/main.d.ts.map +1 -0
- package/dist/src/services/AgentSessionReader.d.ts +121 -0
- package/dist/src/services/AgentSessionReader.d.ts.map +1 -0
- package/dist/src/services/AgentSessionReader.js +392 -0
- package/dist/src/services/AgentSessionReader.js.map +1 -0
- package/dist/src/services/ClockifyAuth.d.ts +47 -0
- package/dist/src/services/ClockifyAuth.d.ts.map +1 -0
- package/dist/src/services/ClockifyAuth.js +2 -2
- package/dist/src/services/ClockifyAuth.js.map +1 -1
- package/dist/src/services/CodexTranscript.d.ts +112 -0
- package/dist/src/services/CodexTranscript.d.ts.map +1 -0
- package/dist/src/services/CodexTranscript.js +96 -0
- package/dist/src/services/CodexTranscript.js.map +1 -0
- package/dist/src/services/ConfigService.d.ts +99 -0
- package/dist/src/services/ConfigService.d.ts.map +1 -0
- package/dist/src/services/ConfigService.js +79 -7
- package/dist/src/services/ConfigService.js.map +1 -1
- package/dist/src/services/HomeDirectory.d.ts +17 -0
- package/dist/src/services/HomeDirectory.d.ts.map +1 -0
- package/dist/src/services/HomeDirectory.js +1 -1
- package/dist/src/services/IssueFacts.d.ts +127 -0
- package/dist/src/services/IssueFacts.d.ts.map +1 -0
- package/dist/src/services/IssueFacts.js +277 -0
- package/dist/src/services/IssueFacts.js.map +1 -0
- package/dist/src/services/ProviderDeletion.d.ts +19 -0
- package/dist/src/services/ProviderDeletion.d.ts.map +1 -0
- package/dist/src/services/ProviderDeletion.js +37 -0
- package/dist/src/services/ProviderDeletion.js.map +1 -0
- package/dist/src/services/ReconcileService.d.ts +378 -0
- package/dist/src/services/ReconcileService.d.ts.map +1 -0
- package/dist/src/services/ReconcileService.js +1270 -79
- package/dist/src/services/ReconcileService.js.map +1 -1
- package/dist/src/services/SavedEntries.d.ts +40 -0
- package/dist/src/services/SavedEntries.d.ts.map +1 -0
- package/dist/src/services/SavedEntries.js +209 -0
- package/dist/src/services/SavedEntries.js.map +1 -0
- package/dist/src/services/SessionAttributor.d.ts +114 -0
- package/dist/src/services/SessionAttributor.d.ts.map +1 -0
- package/dist/src/services/SessionAttributor.js +240 -0
- package/dist/src/services/SessionAttributor.js.map +1 -0
- package/dist/src/services/SourceLedger.d.ts +104 -0
- package/dist/src/services/SourceLedger.d.ts.map +1 -0
- package/dist/src/services/SourceLedger.js +302 -0
- package/dist/src/services/SourceLedger.js.map +1 -0
- package/dist/src/services/StateWriter.d.ts +39 -0
- package/dist/src/services/StateWriter.d.ts.map +1 -0
- package/dist/src/services/TicketService.d.ts +63 -0
- package/dist/src/services/TicketService.d.ts.map +1 -0
- package/dist/src/services/TimerService.d.ts +145 -0
- package/dist/src/services/TimerService.d.ts.map +1 -0
- package/dist/src/services/TimerService.js +113 -80
- package/dist/src/services/TimerService.js.map +1 -1
- package/dist/src/services/internal/JiraWorklogPost.d.ts +7 -0
- package/dist/src/services/internal/JiraWorklogPost.d.ts.map +1 -0
- package/dist/src/services/internal/JiraWorklogPost.js +32 -0
- package/dist/src/services/internal/JiraWorklogPost.js.map +1 -0
- package/dist/src/testing/fakeHeadless.d.ts +318 -0
- package/dist/src/testing/fakeHeadless.d.ts.map +1 -0
- package/dist/src/testing/fakeHeadless.js +1149 -0
- package/dist/src/testing/fakeHeadless.js.map +1 -0
- package/dist/src/tui/App.d.ts +6 -0
- package/dist/src/tui/App.d.ts.map +1 -0
- package/dist/src/tui/App.js +1 -1
- package/dist/src/tui/App.js.map +1 -1
- package/dist/src/tui/atoms/runtime.d.ts +8 -0
- package/dist/src/tui/atoms/runtime.d.ts.map +1 -0
- package/dist/src/tui/atoms/runtime.js +1 -1
- package/dist/src/tui/atoms/runtime.js.map +1 -1
- package/dist/src/tui/atoms/tickets.d.ts +3 -0
- package/dist/src/tui/atoms/tickets.d.ts.map +1 -0
- package/dist/src/tui/atoms/timer.d.ts +11 -0
- package/dist/src/tui/atoms/timer.d.ts.map +1 -0
- package/dist/src/tui/atoms/timer.js +1 -1
- package/dist/src/tui/atoms/timer.js.map +1 -1
- package/dist/src/tui/atoms/ui.d.ts +11 -0
- package/dist/src/tui/atoms/ui.d.ts.map +1 -0
- package/dist/src/tui/atoms/ui.js +1 -1
- package/dist/src/tui/atoms/ui.js.map +1 -1
- package/dist/src/tui/components/BigTimer.d.ts +2 -0
- package/dist/src/tui/components/BigTimer.d.ts.map +1 -0
- package/dist/src/tui/components/Footer.d.ts +2 -0
- package/dist/src/tui/components/Footer.d.ts.map +1 -0
- package/dist/src/tui/components/Header.d.ts +2 -0
- package/dist/src/tui/components/Header.d.ts.map +1 -0
- package/dist/src/tui/components/Header.js +1 -1
- package/dist/src/tui/components/Header.js.map +1 -1
- package/dist/src/tui/components/PopupInput.d.ts +18 -0
- package/dist/src/tui/components/PopupInput.d.ts.map +1 -0
- package/dist/src/tui/components/PopupMessage.d.ts +20 -0
- package/dist/src/tui/components/PopupMessage.d.ts.map +1 -0
- package/dist/src/tui/components/TicketList.d.ts +2 -0
- package/dist/src/tui/components/TicketList.d.ts.map +1 -0
- package/dist/src/tui/components/TicketList.js +1 -1
- package/dist/src/tui/components/TicketList.js.map +1 -1
- package/dist/src/tui/components/TicketRow.d.ts +8 -0
- package/dist/src/tui/components/TicketRow.d.ts.map +1 -0
- package/dist/src/tui/components/TimerDisplay.d.ts +2 -0
- package/dist/src/tui/components/TimerDisplay.d.ts.map +1 -0
- package/dist/src/tui/components/index.d.ts +12 -0
- package/dist/src/tui/components/index.d.ts.map +1 -0
- package/dist/src/tui/context/theme.d.ts +15 -0
- package/dist/src/tui/context/theme.d.ts.map +1 -0
- package/dist/src/tui/hooks/useElapsedTimer.d.ts +6 -0
- package/dist/src/tui/hooks/useElapsedTimer.d.ts.map +1 -0
- package/dist/src/tui/hooks/useElapsedTimer.js +1 -1
- package/dist/src/tui/hooks/useElapsedTimer.js.map +1 -1
- package/dist/src/tui/hooks/useTerminalSize.d.ts +5 -0
- package/dist/src/tui/hooks/useTerminalSize.d.ts.map +1 -0
- package/dist/src/utils/hints.d.ts +12 -0
- package/dist/src/utils/hints.d.ts.map +1 -0
- package/dist/src/utils/hints.js +12 -0
- package/dist/src/utils/hints.js.map +1 -0
- package/dist/src/utils/time.d.ts +104 -0
- package/dist/src/utils/time.d.ts.map +1 -0
- package/dist/src/utils/time.js +74 -8
- package/dist/src/utils/time.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +36 -18
- package/skills/jcf/SKILL.md +93 -1
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FakeHeadlessLayer` — the same service shape as the CLI's `HeadlessLayer`, with every external
|
|
3
|
+
* boundary faked and every write captured.
|
|
4
|
+
*
|
|
5
|
+
* **Why this exists**
|
|
6
|
+
*
|
|
7
|
+
* `HeadlessLayer` is fully live, which is why `commandSurface.test.ts` can only assert `--help`
|
|
8
|
+
* outcomes. This layer keeps the *real* service implementations (TimerService, ReconcileService,
|
|
9
|
+
* AgentSessionReader) and fakes only what leaves the process: Clockify, Jira, both auth services,
|
|
10
|
+
* config, the filesystem, the terminal, and the Coding Agent. Commands can then be run end to end
|
|
11
|
+
* and asserted on what they printed, proposed, and wrote.
|
|
12
|
+
*
|
|
13
|
+
* **How to read a test written against it**
|
|
14
|
+
*
|
|
15
|
+
* - `world.createdClockifyEntries` / `world.jiraWorklogs` are the writes that actually happened.
|
|
16
|
+
* - `world.describeRequests` / `world.describeBatches` do the same for the notes written onto
|
|
17
|
+
* entries, so what a run *spends* on descriptions is assertable too.
|
|
18
|
+
* - `world.attributorRequests` is every session asked about and `world.attributorBatches` is the
|
|
19
|
+
* number of *calls* those took, so both "cost nothing" and "batched, not one-by-one" are
|
|
20
|
+
* assertable. `world.maxAttributorInFlight` is the most calls that ever overlapped.
|
|
21
|
+
* - `world.stdout` / `world.stderr` are the lines the command printed, split by stream, which is
|
|
22
|
+
* what the JSON Output Contract is about.
|
|
23
|
+
* - `world.prompts` is what the terminal rendered. Prompt text goes through `Terminal.display`,
|
|
24
|
+
* not `Console`, so this is the only place a confirmation's wording is observable.
|
|
25
|
+
* - `world.transcriptReads` is every transcript path opened, so a scope rule that promises a file is
|
|
26
|
+
* never read can be asserted rather than assumed.
|
|
27
|
+
* - `world.transcripts` is the transcripts on disk, read live. Append to it mid-run to model a
|
|
28
|
+
* session that starts, or grows, while a long-running command is watching.
|
|
29
|
+
* - `keep` scripts the row picker: `keep[i] === false` unchecks row `i`. Omitting `keep` entirely
|
|
30
|
+
* sends no input at all, which is the same signal as a missing TTY.
|
|
31
|
+
*
|
|
32
|
+
* **Why it ships**
|
|
33
|
+
*
|
|
34
|
+
* It lives in `src` and is exported as `@knpkv/jira-clockify/testing.js` because it is the highest
|
|
35
|
+
* seam in the package and the only one a *consumer* can drive: a web server over these services has
|
|
36
|
+
* to be testable against captured writes rather than a real Clockify account. Kept out of the barrel
|
|
37
|
+
* so nothing production imports it by accident.
|
|
38
|
+
*/
|
|
39
|
+
import { NodePath } from "@effect/platform-node";
|
|
40
|
+
import { ClockifyApiClient } from "@knpkv/clockify-api-client";
|
|
41
|
+
import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client";
|
|
42
|
+
import { JiraAuth } from "@knpkv/jira-cli/JiraAuth";
|
|
43
|
+
import * as ByteSize from "effect/ByteSize";
|
|
44
|
+
import * as Console from "effect/Console";
|
|
45
|
+
import * as Effect from "effect/Effect";
|
|
46
|
+
import * as FileSystem from "effect/FileSystem";
|
|
47
|
+
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
48
|
+
import * as Layer from "effect/Layer";
|
|
49
|
+
import * as Option from "effect/Option";
|
|
50
|
+
import { systemError } from "effect/PlatformError";
|
|
51
|
+
import * as Predicate from "effect/Predicate";
|
|
52
|
+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
|
|
53
|
+
import * as Queue from "effect/Queue";
|
|
54
|
+
import * as Redacted from "effect/Redacted";
|
|
55
|
+
import * as Schema from "effect/Schema";
|
|
56
|
+
import * as Stdio from "effect/Stdio";
|
|
57
|
+
import * as Terminal from "effect/Terminal";
|
|
58
|
+
import { defaultSessionAgentSettings } from "../agent/agentSettings.js";
|
|
59
|
+
import { LogToStderrLive } from "../cli/layers.js";
|
|
60
|
+
import { layer as agentSessionReaderLayer } from "../services/AgentSessionReader.js";
|
|
61
|
+
import { ClockifyAuth } from "../services/ClockifyAuth.js";
|
|
62
|
+
import { ConfigService } from "../services/ConfigService.js";
|
|
63
|
+
import { HomeDirectory } from "../services/HomeDirectory.js";
|
|
64
|
+
import { layer as issueFactsLayer } from "../services/IssueFacts.js";
|
|
65
|
+
import { layer as reconcileServiceLayer } from "../services/ReconcileService.js";
|
|
66
|
+
import { layer as savedEntriesLayer } from "../services/SavedEntries.js";
|
|
67
|
+
import { SessionAttributor, SessionAttributorError } from "../services/SessionAttributor.js";
|
|
68
|
+
import { layer as sourceLedgerLayer } from "../services/SourceLedger.js";
|
|
69
|
+
import { StateWriter } from "../services/StateWriter.js";
|
|
70
|
+
import { layer as ticketServiceLayer } from "../services/TicketService.js";
|
|
71
|
+
import { layer as timerServiceLayer } from "../services/TimerService.js";
|
|
72
|
+
export const FAKE_HOME = "/fake-home";
|
|
73
|
+
export const FAKE_WORKSPACE_ID = "ws-fake";
|
|
74
|
+
export const FAKE_USER_ID = "user-fake";
|
|
75
|
+
/**
|
|
76
|
+
* The text of a Jira worklog comment, read back out of the document format Jira takes.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately tolerant: a test asserts on what a reader would see, and walking the node tree for
|
|
79
|
+
* every `text` leaf says that without also pinning the paragraph structure around it.
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* One node of Jira's document format, reduced to what reading a comment back needs.
|
|
83
|
+
*
|
|
84
|
+
* Decoded rather than inspected: the tree is external data, and a schema is what turns "some
|
|
85
|
+
* object" into something with a `text` and a `content` this can rely on.
|
|
86
|
+
*/
|
|
87
|
+
const AdfNode = Schema.Struct({
|
|
88
|
+
text: Schema.optional(Schema.String),
|
|
89
|
+
content: Schema.optional(Schema.Unknown)
|
|
90
|
+
});
|
|
91
|
+
const decodeAdfNode = Schema.decodeUnknownOption(AdfNode);
|
|
92
|
+
/** The worklog fields the fake reads back off a POST body. */
|
|
93
|
+
const WorklogPayload = Schema.Struct({
|
|
94
|
+
started: Schema.optional(Schema.String),
|
|
95
|
+
timeSpentSeconds: Schema.optional(Schema.Finite),
|
|
96
|
+
comment: Schema.optionalKey(Schema.Json)
|
|
97
|
+
});
|
|
98
|
+
const decodeWorklogPayload = Schema.decodeUnknownOption(Schema.fromJsonString(WorklogPayload));
|
|
99
|
+
const commentText = (comment) => {
|
|
100
|
+
const texts = [];
|
|
101
|
+
const walk = (node) => {
|
|
102
|
+
if (Array.isArray(node)) {
|
|
103
|
+
for (const child of node)
|
|
104
|
+
walk(child);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const decoded = decodeAdfNode(node);
|
|
108
|
+
if (Option.isNone(decoded))
|
|
109
|
+
return;
|
|
110
|
+
const { content, text } = decoded.value;
|
|
111
|
+
if (text !== undefined)
|
|
112
|
+
texts.push(text);
|
|
113
|
+
if (content !== undefined)
|
|
114
|
+
walk(content);
|
|
115
|
+
};
|
|
116
|
+
walk(comment);
|
|
117
|
+
return texts.join(" ");
|
|
118
|
+
};
|
|
119
|
+
/** Whoever the fake is logged in as unless a test says otherwise. */
|
|
120
|
+
export const FAKE_ACCOUNT_ID = "acct-me";
|
|
121
|
+
const defaultConfig = {
|
|
122
|
+
sessionAgent: defaultSessionAgentSettings,
|
|
123
|
+
defaultJql: "",
|
|
124
|
+
refreshInterval: 30,
|
|
125
|
+
projectMap: {},
|
|
126
|
+
workspaceId: null,
|
|
127
|
+
defaultProjectId: null,
|
|
128
|
+
defaultProjectName: null,
|
|
129
|
+
defaultBillable: true,
|
|
130
|
+
sessionRoots: [],
|
|
131
|
+
sessionTicketMap: {},
|
|
132
|
+
sessionIdleCapSeconds: 300,
|
|
133
|
+
sessionConfidenceFloor: 0.7,
|
|
134
|
+
sessionOwnership: "assigned",
|
|
135
|
+
sessionOwnershipOverrides: [],
|
|
136
|
+
sessionDwellSeconds: 900
|
|
137
|
+
};
|
|
138
|
+
const makeTimeEntry = (entry, id) => ({
|
|
139
|
+
id: entry.id ?? id,
|
|
140
|
+
description: entry.description,
|
|
141
|
+
billable: entry.billable ?? true,
|
|
142
|
+
userId: entry.userId ?? FAKE_USER_ID,
|
|
143
|
+
...(entry.projectId !== undefined && { projectId: entry.projectId }),
|
|
144
|
+
...(entry.taskId !== undefined && { taskId: entry.taskId }),
|
|
145
|
+
workspaceId: FAKE_WORKSPACE_ID,
|
|
146
|
+
timeInterval: { start: entry.start, ...((entry.end !== undefined) && { end: entry.end }) },
|
|
147
|
+
tagIds: entry.tagIds ?? [],
|
|
148
|
+
type: "REGULAR",
|
|
149
|
+
isLocked: false
|
|
150
|
+
});
|
|
151
|
+
/** A `File.Info` with no mtime, so the reader's mtime pre-filter never skips a fixture. */
|
|
152
|
+
const fileInfo = (type) => ({
|
|
153
|
+
type,
|
|
154
|
+
mtime: Option.none(),
|
|
155
|
+
atime: Option.none(),
|
|
156
|
+
birthtime: Option.none(),
|
|
157
|
+
dev: 0,
|
|
158
|
+
ino: Option.none(),
|
|
159
|
+
mode: 0,
|
|
160
|
+
nlink: Option.none(),
|
|
161
|
+
uid: Option.none(),
|
|
162
|
+
gid: Option.none(),
|
|
163
|
+
rdev: Option.none(),
|
|
164
|
+
size: ByteSize.bytes(0),
|
|
165
|
+
blksize: Option.none(),
|
|
166
|
+
blocks: Option.none()
|
|
167
|
+
});
|
|
168
|
+
const TRANSCRIPT_ROOT = `${FAKE_HOME}/.claude/projects`;
|
|
169
|
+
/**
|
|
170
|
+
* A read-only in-memory filesystem serving the transcript fixtures a test declared.
|
|
171
|
+
*
|
|
172
|
+
* Read live from `world.transcripts` on every call rather than snapshotted at layer construction.
|
|
173
|
+
* A watch is a command that runs while the transcripts underneath it grow, so a test has to be able
|
|
174
|
+
* to append a prompt between two ticks — with a snapshot, every tick would see the same day.
|
|
175
|
+
*/
|
|
176
|
+
const fakeFileSystemLayer = (transcripts, reads, written, unwritable, unreadable, afterFileWrite, pidNamespaceInode) => {
|
|
177
|
+
/**
|
|
178
|
+
* The project directory a transcript really lives in: the Claude CLI derives it from the working
|
|
179
|
+
* directory the session ran in, not from anything the test chose.
|
|
180
|
+
*
|
|
181
|
+
* Derived here rather than taken from the fixture key so a test cannot accidentally file an
|
|
182
|
+
* out-of-scope session inside a Session Root — which is the behaviour the reader's pre-read scope
|
|
183
|
+
* filter depends on. The key's own directory is the fallback for a fixture with no readable `cwd`.
|
|
184
|
+
*/
|
|
185
|
+
const projectDirOf = (key, content) => {
|
|
186
|
+
for (const line of content.split("\n")) {
|
|
187
|
+
if (!line.includes("\"cwd\""))
|
|
188
|
+
continue;
|
|
189
|
+
const parsed = JSON.parse(line);
|
|
190
|
+
const cwd = Predicate.isObject(parsed) ? parsed["cwd"] : undefined;
|
|
191
|
+
if (Predicate.isString(cwd))
|
|
192
|
+
return cwd.replace(/[/.]/g, "-");
|
|
193
|
+
}
|
|
194
|
+
return key.split("/")[0] ?? key;
|
|
195
|
+
};
|
|
196
|
+
const fileName = (key) => key.slice(key.lastIndexOf("/") + 1);
|
|
197
|
+
const path = (key, content) => `${TRANSCRIPT_ROOT}/${projectDirOf(key, content)}/${fileName(key)}`;
|
|
198
|
+
const files = () => new Map(Object.entries(transcripts).map(([key, c]) => [path(key, c), c]));
|
|
199
|
+
const projectDirs = () => new Set(Object.entries(transcripts).map(([key, c]) => projectDirOf(key, c)));
|
|
200
|
+
const directories = () => {
|
|
201
|
+
const dirs = new Set([FAKE_HOME, `${FAKE_HOME}/.claude`, TRANSCRIPT_ROOT, `${FAKE_HOME}/.jcf`]);
|
|
202
|
+
for (const dir of projectDirs())
|
|
203
|
+
dirs.add(`${TRANSCRIPT_ROOT}/${dir}`);
|
|
204
|
+
return dirs;
|
|
205
|
+
};
|
|
206
|
+
const notFound = (method, path) => Effect.fail(systemError({
|
|
207
|
+
_tag: "NotFound",
|
|
208
|
+
module: "FileSystem",
|
|
209
|
+
method,
|
|
210
|
+
description: "No such file or directory",
|
|
211
|
+
pathOrDescriptor: path
|
|
212
|
+
}));
|
|
213
|
+
const permissionDenied = (method, path) => Effect.fail(systemError({
|
|
214
|
+
_tag: "PermissionDenied",
|
|
215
|
+
module: "FileSystem",
|
|
216
|
+
method,
|
|
217
|
+
description: "permission denied",
|
|
218
|
+
pathOrDescriptor: path
|
|
219
|
+
}));
|
|
220
|
+
const syncedHandle = (type) => ({
|
|
221
|
+
[FileSystem.FileTypeId]: FileSystem.FileTypeId,
|
|
222
|
+
stat: Effect.succeed(fileInfo(type)),
|
|
223
|
+
seek: () => Effect.succeed(ByteSize.bytes(0)),
|
|
224
|
+
sync: Effect.void,
|
|
225
|
+
read: () => Effect.succeed(0),
|
|
226
|
+
readAlloc: () => Effect.succeed(Option.none()),
|
|
227
|
+
truncate: () => Effect.void,
|
|
228
|
+
write: (buffer) => Effect.succeed(buffer.length),
|
|
229
|
+
writeAll: () => Effect.void
|
|
230
|
+
});
|
|
231
|
+
return FileSystem.layerNoop({
|
|
232
|
+
realPath: (path) => directories().has(path) || files().has(path) || written[path] !== undefined
|
|
233
|
+
? Effect.succeed(path)
|
|
234
|
+
: notFound("realPath", path),
|
|
235
|
+
open: (path) => directories().has(path)
|
|
236
|
+
? Effect.succeed(syncedHandle("Directory"))
|
|
237
|
+
: written[path] !== undefined
|
|
238
|
+
? Effect.succeed(syncedHandle("File"))
|
|
239
|
+
: notFound("open", path),
|
|
240
|
+
exists: (path) => Effect.succeed(directories().has(path) || files().has(path) || written[path] !== undefined),
|
|
241
|
+
stat: (path) => unreadable.includes(path)
|
|
242
|
+
? permissionDenied("stat", path)
|
|
243
|
+
: path === "/proc/self/ns/pid" && pidNamespaceInode !== undefined
|
|
244
|
+
? Effect.succeed({ ...fileInfo("File"), ino: Option.some(pidNamespaceInode) })
|
|
245
|
+
: directories().has(path)
|
|
246
|
+
? Effect.succeed(fileInfo("Directory"))
|
|
247
|
+
: files().has(path) || written[path] !== undefined
|
|
248
|
+
? Effect.succeed(fileInfo("File"))
|
|
249
|
+
: notFound("stat", path),
|
|
250
|
+
readDirectory: (path) => {
|
|
251
|
+
if (unreadable.some((suffix) => path.endsWith(suffix)))
|
|
252
|
+
return permissionDenied("readDirectory", path);
|
|
253
|
+
if (path === TRANSCRIPT_ROOT)
|
|
254
|
+
return Effect.succeed([...projectDirs()]);
|
|
255
|
+
const prefix = `${path}/`;
|
|
256
|
+
const entries = [...files().keys()]
|
|
257
|
+
.filter((file) => file.startsWith(prefix))
|
|
258
|
+
.map((file) => file.slice(prefix.length));
|
|
259
|
+
return directories().has(path) ? Effect.succeed(entries) : notFound("readDirectory", path);
|
|
260
|
+
},
|
|
261
|
+
readFileString: (path) => {
|
|
262
|
+
if (path.startsWith(`${TRANSCRIPT_ROOT}/`))
|
|
263
|
+
reads.push(path);
|
|
264
|
+
// Matched by suffix, so a test names the fixture it wrote rather than reproducing the project
|
|
265
|
+
// directory encoding the Claude CLI derives from a session's `cwd`.
|
|
266
|
+
if (unreadable.some((suffix) => path.endsWith(suffix))) {
|
|
267
|
+
return permissionDenied("readFileString", path);
|
|
268
|
+
}
|
|
269
|
+
const stored = written[path];
|
|
270
|
+
if (stored !== undefined)
|
|
271
|
+
return Effect.succeed(stored);
|
|
272
|
+
const content = files().get(path);
|
|
273
|
+
return content === undefined ? notFound("readFileString", path) : Effect.succeed(content);
|
|
274
|
+
},
|
|
275
|
+
// The config service writes through this; nothing in these tests reads it back from disk.
|
|
276
|
+
makeDirectory: () => Effect.void,
|
|
277
|
+
// A real store, so anything the CLI writes and reads back — the watch lease — behaves. `wx` is
|
|
278
|
+
// honoured, because an exclusive create is exactly what the lease relies on for its exclusion.
|
|
279
|
+
writeFileString: (path, content, options) => unwritable.includes(path)
|
|
280
|
+
// Deliberately not `AlreadyExists`: telling those two apart is the whole point of the
|
|
281
|
+
// lease's fail-closed rule, so the fake must be able to produce the other one.
|
|
282
|
+
? Effect.fail(systemError({
|
|
283
|
+
_tag: "PermissionDenied",
|
|
284
|
+
module: "FileSystem",
|
|
285
|
+
method: "writeFileString",
|
|
286
|
+
description: "permission denied",
|
|
287
|
+
pathOrDescriptor: path
|
|
288
|
+
}))
|
|
289
|
+
: options?.flag === "wx" && written[path] !== undefined
|
|
290
|
+
? Effect.fail(systemError({
|
|
291
|
+
_tag: "AlreadyExists",
|
|
292
|
+
module: "FileSystem",
|
|
293
|
+
method: "writeFileString",
|
|
294
|
+
description: "file already exists",
|
|
295
|
+
pathOrDescriptor: path
|
|
296
|
+
}))
|
|
297
|
+
: Effect.sync(() => {
|
|
298
|
+
written[path] = content;
|
|
299
|
+
}).pipe(Effect.andThen(afterFileWrite(path))),
|
|
300
|
+
remove: (path) => Effect.sync(() => {
|
|
301
|
+
delete written[path];
|
|
302
|
+
}),
|
|
303
|
+
rename: (from, to) => written[from] === undefined
|
|
304
|
+
? notFound("rename", from)
|
|
305
|
+
: unwritable.includes(to)
|
|
306
|
+
? permissionDenied("rename", to)
|
|
307
|
+
: Effect.sync(() => {
|
|
308
|
+
written[to] = written[from];
|
|
309
|
+
delete written[from];
|
|
310
|
+
})
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
const keypress = (name) => ({
|
|
314
|
+
input: Option.none(),
|
|
315
|
+
key: { name, ctrl: false, meta: false, shift: false }
|
|
316
|
+
});
|
|
317
|
+
/**
|
|
318
|
+
* Translate "keep these rows" into the keys a multi-select actually receives.
|
|
319
|
+
*
|
|
320
|
+
* The picker's cursor starts on its two meta options (select-all, invert), so choice `i` sits at
|
|
321
|
+
* cursor position `i + 2`. Every row starts checked, so only the rows to *drop* need a `space`.
|
|
322
|
+
*/
|
|
323
|
+
export const pickerKeys = (keep) => {
|
|
324
|
+
const keys = [];
|
|
325
|
+
let cursor = 0;
|
|
326
|
+
keep.forEach((keepRow, index) => {
|
|
327
|
+
if (keepRow)
|
|
328
|
+
return;
|
|
329
|
+
const target = index + 2;
|
|
330
|
+
for (; cursor < target; cursor++)
|
|
331
|
+
keys.push(keypress("down"));
|
|
332
|
+
keys.push(keypress("space"));
|
|
333
|
+
});
|
|
334
|
+
keys.push(keypress("enter"));
|
|
335
|
+
return keys;
|
|
336
|
+
};
|
|
337
|
+
/** The conventional terminal width, and the narrowest the picker lays rows out for. */
|
|
338
|
+
const DEFAULT_COLUMNS = 80;
|
|
339
|
+
/**
|
|
340
|
+
* A terminal that replays scripted keypresses. Once the script is exhausted the input queue ends,
|
|
341
|
+
* which `Prompt` reports as `QuitError` — the same outcome as running without a TTY.
|
|
342
|
+
*/
|
|
343
|
+
const fakeTerminalLayer = (options, world) => {
|
|
344
|
+
let prompt = 0;
|
|
345
|
+
return Layer.succeed(Terminal.Terminal, Terminal.make({
|
|
346
|
+
columns: Effect.succeed(options.columns),
|
|
347
|
+
rows: Effect.succeed(24),
|
|
348
|
+
// Scoped in Effect 4, and the queue ends with `Cause.Done` rather than a terminal-specific
|
|
349
|
+
// error. An exhausted script is still what a missing TTY looks like to `Prompt`.
|
|
350
|
+
readInput: Effect.gen(function* () {
|
|
351
|
+
yield* Effect.sync(() => options.beforePromptInput?.(world));
|
|
352
|
+
const queue = yield* Queue.unbounded();
|
|
353
|
+
const promptInput = options.promptInputs?.[prompt++];
|
|
354
|
+
if (promptInput !== undefined)
|
|
355
|
+
yield* Queue.offerAll(queue, promptInput.map(keypress));
|
|
356
|
+
// No script at all means no input, so the prompt ends unanswered.
|
|
357
|
+
if (promptInput === undefined && options.keep !== undefined) {
|
|
358
|
+
yield* Queue.offerAll(queue, pickerKeys(options.keep));
|
|
359
|
+
}
|
|
360
|
+
yield* Queue.end(queue);
|
|
361
|
+
return queue;
|
|
362
|
+
}),
|
|
363
|
+
readLine: Effect.succeed(""),
|
|
364
|
+
display: (text) => Effect.sync(() => {
|
|
365
|
+
world.prompts.push(text);
|
|
366
|
+
})
|
|
367
|
+
}));
|
|
368
|
+
};
|
|
369
|
+
/** A console that keeps stdout and stderr apart, which is what the JSON Output Contract needs. */
|
|
370
|
+
const captureConsoleLayer = (world, afterLog) => Layer.succeed(Console.Console, {
|
|
371
|
+
log: (...args) => {
|
|
372
|
+
const line = args.map(String).join(" ");
|
|
373
|
+
world.stdout.push(line);
|
|
374
|
+
afterLog?.(line, world);
|
|
375
|
+
},
|
|
376
|
+
error: (...args) => {
|
|
377
|
+
world.stderr.push(args.map(String).join(" "));
|
|
378
|
+
},
|
|
379
|
+
assert: () => { },
|
|
380
|
+
clear: () => { },
|
|
381
|
+
count: () => { },
|
|
382
|
+
countReset: () => { },
|
|
383
|
+
debug: () => { },
|
|
384
|
+
dir: () => { },
|
|
385
|
+
dirxml: () => { },
|
|
386
|
+
group: () => { },
|
|
387
|
+
groupCollapsed: () => { },
|
|
388
|
+
groupEnd: () => { },
|
|
389
|
+
info: () => { },
|
|
390
|
+
table: () => { },
|
|
391
|
+
time: () => { },
|
|
392
|
+
timeEnd: () => { },
|
|
393
|
+
timeLog: () => { },
|
|
394
|
+
trace: () => { },
|
|
395
|
+
warn: () => { }
|
|
396
|
+
});
|
|
397
|
+
/**
|
|
398
|
+
* Build a fully closed layer with the same services `HeadlessLayer` provides, plus the world the
|
|
399
|
+
* assertions read.
|
|
400
|
+
*/
|
|
401
|
+
export const makeFakeHeadless = (options = {}) => {
|
|
402
|
+
let jiraAccessTokenOverride;
|
|
403
|
+
let jiraAccountIdOverride;
|
|
404
|
+
let jiraCloudIdOverride;
|
|
405
|
+
let jiraSiteUrl = "https://fake.atlassian.net";
|
|
406
|
+
const world = {
|
|
407
|
+
createdClockifyEntries: [],
|
|
408
|
+
clockifyRequests: [],
|
|
409
|
+
clockifyAuth: {
|
|
410
|
+
baseUrl: "https://api.clockify.me/api",
|
|
411
|
+
workspaceId: FAKE_WORKSPACE_ID,
|
|
412
|
+
userId: FAKE_USER_ID,
|
|
413
|
+
apiKey: "key",
|
|
414
|
+
...options.clockifyAuth
|
|
415
|
+
},
|
|
416
|
+
jiraAuth: {
|
|
417
|
+
get accessToken() {
|
|
418
|
+
return jiraAccessTokenOverride ?? options.jiraAccessToken ?? "jira-token";
|
|
419
|
+
},
|
|
420
|
+
set accessToken(value) {
|
|
421
|
+
jiraAccessTokenOverride = value;
|
|
422
|
+
},
|
|
423
|
+
get accountId() {
|
|
424
|
+
return jiraAccountIdOverride ?? options.jiraAccountId ?? FAKE_ACCOUNT_ID;
|
|
425
|
+
},
|
|
426
|
+
set accountId(value) {
|
|
427
|
+
jiraAccountIdOverride = value;
|
|
428
|
+
},
|
|
429
|
+
get cloudId() {
|
|
430
|
+
return jiraCloudIdOverride ?? options.jiraCloudId ?? "cloud-fake";
|
|
431
|
+
},
|
|
432
|
+
set cloudId(value) {
|
|
433
|
+
jiraCloudIdOverride = value;
|
|
434
|
+
},
|
|
435
|
+
get siteUrl() {
|
|
436
|
+
return jiraSiteUrl;
|
|
437
|
+
},
|
|
438
|
+
set siteUrl(value) {
|
|
439
|
+
jiraSiteUrl = value;
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
jiraRequests: [],
|
|
443
|
+
clockifyVerifiedUserId: options.clockifyVerifiedUserId ?? FAKE_USER_ID,
|
|
444
|
+
clockifyUserReadFailuresRemaining: 0,
|
|
445
|
+
clockifyEntryReadFailuresRemaining: 0,
|
|
446
|
+
clockifyEntriesReadFailuresRemaining: 0,
|
|
447
|
+
jiraWorklogs: [],
|
|
448
|
+
updatedClockifyEntries: [],
|
|
449
|
+
updatedJiraWorklogs: [],
|
|
450
|
+
attributorRequests: [],
|
|
451
|
+
attributorBatches: [],
|
|
452
|
+
describeRequests: [],
|
|
453
|
+
describeBatches: [],
|
|
454
|
+
maxAttributorInFlight: 0,
|
|
455
|
+
stdout: [],
|
|
456
|
+
stderr: [],
|
|
457
|
+
prompts: [],
|
|
458
|
+
transcripts: { ...options.transcripts },
|
|
459
|
+
transcriptReads: [],
|
|
460
|
+
// Ordinary fake scenarios model an installation whose earlier provider history was already
|
|
461
|
+
// reviewed. Pass an explicit empty map to exercise first-run migration and its fail-closed path.
|
|
462
|
+
writtenFiles: options.writtenFiles === undefined ?
|
|
463
|
+
{
|
|
464
|
+
[`${FAKE_HOME}/.jcf/source-consumption.v1.json`]: JSON.stringify({
|
|
465
|
+
version: 3,
|
|
466
|
+
reviewedWindows: [
|
|
467
|
+
{
|
|
468
|
+
provider: "clockify",
|
|
469
|
+
scope: JSON.stringify([
|
|
470
|
+
"clockify-v3",
|
|
471
|
+
options.clockifyAuth?.baseUrl ?? "https://api.clockify.me/api",
|
|
472
|
+
options.clockifyAuth?.workspaceId ?? FAKE_WORKSPACE_ID,
|
|
473
|
+
options.clockifyVerifiedUserId ?? FAKE_USER_ID
|
|
474
|
+
]),
|
|
475
|
+
fromMs: 0,
|
|
476
|
+
toMs: 4_102_444_800_000
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
provider: "jira",
|
|
480
|
+
scope: JSON.stringify([options.jiraCloudId ?? "cloud-fake", options.jiraAccountId ?? FAKE_ACCOUNT_ID]),
|
|
481
|
+
fromMs: 0,
|
|
482
|
+
toMs: 4_102_444_800_000
|
|
483
|
+
}
|
|
484
|
+
],
|
|
485
|
+
pending: [],
|
|
486
|
+
bindings: [],
|
|
487
|
+
observedUnbound: []
|
|
488
|
+
})
|
|
489
|
+
} :
|
|
490
|
+
{ ...options.writtenFiles },
|
|
491
|
+
jiraLoggedIn: options.jiraLoggedIn ?? true,
|
|
492
|
+
runningTimer: options.runningTimer ?? null,
|
|
493
|
+
jiraLoginStateReadFailuresRemaining: 0,
|
|
494
|
+
jiraCurrentUserReadFailuresRemaining: 0,
|
|
495
|
+
jiraWorklogReadFailuresRemaining: 0,
|
|
496
|
+
jiraSearchFailuresRemaining: 0,
|
|
497
|
+
jiraSearchHiddenIssues: new Set(options.jiraSearchHiddenIssues),
|
|
498
|
+
setClockifyEntries: (entries) => {
|
|
499
|
+
clockifyLedger.splice(0, clockifyLedger.length, ...entries.map((entry, index) => makeTimeEntry(entry, `replaced-${index}`)));
|
|
500
|
+
},
|
|
501
|
+
setJiraWorklogs: (entries) => {
|
|
502
|
+
jiraLedger.clear();
|
|
503
|
+
for (const [key, worklogs] of Object.entries(entries)) {
|
|
504
|
+
jiraLedger.set(key, [...worklogs]);
|
|
505
|
+
for (const entry of worklogs) {
|
|
506
|
+
if (entry.id !== undefined)
|
|
507
|
+
issuedJiraIds.add(entry.id);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
// Ledgers, not fixed fixtures: a write lands here and the next read sees it, which is what
|
|
513
|
+
// makes "run it twice and nothing is proposed the second time" a real assertion.
|
|
514
|
+
const clockifyLedger = (options.clockifyEntries ?? []).map((entry, index) => makeTimeEntry(entry, `existing-${index}`));
|
|
515
|
+
const jiraLedger = new Map(Object.entries(options.jiraWorklogs ?? {}).map(([key, worklogs]) => [key, [...worklogs]]));
|
|
516
|
+
const jiraLedgersByCloudId = new Map(Object.entries(options.jiraWorklogsByCloudId ?? {}).map(([cloudId, issues]) => [
|
|
517
|
+
cloudId,
|
|
518
|
+
new Map(Object.entries(issues).map(([key, worklogs]) => [key, [...worklogs]]))
|
|
519
|
+
]));
|
|
520
|
+
const jiraLedgerForUrl = (url) => {
|
|
521
|
+
const cloudId = url.match(/\/ex\/jira\/([^/]+)\//u)?.[1];
|
|
522
|
+
return cloudId === undefined ? jiraLedger : jiraLedgersByCloudId.get(decodeURIComponent(cloudId)) ?? jiraLedger;
|
|
523
|
+
};
|
|
524
|
+
const issuedJiraIds = new Set([jiraLedger, ...jiraLedgersByCloudId.values()].flatMap((ledger) => [...ledger.values()].flatMap((entries) => entries.flatMap((entry) => entry.id === undefined ? [] : [entry.id]))));
|
|
525
|
+
const runningEntry = () => world.runningTimer === null ? null : makeTimeEntry(world.runningTimer, "running-1");
|
|
526
|
+
const clockify = {
|
|
527
|
+
getUser: () => Effect.succeed({
|
|
528
|
+
id: FAKE_USER_ID,
|
|
529
|
+
name: "Fake",
|
|
530
|
+
email: "fake@example.com",
|
|
531
|
+
activeWorkspace: FAKE_WORKSPACE_ID,
|
|
532
|
+
defaultWorkspace: FAKE_WORKSPACE_ID,
|
|
533
|
+
profilePicture: "",
|
|
534
|
+
status: "ACTIVE"
|
|
535
|
+
}),
|
|
536
|
+
getWorkspaces: () => Effect.succeed([{ id: FAKE_WORKSPACE_ID, name: "WS", imageUrl: "" }]),
|
|
537
|
+
getProjects: () => Effect.succeed(options.clockifyProjects ?? []),
|
|
538
|
+
getProjectByName: () => Effect.succeed(null),
|
|
539
|
+
// Paged when a test asks for it, so "reads only the first page" is observable rather than
|
|
540
|
+
// assumed. `getRunningTimer` has its own path, so the running entry rides along on page one.
|
|
541
|
+
getTimeEntries: (_ws, _user, params) => {
|
|
542
|
+
if (world.clockifyEntriesReadFailuresRemaining > 0) {
|
|
543
|
+
world.clockifyEntriesReadFailuresRemaining--;
|
|
544
|
+
return Effect.fail(new HttpClientError.HttpClientError({
|
|
545
|
+
reason: new HttpClientError.TransportError({
|
|
546
|
+
request: HttpClientRequest.get("https://fake.clockify/time-entries"),
|
|
547
|
+
description: "Clockify entries are unavailable"
|
|
548
|
+
})
|
|
549
|
+
}));
|
|
550
|
+
}
|
|
551
|
+
const running = runningEntry();
|
|
552
|
+
const all = running === null ? [...clockifyLedger] : [...clockifyLedger, running];
|
|
553
|
+
const page = params?.page ?? 1;
|
|
554
|
+
const cap = options.clockifyPageSize;
|
|
555
|
+
// One page holds everything, and the page after it is empty — a paginating caller has to be
|
|
556
|
+
// able to reach the end. Returning `all` for every page would model a server that never ends.
|
|
557
|
+
if (cap === undefined)
|
|
558
|
+
return Effect.succeed(page === 1 ? all : []);
|
|
559
|
+
// The smaller of the two, because Clockify may serve fewer than it was asked for — which is
|
|
560
|
+
// the case a caller that stops at the first short page gets wrong.
|
|
561
|
+
const size = Math.min(params?.pageSize ?? cap, cap);
|
|
562
|
+
return Effect.succeed(all.slice((page - 1) * size, page * size));
|
|
563
|
+
},
|
|
564
|
+
getRunningTimer: () => options.clockifyRunningTimerReadFails === true
|
|
565
|
+
? Effect.fail(new HttpClientError.HttpClientError({
|
|
566
|
+
reason: new HttpClientError.TransportError({
|
|
567
|
+
request: HttpClientRequest.get("https://api.clockify.me/running-timer"),
|
|
568
|
+
description: "Clockify running timer is unavailable"
|
|
569
|
+
})
|
|
570
|
+
}))
|
|
571
|
+
: Effect.sync(runningEntry),
|
|
572
|
+
getTimeEntry: (_ws, id) => Effect.suspend(() => {
|
|
573
|
+
if (world.clockifyEntryReadFailuresRemaining > 0) {
|
|
574
|
+
world.clockifyEntryReadFailuresRemaining--;
|
|
575
|
+
return Effect.fail(new HttpClientError.HttpClientError({
|
|
576
|
+
reason: new HttpClientError.TransportError({
|
|
577
|
+
request: HttpClientRequest.get(`https://fake.clockify/entries/${id}`),
|
|
578
|
+
description: "Clockify entry read is unavailable"
|
|
579
|
+
})
|
|
580
|
+
}));
|
|
581
|
+
}
|
|
582
|
+
const entry = clockifyLedger.find((entry) => entry.id === id);
|
|
583
|
+
if (entry !== undefined)
|
|
584
|
+
return Effect.succeed(entry);
|
|
585
|
+
const request = HttpClientRequest.get(`https://fake.clockify/entries/${id}`);
|
|
586
|
+
return Effect.fail(new HttpClientError.HttpClientError({
|
|
587
|
+
reason: new HttpClientError.StatusCodeError({
|
|
588
|
+
request,
|
|
589
|
+
response: HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))
|
|
590
|
+
})
|
|
591
|
+
}));
|
|
592
|
+
}),
|
|
593
|
+
getTags: () => Effect.succeed(options.clockifyTags ?? []),
|
|
594
|
+
createTag: (_ws, name) => Effect.succeed({ id: `tag-${name}`, name, workspaceId: FAKE_WORKSPACE_ID, archived: false }),
|
|
595
|
+
findOrCreateTag: (_ws, name) => Effect.succeed({ id: `tag-${name}`, name, workspaceId: FAKE_WORKSPACE_ID, archived: false }),
|
|
596
|
+
createTimeEntry: (_ws, params) => Effect.gen(function* () {
|
|
597
|
+
if (options.beforeClockifyWrite !== undefined)
|
|
598
|
+
yield* options.beforeClockifyWrite(world);
|
|
599
|
+
if (options.clockifyWritesFail === true) {
|
|
600
|
+
return yield* new HttpClientError.HttpClientError({
|
|
601
|
+
reason: new HttpClientError.TransportError({
|
|
602
|
+
request: HttpClientRequest.post("https://api.clockify.me/time-entries"),
|
|
603
|
+
description: "Clockify refused the entry"
|
|
604
|
+
})
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
return yield* Effect.sync(() => {
|
|
608
|
+
world.createdClockifyEntries.push({
|
|
609
|
+
description: params.description,
|
|
610
|
+
start: params.start,
|
|
611
|
+
end: params.end,
|
|
612
|
+
billable: params.billable
|
|
613
|
+
});
|
|
614
|
+
const entry = makeTimeEntry({ description: params.description, start: params.start, end: params.end }, `created-${clockifyLedger.length}`);
|
|
615
|
+
clockifyLedger.push(entry);
|
|
616
|
+
options.afterClockifyWrite?.(world);
|
|
617
|
+
return entry;
|
|
618
|
+
});
|
|
619
|
+
}),
|
|
620
|
+
updateTimeEntry: (_ws, id, params) => Effect.sync(() => {
|
|
621
|
+
const index = clockifyLedger.findIndex((entry) => entry.id === id);
|
|
622
|
+
const existing = clockifyLedger[index];
|
|
623
|
+
const saved = makeTimeEntry({
|
|
624
|
+
...existing,
|
|
625
|
+
...params,
|
|
626
|
+
description: params.description ?? existing?.description ?? "",
|
|
627
|
+
start: params.start
|
|
628
|
+
}, id);
|
|
629
|
+
world.updatedClockifyEntries.push({ id, payload: params });
|
|
630
|
+
if (index >= 0)
|
|
631
|
+
clockifyLedger[index] = saved;
|
|
632
|
+
return saved;
|
|
633
|
+
}),
|
|
634
|
+
deleteTimeEntry: () => Effect.void,
|
|
635
|
+
stopTimer: (_ws, _user, params) => Effect.succeed(makeTimeEntry({ description: "", start: "", end: params.end }, "stopped-1"))
|
|
636
|
+
};
|
|
637
|
+
// Read through the world, not captured, so one world can model logging back in between two runs —
|
|
638
|
+
// which is exactly the flow the "Jira refused, go and log in, restart" message asks the user for.
|
|
639
|
+
const loggedIn = () => world.jiraLoggedIn;
|
|
640
|
+
const jsonResponse = (request, status, body) => HttpClientResponse.fromWeb(request, new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }));
|
|
641
|
+
const bodyFailureResponse = (request, status) => HttpClientResponse.fromWeb(request, new Response(new ReadableStream({ start: (controller) => controller.error("synthetic body read failure") }), {
|
|
642
|
+
status,
|
|
643
|
+
headers: { "content-type": "application/json" }
|
|
644
|
+
}));
|
|
645
|
+
const requestPayload = (body) => {
|
|
646
|
+
if (body._tag !== "Uint8Array" || !("body" in body) || !Predicate.isUint8Array(body.body))
|
|
647
|
+
return {};
|
|
648
|
+
return Option.getOrElse(decodeWorklogPayload(new TextDecoder().decode(body.body)), () => ({}));
|
|
649
|
+
};
|
|
650
|
+
/**
|
|
651
|
+
* The single Jira boundary. Both the generated `JiraApiClient` (reads) and TimerService's raw
|
|
652
|
+
* worklog POST (writes) go through it, so reads exercise the real client's decoding rather than
|
|
653
|
+
* a hand-shaped stub of a very large generated interface.
|
|
654
|
+
*/
|
|
655
|
+
const httpClientLayer = Layer.succeed(HttpClient.HttpClient, HttpClient.make((request) => Effect.sync(() => {
|
|
656
|
+
const requestJiraLedger = jiraLedgerForUrl(request.url);
|
|
657
|
+
if (request.url.includes("atlassian")) {
|
|
658
|
+
world.jiraRequests.push({ method: request.method, url: request.url });
|
|
659
|
+
}
|
|
660
|
+
if (request.url.includes("/v1/user") && request.method === "GET") {
|
|
661
|
+
world.clockifyRequests.push({ method: request.method, url: request.url });
|
|
662
|
+
if (options.clockifyUserReadFails === true) {
|
|
663
|
+
return jsonResponse(request, 503, { message: "synthetic verification failure" });
|
|
664
|
+
}
|
|
665
|
+
if (world.clockifyUserReadFailuresRemaining > 0) {
|
|
666
|
+
world.clockifyUserReadFailuresRemaining--;
|
|
667
|
+
return jsonResponse(request, 503, { message: "synthetic verification failure" });
|
|
668
|
+
}
|
|
669
|
+
return jsonResponse(request, 200, {
|
|
670
|
+
id: world.clockifyVerifiedUserId,
|
|
671
|
+
name: "Fake",
|
|
672
|
+
email: "fake@example.com",
|
|
673
|
+
profilePicture: "",
|
|
674
|
+
status: "ACTIVE"
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
const clockifyEntries = request.url.match(/\/v1\/workspaces\/([^/]+)\/user\/([^/]+)\/time-entries/);
|
|
678
|
+
if (clockifyEntries !== null && request.method === "GET") {
|
|
679
|
+
world.clockifyRequests.push({ method: request.method, url: request.url });
|
|
680
|
+
if (world.clockifyEntriesReadFailuresRemaining > 0) {
|
|
681
|
+
world.clockifyEntriesReadFailuresRemaining--;
|
|
682
|
+
return jsonResponse(request, 503, { message: "synthetic entry read failure" });
|
|
683
|
+
}
|
|
684
|
+
if (options.clockifyRunningTimerReadFails === true &&
|
|
685
|
+
request.urlParams.params.some(([name]) => name === "in-progress")) {
|
|
686
|
+
return jsonResponse(request, 503, { message: "synthetic timer read failure" });
|
|
687
|
+
}
|
|
688
|
+
const page = Number(request.urlParams.params.find(([name]) => name === "page")?.[1] ?? 1);
|
|
689
|
+
const cap = options.clockifyPageSize;
|
|
690
|
+
const size = Math.min(Number(request.urlParams.params.find(([name]) => name === "page-size")?.[1] ?? 200), cap ?? 200);
|
|
691
|
+
const running = runningEntry();
|
|
692
|
+
const hiddenIds = new Set(options.clockifyListHiddenIds);
|
|
693
|
+
const visibleLedger = clockifyLedger.filter((entry) => entry.id === undefined || !hiddenIds.has(entry.id));
|
|
694
|
+
const all = request.urlParams.params.some(([name]) => name === "in-progress")
|
|
695
|
+
? running === null ? [] : [running]
|
|
696
|
+
: running === null
|
|
697
|
+
? visibleLedger
|
|
698
|
+
: [...visibleLedger, running];
|
|
699
|
+
const entries = all.slice((page - 1) * size, page * size);
|
|
700
|
+
options.afterClockifyEntriesRead?.(page, world);
|
|
701
|
+
return jsonResponse(request, 200, entries);
|
|
702
|
+
}
|
|
703
|
+
const exactClockifyEntry = request.url.match(/\/v1\/workspaces\/([^/]+)\/time-entries\/([^/?]+)/);
|
|
704
|
+
if (exactClockifyEntry !== null && request.method === "GET") {
|
|
705
|
+
world.clockifyRequests.push({ method: request.method, url: request.url });
|
|
706
|
+
const fault = options.clockifyExactEntryReadFault;
|
|
707
|
+
if (fault === "malformed-404") {
|
|
708
|
+
return HttpClientResponse.fromWeb(request, new Response("{", {
|
|
709
|
+
status: 404,
|
|
710
|
+
headers: { "content-type": "application/json" }
|
|
711
|
+
}));
|
|
712
|
+
}
|
|
713
|
+
if (fault === "body-failure-404")
|
|
714
|
+
return bodyFailureResponse(request, 404);
|
|
715
|
+
if (fault !== undefined)
|
|
716
|
+
return jsonResponse(request, Number(fault), { message: "synthetic read failure" });
|
|
717
|
+
const entry = clockifyLedger.find((candidate) => candidate.id === exactClockifyEntry[2]);
|
|
718
|
+
return entry === undefined
|
|
719
|
+
? jsonResponse(request, 404, { message: "synthetic time entry not found" })
|
|
720
|
+
: jsonResponse(request, 200, entry);
|
|
721
|
+
}
|
|
722
|
+
if (request.url.match(/\/v1\/workspaces\/[^/]+\/time-entries$/) !== null && request.method === "POST") {
|
|
723
|
+
world.clockifyRequests.push({ method: request.method, url: request.url });
|
|
724
|
+
if (options.clockifyWritesFail === true)
|
|
725
|
+
return jsonResponse(request, 503, { message: "synthetic failure" });
|
|
726
|
+
const body = request.body;
|
|
727
|
+
if (body._tag !== "Uint8Array" || !("body" in body) || !Predicate.isUint8Array(body.body)) {
|
|
728
|
+
return jsonResponse(request, 400, { message: "missing body" });
|
|
729
|
+
}
|
|
730
|
+
const payload = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({
|
|
731
|
+
description: Schema.String,
|
|
732
|
+
start: Schema.String,
|
|
733
|
+
end: Schema.String,
|
|
734
|
+
billable: Schema.Boolean
|
|
735
|
+
})))(new TextDecoder().decode(body.body));
|
|
736
|
+
if (Option.isNone(payload))
|
|
737
|
+
return jsonResponse(request, 400, { message: "invalid body" });
|
|
738
|
+
const entry = makeTimeEntry(payload.value, `created-${clockifyLedger.length}`);
|
|
739
|
+
clockifyLedger.push(entry);
|
|
740
|
+
world.createdClockifyEntries.push(payload.value);
|
|
741
|
+
options.afterClockifyWrite?.(world);
|
|
742
|
+
return jsonResponse(request, 201, entry);
|
|
743
|
+
}
|
|
744
|
+
const exactWorklog = request.url.match(/issue\/([^/]+)\/worklog\/([^/?]+)/);
|
|
745
|
+
if (exactWorklog !== null) {
|
|
746
|
+
const issueKey = exactWorklog[1] ?? "";
|
|
747
|
+
const id = exactWorklog[2] ?? "";
|
|
748
|
+
const entries = requestJiraLedger.get(issueKey) ?? [];
|
|
749
|
+
const index = entries.findIndex((entry, index) => (entry.id ?? `wl-${index}`) === id);
|
|
750
|
+
const entry = entries[index];
|
|
751
|
+
const fault = options.jiraExactWorklogReadFault;
|
|
752
|
+
if (fault === "malformed-404") {
|
|
753
|
+
return HttpClientResponse.fromWeb(request, new Response("{", {
|
|
754
|
+
status: 404,
|
|
755
|
+
headers: { "content-type": "application/json" }
|
|
756
|
+
}));
|
|
757
|
+
}
|
|
758
|
+
if (fault === "body-failure-404")
|
|
759
|
+
return bodyFailureResponse(request, 404);
|
|
760
|
+
if (fault === "permission-404") {
|
|
761
|
+
return jsonResponse(request, 404, { errorMessages: ["synthetic visibility denied"] });
|
|
762
|
+
}
|
|
763
|
+
if (fault === "disabled-404") {
|
|
764
|
+
return jsonResponse(request, 404, { errorMessages: ["synthetic time tracking unavailable"] });
|
|
765
|
+
}
|
|
766
|
+
if (fault === "null-404")
|
|
767
|
+
return jsonResponse(request, 404, null);
|
|
768
|
+
if (fault === "empty-404")
|
|
769
|
+
return jsonResponse(request, 404, {});
|
|
770
|
+
if (fault !== undefined)
|
|
771
|
+
return jsonResponse(request, Number(fault), { errorMessages: ["synthetic failure"] });
|
|
772
|
+
if (entry === undefined)
|
|
773
|
+
return jsonResponse(request, 404, { errorMessages: ["Worklog not found"] });
|
|
774
|
+
if (request.method === "PUT") {
|
|
775
|
+
const payload = requestPayload(request.body);
|
|
776
|
+
entries[index] = {
|
|
777
|
+
...entry,
|
|
778
|
+
started: payload.started ?? entry.started,
|
|
779
|
+
timeSpentSeconds: payload.timeSpentSeconds ?? entry.timeSpentSeconds,
|
|
780
|
+
...(payload.comment !== undefined && { comment: payload.comment })
|
|
781
|
+
};
|
|
782
|
+
world.updatedJiraWorklogs.push({
|
|
783
|
+
issueKey,
|
|
784
|
+
id,
|
|
785
|
+
payload,
|
|
786
|
+
adjustEstimate: [...request.urlParams].find(([key]) => key === "adjustEstimate")?.[1]
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
return jsonResponse(request, 200, {
|
|
790
|
+
author: { accountId: options.jiraAccountId ?? FAKE_ACCOUNT_ID },
|
|
791
|
+
...entries[index],
|
|
792
|
+
id: options.jiraReadOmitsId === true ? undefined : options.jiraExactWorklogReturnedId ?? id
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
const worklogMatch = request.url.match(/issue\/([^/]+)\/worklog/);
|
|
796
|
+
if (request.method === "POST" && worklogMatch !== null) {
|
|
797
|
+
const payload = requestPayload(request.body);
|
|
798
|
+
const issueKey = worklogMatch[1] ?? "unknown";
|
|
799
|
+
let nextId = 0;
|
|
800
|
+
while (issuedJiraIds.has(`wl-created-${nextId}`))
|
|
801
|
+
nextId++;
|
|
802
|
+
const entryId = options.jiraPostedWorklog?.id ?? `wl-created-${nextId}`;
|
|
803
|
+
if (issuedJiraIds.has(entryId)) {
|
|
804
|
+
return jsonResponse(request, 409, { errorMessages: ["Synthetic worklog ID already exists"] });
|
|
805
|
+
}
|
|
806
|
+
issuedJiraIds.add(entryId);
|
|
807
|
+
const started = payload.started ?? "";
|
|
808
|
+
const timeSpentSeconds = payload.timeSpentSeconds ?? 0;
|
|
809
|
+
world.jiraWorklogs.push({ issueKey, started, timeSpentSeconds, comment: commentText(payload.comment) });
|
|
810
|
+
requestJiraLedger.set(issueKey, [...(requestJiraLedger.get(issueKey) ?? []), {
|
|
811
|
+
id: entryId,
|
|
812
|
+
...(options.jiraPostedWorklog !== undefined && {
|
|
813
|
+
author: { accountId: options.jiraPostedWorklog.accountId },
|
|
814
|
+
...(options.jiraPostedWorklog.created !== undefined && { created: options.jiraPostedWorklog.created })
|
|
815
|
+
}),
|
|
816
|
+
started,
|
|
817
|
+
timeSpentSeconds,
|
|
818
|
+
...(payload.comment !== undefined && { comment: payload.comment })
|
|
819
|
+
}]);
|
|
820
|
+
return jsonResponse(request, 201, options.jiraPostOmitsId === true ? {} : { id: entryId });
|
|
821
|
+
}
|
|
822
|
+
if (worklogMatch !== null) {
|
|
823
|
+
const requestedStartAt = Number(request.urlParams.params.find(([name]) => name === "startAt")?.[1] ?? 0);
|
|
824
|
+
if (requestedStartAt > 0 && options.jiraWorklogPageFault === "failure") {
|
|
825
|
+
return jsonResponse(request, 500, { errorMessages: ["Later worklog page unavailable"] });
|
|
826
|
+
}
|
|
827
|
+
if (options.jiraWorklogReadFails === true || world.jiraWorklogReadFailuresRemaining > 0) {
|
|
828
|
+
if (world.jiraWorklogReadFailuresRemaining > 0)
|
|
829
|
+
world.jiraWorklogReadFailuresRemaining--;
|
|
830
|
+
return jsonResponse(request, 500, { errorMessages: ["Jira is having a moment"] });
|
|
831
|
+
}
|
|
832
|
+
const numberParam = (name) => {
|
|
833
|
+
const raw = request.urlParams.params.find(([candidate]) => candidate === name)?.[1];
|
|
834
|
+
if (raw === undefined)
|
|
835
|
+
return undefined;
|
|
836
|
+
const parsed = Number(raw);
|
|
837
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
838
|
+
};
|
|
839
|
+
const startedAfter = numberParam("startedAfter");
|
|
840
|
+
const startedBefore = numberParam("startedBefore");
|
|
841
|
+
const startAt = numberParam("startAt") ?? 0;
|
|
842
|
+
const entries = (requestJiraLedger.get(worklogMatch[1] ?? "") ?? []).filter((worklog) => {
|
|
843
|
+
const startedMs = new Date(worklog.started).getTime();
|
|
844
|
+
return (startedAfter === undefined || startedMs >= startedAfter) &&
|
|
845
|
+
(startedBefore === undefined || startedMs < startedBefore);
|
|
846
|
+
});
|
|
847
|
+
const pageSize = options.jiraWorklogPageSize ?? entries.length;
|
|
848
|
+
const page = entries.slice(startAt, startAt + pageSize);
|
|
849
|
+
if (startAt > 0 && options.jiraWorklogPageFault === "missing") {
|
|
850
|
+
return jsonResponse(request, 200, { startAt, maxResults: pageSize, total: entries.length });
|
|
851
|
+
}
|
|
852
|
+
const response = {
|
|
853
|
+
startAt: startAt > 0 && options.jiraWorklogPageFault === "stalled" ? 0 : startAt,
|
|
854
|
+
maxResults: pageSize,
|
|
855
|
+
total: entries.length + (startAt > 0 && options.jiraWorklogPageFault === "inconsistent" ? 1 : 0),
|
|
856
|
+
worklogs: page.map((worklog, index) => {
|
|
857
|
+
const id = worklog.id ?? `wl-${startAt + index}`;
|
|
858
|
+
issuedJiraIds.add(id);
|
|
859
|
+
return {
|
|
860
|
+
id,
|
|
861
|
+
author: { accountId: options.jiraAccountId ?? FAKE_ACCOUNT_ID },
|
|
862
|
+
...worklog,
|
|
863
|
+
...(startAt > 0 && options.jiraWorklogPageFault === "missing-duration" && {
|
|
864
|
+
timeSpentSeconds: undefined
|
|
865
|
+
}),
|
|
866
|
+
...(startAt > 0 && options.jiraWorklogPageFault === "missing-started" && { started: undefined }),
|
|
867
|
+
...(startAt > 0 && options.jiraWorklogPageFault === "missing-author" && { author: undefined }),
|
|
868
|
+
...(startAt > 0 && options.jiraWorklogPageFault === "missing-id" && { id: undefined }),
|
|
869
|
+
...(startAt > 0 && options.jiraWorklogPageFault === "empty-id" && { id: "" }),
|
|
870
|
+
...(startAt > 0 && options.jiraWorklogPageFault === "spaces-id" && { id: " " }),
|
|
871
|
+
...(startAt > 0 && options.jiraWorklogPageFault === "tab-newline-id" && { id: "\t\n" }),
|
|
872
|
+
...(options.jiraReadOmitsId === true && { id: undefined })
|
|
873
|
+
};
|
|
874
|
+
})
|
|
875
|
+
};
|
|
876
|
+
options.afterJiraWorklogsRead?.(worklogMatch[1] ?? "", startAt, world);
|
|
877
|
+
return jsonResponse(request, 200, response);
|
|
878
|
+
}
|
|
879
|
+
const issueMatch = request.url.match(/issue\/([^/?]+)(?:\?|$)/);
|
|
880
|
+
if (request.method === "GET" && issueMatch !== null) {
|
|
881
|
+
const key = issueMatch[1] ?? "";
|
|
882
|
+
const summary = (options.issueSummaries ?? {})[key];
|
|
883
|
+
if (summary === undefined) {
|
|
884
|
+
return jsonResponse(request, 404, { errorMessages: ["Issue does not exist"] });
|
|
885
|
+
}
|
|
886
|
+
const assignee = (options.issueAssignees ?? {})[key];
|
|
887
|
+
const displayName = (options.issueAssigneeDisplayNames ?? {})[key];
|
|
888
|
+
return jsonResponse(request, 200, {
|
|
889
|
+
id: "10000",
|
|
890
|
+
key,
|
|
891
|
+
fields: {
|
|
892
|
+
summary,
|
|
893
|
+
...((assignee !== undefined) && {
|
|
894
|
+
assignee: assignee === null ? null : displayName === null
|
|
895
|
+
? {}
|
|
896
|
+
: { displayName: displayName ?? assignee }
|
|
897
|
+
})
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
if (request.url.includes("/myself")) {
|
|
902
|
+
if (options.jiraCurrentUserFails === true || world.jiraCurrentUserReadFailuresRemaining > 0) {
|
|
903
|
+
if (world.jiraCurrentUserReadFailuresRemaining > 0)
|
|
904
|
+
world.jiraCurrentUserReadFailuresRemaining--;
|
|
905
|
+
return jsonResponse(request, 500, { errorMessages: ["Current user unavailable"] });
|
|
906
|
+
}
|
|
907
|
+
return jsonResponse(request, 200, {
|
|
908
|
+
accountId: world.jiraAuth.accountId,
|
|
909
|
+
displayName: "Fake User"
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
if (request.url.includes("/search/jql")) {
|
|
913
|
+
if (world.jiraSearchFailuresRemaining > 0) {
|
|
914
|
+
world.jiraSearchFailuresRemaining--;
|
|
915
|
+
return jsonResponse(request, 500, { errorMessages: ["Jira search is unavailable"] });
|
|
916
|
+
}
|
|
917
|
+
// A `key in (…)` search answers about exactly those keys. Any other JQL keeps the old
|
|
918
|
+
// behaviour: whatever the ledger happens to hold. The query lives in `urlParams` rather
|
|
919
|
+
// than in `url` — the client keeps them apart until the request is actually sent.
|
|
920
|
+
const jql = request.urlParams.params.find(([name]) => name === "jql")?.[1];
|
|
921
|
+
const clause = jql === undefined ? null : jql.match(/key in \(([^)]*)\)/);
|
|
922
|
+
if (clause !== null) {
|
|
923
|
+
const keys = (clause[1] ?? "").split(",").map((key) => key.trim()).filter((key) => key !== "");
|
|
924
|
+
const summaries = options.issueSummaries ?? {};
|
|
925
|
+
const assignees = options.issueAssignees ?? {};
|
|
926
|
+
const displayNames = options.issueAssigneeDisplayNames ?? {};
|
|
927
|
+
return jsonResponse(request, 200, {
|
|
928
|
+
// Only the keys this fake Jira knows about, so a test can model an issue that is gone.
|
|
929
|
+
issues: keys.filter((key) => summaries[key] !== undefined).map((key, index) => ({
|
|
930
|
+
id: String(index),
|
|
931
|
+
key,
|
|
932
|
+
fields: {
|
|
933
|
+
summary: summaries[key],
|
|
934
|
+
...((assignees[key] !== undefined) && {
|
|
935
|
+
assignee: assignees[key] === null
|
|
936
|
+
? null
|
|
937
|
+
: displayNames[key] === null
|
|
938
|
+
? { accountId: assignees[key] }
|
|
939
|
+
: { accountId: assignees[key], displayName: displayNames[key] ?? assignees[key] }
|
|
940
|
+
})
|
|
941
|
+
}
|
|
942
|
+
}))
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
return jsonResponse(request, 200, {
|
|
946
|
+
...(options.jiraSearchClaimsMoreWithoutToken === true && { isLast: false }),
|
|
947
|
+
issues: [...requestJiraLedger.keys()]
|
|
948
|
+
.filter((key) => !world.jiraSearchHiddenIssues.has(key))
|
|
949
|
+
.map((key, index) => ({ id: String(index), key }))
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
return jsonResponse(request, 404, { errorMessages: [`unrouted: ${request.method} ${request.url}`] });
|
|
953
|
+
})));
|
|
954
|
+
let attributorInFlight = 0;
|
|
955
|
+
const attributorLayer = Layer.succeed(SessionAttributor, {
|
|
956
|
+
attribute: (requests) => Effect.gen(function* () {
|
|
957
|
+
for (const request of requests) {
|
|
958
|
+
world.attributorRequests.push({ candidateKeys: request.candidateKeys, digest: request.digest });
|
|
959
|
+
}
|
|
960
|
+
world.attributorBatches.push(requests.map((request) => request.sessionId));
|
|
961
|
+
attributorInFlight += 1;
|
|
962
|
+
world.maxAttributorInFlight = Math.max(world.maxAttributorInFlight, attributorInFlight);
|
|
963
|
+
// Yield so sibling calls get a chance to start. A real call is a CLI process taking tens of
|
|
964
|
+
// seconds, so overlapping is the whole point; without a suspension point here every fake
|
|
965
|
+
// call would complete before the next began and the high-water mark could not tell a
|
|
966
|
+
// concurrent implementation from a sequential one.
|
|
967
|
+
//
|
|
968
|
+
// Cooperative yielding, not `Effect.sleep`: `it.effect` runs on a TestClock that nothing
|
|
969
|
+
// here advances, so a sleep would never return.
|
|
970
|
+
yield* Effect.yieldNow;
|
|
971
|
+
yield* Effect.yieldNow;
|
|
972
|
+
attributorInFlight -= 1;
|
|
973
|
+
const answers = [];
|
|
974
|
+
for (const request of requests) {
|
|
975
|
+
const answer = options.attributor?.(request) ?? { _tag: "None" };
|
|
976
|
+
// A single "fail" fails the whole call, which is what a real timeout does to a batch.
|
|
977
|
+
if (answer === "fail") {
|
|
978
|
+
return yield* new SessionAttributorError({ message: "claude executable not found" });
|
|
979
|
+
}
|
|
980
|
+
answers.push({ sessionId: request.sessionId, choice: answer });
|
|
981
|
+
}
|
|
982
|
+
return answers;
|
|
983
|
+
}),
|
|
984
|
+
describe: (requests) => Effect.gen(function* () {
|
|
985
|
+
options.beforeDescribe?.(world);
|
|
986
|
+
for (const request of requests) {
|
|
987
|
+
world.describeRequests.push({
|
|
988
|
+
ticketKey: request.ticketKey,
|
|
989
|
+
summary: request.summary,
|
|
990
|
+
digest: request.digest
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
world.describeBatches.push(requests.map((request) => request.ticketKey));
|
|
994
|
+
const answers = [];
|
|
995
|
+
for (const request of requests) {
|
|
996
|
+
const answer = options.describer?.(request) ?? null;
|
|
997
|
+
// As with attribution, one "fail" fails the whole call — a timeout costs a batch.
|
|
998
|
+
if (answer === "fail") {
|
|
999
|
+
return yield* new SessionAttributorError({ message: "claude executable not found" });
|
|
1000
|
+
}
|
|
1001
|
+
answers.push({ id: request.id, note: answer });
|
|
1002
|
+
}
|
|
1003
|
+
return answers;
|
|
1004
|
+
})
|
|
1005
|
+
});
|
|
1006
|
+
/**
|
|
1007
|
+
* A spawner that refuses to spawn.
|
|
1008
|
+
*
|
|
1009
|
+
* The Coding Agent is faked at the `SessionAttributor` seam, so nothing in these tests should
|
|
1010
|
+
* reach a child process — but the layer was previously left open, which typechecking would have
|
|
1011
|
+
* caught had this package's tests been in a tsconfig. Failing loudly beats a real `claude` process
|
|
1012
|
+
* starting during a test run.
|
|
1013
|
+
*/
|
|
1014
|
+
const SpawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => Effect.die("the fake headless layer spawns no child processes")));
|
|
1015
|
+
const HomeLayer = Layer.succeed(HomeDirectory, { path: FAKE_HOME });
|
|
1016
|
+
// A ledger rather than a constant: `set` is how the config commands do their whole job, so a
|
|
1017
|
+
// fake that discards it can only ever assert what was printed, not what was stored.
|
|
1018
|
+
let config = { ...defaultConfig, ...options.config };
|
|
1019
|
+
const ConfigLayer = Layer.succeed(ConfigService, {
|
|
1020
|
+
get: Effect.gen(function* () {
|
|
1021
|
+
const snapshot = config;
|
|
1022
|
+
if (options.delayConfigReads === true)
|
|
1023
|
+
yield* Effect.sleep("10 millis");
|
|
1024
|
+
return snapshot;
|
|
1025
|
+
}),
|
|
1026
|
+
set: (patch) => Effect.sync(() => {
|
|
1027
|
+
config = { ...config, ...patch };
|
|
1028
|
+
}),
|
|
1029
|
+
configDir: Effect.succeed(`${FAKE_HOME}/.jcf`)
|
|
1030
|
+
});
|
|
1031
|
+
const ClockifyAuthLayer = Layer.succeed(ClockifyAuth, {
|
|
1032
|
+
getConfig: Effect.sync(() => ({
|
|
1033
|
+
...world.clockifyAuth,
|
|
1034
|
+
apiKey: Redacted.make(world.clockifyAuth.apiKey)
|
|
1035
|
+
})),
|
|
1036
|
+
save: () => Effect.void,
|
|
1037
|
+
isConfigured: Effect.succeed(true)
|
|
1038
|
+
});
|
|
1039
|
+
const JiraAuthLayer = Layer.succeed(JiraAuth, {
|
|
1040
|
+
configure: () => Effect.void,
|
|
1041
|
+
isConfigured: () => Effect.succeed(loggedIn()),
|
|
1042
|
+
login: () => Effect.void,
|
|
1043
|
+
logout: () => Effect.void,
|
|
1044
|
+
getAccessToken: () => Effect.succeed(Redacted.make(loggedIn() ? world.jiraAuth.accessToken : "")),
|
|
1045
|
+
getCloudId: () => Effect.succeed(world.jiraAuth.cloudId),
|
|
1046
|
+
getSiteUrl: () => Effect.succeed(world.jiraAuth.siteUrl),
|
|
1047
|
+
getCurrentUser: () => Effect.succeed(!loggedIn() || options.jiraCachedUserMissing === true ? null : {
|
|
1048
|
+
account_id: world.jiraAuth.accountId,
|
|
1049
|
+
name: "Fake User",
|
|
1050
|
+
email: "fake@example.com"
|
|
1051
|
+
}),
|
|
1052
|
+
getActiveProfile: () => Effect.succeed(loggedIn() ?
|
|
1053
|
+
{
|
|
1054
|
+
id: "synthetic-profile",
|
|
1055
|
+
name: "Synthetic",
|
|
1056
|
+
token: {
|
|
1057
|
+
access_token: world.jiraAuth.accessToken,
|
|
1058
|
+
refresh_token: "synthetic-refresh",
|
|
1059
|
+
expires_at: 4_102_444_800_000,
|
|
1060
|
+
scope: "write:jira-work",
|
|
1061
|
+
cloud_id: world.jiraAuth.cloudId,
|
|
1062
|
+
site_url: world.jiraAuth.siteUrl,
|
|
1063
|
+
user: {
|
|
1064
|
+
account_id: world.jiraAuth.accountId,
|
|
1065
|
+
name: "Fake User",
|
|
1066
|
+
email: "fake@example.com"
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
created_at: "2026-01-01T00:00:00.000Z",
|
|
1070
|
+
updated_at: "2026-01-01T00:00:00.000Z"
|
|
1071
|
+
} :
|
|
1072
|
+
null),
|
|
1073
|
+
listProfiles: () => Effect.succeed([]),
|
|
1074
|
+
switchProfile: () => Effect.succeed(null),
|
|
1075
|
+
removeProfile: () => Effect.succeed(null),
|
|
1076
|
+
isLoggedIn: () => {
|
|
1077
|
+
if (world.jiraLoginStateReadFailuresRemaining === 0)
|
|
1078
|
+
return Effect.succeed(loggedIn());
|
|
1079
|
+
world.jiraLoginStateReadFailuresRemaining--;
|
|
1080
|
+
return Effect.fail(systemError({
|
|
1081
|
+
_tag: "PermissionDenied",
|
|
1082
|
+
module: "FileSystem",
|
|
1083
|
+
method: "readFileString",
|
|
1084
|
+
description: "synthetic Jira login state read failure",
|
|
1085
|
+
pathOrDescriptor: `${FAKE_HOME}/.jira/config.json`
|
|
1086
|
+
}));
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
const StateWriterLayer = Layer.succeed(StateWriter, {
|
|
1090
|
+
write: () => Effect.void,
|
|
1091
|
+
read: Effect.succeed({
|
|
1092
|
+
active: false,
|
|
1093
|
+
ticketKey: null,
|
|
1094
|
+
summary: null,
|
|
1095
|
+
project: null,
|
|
1096
|
+
startedAt: null,
|
|
1097
|
+
startedAt_unix: null,
|
|
1098
|
+
elapsed: 0,
|
|
1099
|
+
clockifyEntryId: null
|
|
1100
|
+
}),
|
|
1101
|
+
clear: Effect.void
|
|
1102
|
+
});
|
|
1103
|
+
const ClockifyLayer = Layer.succeed(ClockifyApiClient, clockify);
|
|
1104
|
+
const resolvedJiraCredential = () => ({
|
|
1105
|
+
type: "oauth2",
|
|
1106
|
+
accessToken: Redacted.make(world.jiraAuth.accessToken),
|
|
1107
|
+
cloudId: world.jiraAuth.cloudId
|
|
1108
|
+
});
|
|
1109
|
+
const jiraConfig = options.beforeJiraAuthResolution === undefined
|
|
1110
|
+
? {
|
|
1111
|
+
baseUrl: "https://fake.atlassian.net",
|
|
1112
|
+
auth: { type: "basic", email: "fake@example.com", apiToken: Redacted.make("token") }
|
|
1113
|
+
}
|
|
1114
|
+
: {
|
|
1115
|
+
baseUrl: "",
|
|
1116
|
+
auth: resolvedJiraCredential(),
|
|
1117
|
+
resolveAuth: Effect.sync(() => {
|
|
1118
|
+
options.beforeJiraAuthResolution?.(world);
|
|
1119
|
+
return resolvedJiraCredential();
|
|
1120
|
+
})
|
|
1121
|
+
};
|
|
1122
|
+
const JiraConfigLayer = Layer.succeed(JiraApiConfig, jiraConfig);
|
|
1123
|
+
const FileSystemLayer = fakeFileSystemLayer(world.transcripts, world.transcriptReads, world.writtenFiles, options.unwritablePaths ?? [], options.unreadableTranscripts ?? [], options.afterFileWrite ?? (() => Effect.void), options.pidNamespaceInode);
|
|
1124
|
+
const Externals = Layer.mergeAll(ClockifyLayer, ClockifyAuthLayer, JiraAuthLayer, ConfigLayer, StateWriterLayer, HomeLayer, FileSystemLayer, NodePath.layer,
|
|
1125
|
+
// The CLI runner writes help and error text through Stdio; the command's own output goes
|
|
1126
|
+
// through the captured Console instead.
|
|
1127
|
+
Stdio.layerTest({}), httpClientLayer, attributorLayer, fakeTerminalLayer({
|
|
1128
|
+
keep: options.keep,
|
|
1129
|
+
promptInputs: options.promptInputs,
|
|
1130
|
+
columns: options.columns ?? DEFAULT_COLUMNS,
|
|
1131
|
+
beforePromptInput: options.beforePromptInput
|
|
1132
|
+
}, world), captureConsoleLayer(world, options.afterConsoleLog), SpawnerLayer,
|
|
1133
|
+
// Mirrors HeadlessLayer: warnings must land on stderr, or they corrupt --json output.
|
|
1134
|
+
LogToStderrLive);
|
|
1135
|
+
// The real generated client over the routed fake HttpClient, so read decoding is exercised.
|
|
1136
|
+
const JiraLayer = JiraApiClient.layer.pipe(Layer.provide(JiraConfigLayer), Layer.provide(httpClientLayer));
|
|
1137
|
+
const TimerLive = timerServiceLayer.pipe(Layer.provide(Externals));
|
|
1138
|
+
const SourceLedgerLive = sourceLedgerLayer.pipe(Layer.provide(Externals));
|
|
1139
|
+
const ReaderLive = agentSessionReaderLayer.pipe(Layer.provide(Externals));
|
|
1140
|
+
const ReconcileLive = reconcileServiceLayer.pipe(Layer.provide(Externals), Layer.provide(JiraLayer), Layer.provide(TimerLive), Layer.provide(ReaderLive), Layer.provide(SourceLedgerLive));
|
|
1141
|
+
const SavedLive = savedEntriesLayer.pipe(Layer.provide(Externals), Layer.provide(JiraLayer));
|
|
1142
|
+
const TicketLive = ticketServiceLayer.pipe(Layer.provide(Externals), Layer.provide(JiraLayer));
|
|
1143
|
+
const IssueFactsLive = issueFactsLayer.pipe(Layer.provide(Externals), Layer.provide(JiraLayer));
|
|
1144
|
+
// `ReconcileService` is built from the timer, the reader and the Jira client, so those cannot sit
|
|
1145
|
+
// beside it in a `mergeAll` — that builds its members in parallel. They go underneath.
|
|
1146
|
+
const layer = Layer.mergeAll(ReconcileLive, TicketLive, IssueFactsLive, SavedLive).pipe(Layer.provideMerge(Layer.mergeAll(TimerLive, ReaderLive, JiraLayer, SourceLedgerLive)), Layer.provideMerge(Externals));
|
|
1147
|
+
return { layer, sourceLedgerLayer: SourceLedgerLive, world };
|
|
1148
|
+
};
|
|
1149
|
+
//# sourceMappingURL=fakeHeadless.js.map
|