@cat-factory/app 0.315.4 → 0.315.7
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 +3 -1
- package/app/components/bootstrap/BootstrapModal.vue +1 -1
- package/app/components/common/ConfirmDialog.vue +5 -28
- package/app/components/common/TakeoverShell.vue +97 -0
- package/app/components/focus/BlockFocusView.vue +24 -3
- package/app/components/github/AddServiceFromRepoModal.vue +2 -2
- package/app/components/github/GitHubPanel.vue +1 -1
- package/app/components/kaizen/KaizenPanel.vue +6 -11
- package/app/components/media/ArtifactLightbox.vue +13 -21
- package/app/components/observability/StepModelActivity.vue +1 -0
- package/app/components/panels/AgentStepDetail.vue +8 -12
- package/app/components/panels/InspectorPanel.vue +6 -1
- package/app/components/panels/ObservabilityPanel.vue +7 -11
- package/app/components/panels/OperatorDashboardPanel.vue +6 -11
- package/app/components/panels/ReportsPanel.vue +6 -11
- package/app/components/panels/inspector/ServiceTestConfig.vue +2 -2
- package/app/components/pipeline/PipelineBuilder.vue +4 -0
- package/app/components/settings/ModelConfigurationPanel.vue +21 -13
- package/app/plugins/foreign-layers.client.ts +8 -0
- package/app/stores/github/connection.ts +2 -4
- package/app/stores/github/context.ts +5 -1
- package/app/stores/github/projectionLoad.ts +127 -0
- package/app/stores/github/repoActions.ts +7 -2
- package/app/stores/github.spec.ts +355 -1
- package/app/stores/github.ts +21 -22
- package/app/stores/ui/builderModelConfigHandoff.spec.ts +88 -0
- package/app/stores/ui/modals.ts +78 -12
- package/app/utils/foreignLayers.spec.ts +100 -0
- package/app/utils/foreignLayers.ts +71 -0
- package/i18n/locales/de.json +1 -0
- package/i18n/locales/en.json +1 -0
- package/i18n/locales/es.json +1 -0
- package/i18n/locales/fr.json +1 -0
- package/i18n/locales/he.json +1 -0
- package/i18n/locales/it.json +1 -0
- package/i18n/locales/ja.json +1 -0
- package/i18n/locales/pl.json +1 -0
- package/i18n/locales/tr.json +1 -0
- package/i18n/locales/uk.json +1 -0
- package/package.json +1 -1
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
// preference. The list view shows each preset; the editor view creates/edits one with a
|
|
12
12
|
// base-model picker plus a filterable per-agent override list.
|
|
13
13
|
import { computed, ref, watch } from 'vue'
|
|
14
|
-
import { onKeyStroke } from '@vueuse/core'
|
|
15
14
|
import type { ModelFlavor } from '@cat-factory/contracts'
|
|
16
15
|
import type { AgentKind } from '~/types/domain'
|
|
17
16
|
import type { ModelPreset } from '~/types/model-presets'
|
|
@@ -25,7 +24,7 @@ import { showOverrideField } from '~/utils/uiMode'
|
|
|
25
24
|
import SectionLabel from '~/components/common/SectionLabel.vue'
|
|
26
25
|
import IconButton from '~/components/common/IconButton.vue'
|
|
27
26
|
import EmptyState from '~/components/common/EmptyState.vue'
|
|
28
|
-
import
|
|
27
|
+
import TakeoverShell from '~/components/common/TakeoverShell.vue'
|
|
29
28
|
|
|
30
29
|
const { t } = useI18n()
|
|
31
30
|
const ui = useUiStore()
|
|
@@ -119,12 +118,11 @@ watch(
|
|
|
119
118
|
{ immediate: true },
|
|
120
119
|
)
|
|
121
120
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// Esc backs out of the editor first, then closes the panel.
|
|
121
|
+
// Esc backs out of the editor first, then closes the panel.
|
|
122
|
+
function backOut() {
|
|
125
123
|
if (editor.value) editor.value = null
|
|
126
124
|
else open.value = false
|
|
127
|
-
}
|
|
125
|
+
}
|
|
128
126
|
|
|
129
127
|
/** The selectable catalog models, as `{ id, label, suffix }` for a dropdown. */
|
|
130
128
|
const selectableModels = computed(() => {
|
|
@@ -309,12 +307,13 @@ async function save() {
|
|
|
309
307
|
<template>
|
|
310
308
|
<Teleport to="body">
|
|
311
309
|
<Transition name="reader-fade">
|
|
312
|
-
<
|
|
310
|
+
<TakeoverShell
|
|
313
311
|
v-if="open"
|
|
314
|
-
|
|
315
|
-
:
|
|
316
|
-
|
|
317
|
-
|
|
312
|
+
:label="t('settings.modelConfiguration.title')"
|
|
313
|
+
:content-key="editor ? 'editor' : 'list'"
|
|
314
|
+
class="flex-col bg-app-950/96 backdrop-blur-sm"
|
|
315
|
+
data-testid="model-configuration-panel"
|
|
316
|
+
@close="backOut"
|
|
318
317
|
>
|
|
319
318
|
<header class="flex items-center gap-3 border-b border-default px-6 py-4">
|
|
320
319
|
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/15">
|
|
@@ -377,7 +376,13 @@ async function save() {
|
|
|
377
376
|
</template>
|
|
378
377
|
</i18n-t>
|
|
379
378
|
</p>
|
|
380
|
-
<UButton
|
|
379
|
+
<UButton
|
|
380
|
+
icon="i-lucide-plus"
|
|
381
|
+
size="sm"
|
|
382
|
+
class="shrink-0"
|
|
383
|
+
data-testid="model-preset-new"
|
|
384
|
+
@click="startCreate"
|
|
385
|
+
>
|
|
381
386
|
{{ t('settings.modelConfiguration.list.newPreset') }}
|
|
382
387
|
</UButton>
|
|
383
388
|
</div>
|
|
@@ -437,6 +442,7 @@ async function save() {
|
|
|
437
442
|
variant="ghost"
|
|
438
443
|
color="error"
|
|
439
444
|
icon="i-lucide-trash-2"
|
|
445
|
+
data-testid="model-preset-delete"
|
|
440
446
|
:disabled="p.isDefault"
|
|
441
447
|
:loading="busy"
|
|
442
448
|
:label="
|
|
@@ -491,6 +497,7 @@ async function save() {
|
|
|
491
497
|
</SectionLabel>
|
|
492
498
|
<UInput
|
|
493
499
|
v-model="editor.name"
|
|
500
|
+
data-testid="model-preset-name"
|
|
494
501
|
:placeholder="t('settings.modelConfiguration.editor.namePlaceholder')"
|
|
495
502
|
size="sm"
|
|
496
503
|
class="w-full"
|
|
@@ -510,6 +517,7 @@ async function save() {
|
|
|
510
517
|
variant="subtle"
|
|
511
518
|
trailing-icon="i-lucide-chevron-down"
|
|
512
519
|
class="w-full justify-between"
|
|
520
|
+
data-testid="model-preset-base-model"
|
|
513
521
|
>
|
|
514
522
|
<span class="truncate">{{ modelLabel(editor.baseModelId) }}</span>
|
|
515
523
|
</UButton>
|
|
@@ -610,7 +618,7 @@ async function save() {
|
|
|
610
618
|
</template>
|
|
611
619
|
</div>
|
|
612
620
|
</div>
|
|
613
|
-
</
|
|
621
|
+
</TakeoverShell>
|
|
614
622
|
</Transition>
|
|
615
623
|
</Teleport>
|
|
616
624
|
</template>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { sharedOverlayStack } from '@modular-vue/core'
|
|
2
|
+
import { trackForeignLayers } from '~/utils/foreignLayers'
|
|
3
|
+
|
|
4
|
+
// Put every open Nuxt UI menu, select, popover and modal on the shared overlay stack, so the
|
|
5
|
+
// takeover or window under it leaves Escape and Tab to it. Why: `utils/foreignLayers.ts`.
|
|
6
|
+
export default defineNuxtPlugin(() => {
|
|
7
|
+
trackForeignLayers(document.body, sharedOverlayStack)
|
|
8
|
+
})
|
|
@@ -14,7 +14,7 @@ import type { GitHubStoreContext } from './context'
|
|
|
14
14
|
*/
|
|
15
15
|
export function createGitHubConnectionActions(ctx: GitHubStoreContext) {
|
|
16
16
|
const { api, workspace, available, connection, installations, loadingInstallations } = ctx
|
|
17
|
-
const {
|
|
17
|
+
const { availableRepos, branches, syncing, load, clearProjection } = ctx
|
|
18
18
|
|
|
19
19
|
/** The URL a workspace owner visits to install the App against this workspace. */
|
|
20
20
|
function getInstallUrl(): Promise<string> {
|
|
@@ -48,10 +48,8 @@ export function createGitHubConnectionActions(ctx: GitHubStoreContext) {
|
|
|
48
48
|
// Backends predating the discriminator omit `provider`; those connections are GitHub App ones.
|
|
49
49
|
await DISCONNECT_BY_PROVIDER[connection.value?.provider ?? 'github'](workspace.requireId())
|
|
50
50
|
connection.value = null
|
|
51
|
-
|
|
51
|
+
clearProjection()
|
|
52
52
|
availableRepos.value = []
|
|
53
|
-
pulls.value = []
|
|
54
|
-
issues.value = []
|
|
55
53
|
branches.value = {}
|
|
56
54
|
}
|
|
57
55
|
|
|
@@ -45,6 +45,10 @@ export interface GitHubStoreContext {
|
|
|
45
45
|
syncing: Ref<boolean>
|
|
46
46
|
/** Whether an App installation is bound (the store's `connected` computed). */
|
|
47
47
|
connected: Readonly<Ref<boolean>>
|
|
48
|
-
/** Reload the cached repos/PRs/issues projection (the store's `load`). */
|
|
48
|
+
/** Reload the cached repos/PRs/issues projection after a write (the store's `load`). */
|
|
49
49
|
load: () => Promise<void>
|
|
50
|
+
/** Record a direct write to the projection, so a read that started before it cannot land. */
|
|
51
|
+
markWritten: () => void
|
|
52
|
+
/** Empty the repos/PRs/issues projection and mark it unloaded, so `ensureLoaded` reads it again. */
|
|
53
|
+
clearProjection: () => void
|
|
50
54
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { GitHubConnection, GitHubIssue, GitHubPullRequest, GitHubRepo } from '~/types/domain'
|
|
3
|
+
import type { GitHubStoreContext } from '~/stores/github/context'
|
|
4
|
+
|
|
5
|
+
/** What the projection load reads and writes. The store owns every ref. */
|
|
6
|
+
interface ProjectionLoadDeps {
|
|
7
|
+
api: GitHubStoreContext['api']
|
|
8
|
+
workspace: GitHubStoreContext['workspace']
|
|
9
|
+
connection: Readonly<Ref<GitHubConnection | null>>
|
|
10
|
+
repos: Ref<GitHubRepo[]>
|
|
11
|
+
pulls: Ref<GitHubPullRequest[]>
|
|
12
|
+
issues: Ref<GitHubIssue[]>
|
|
13
|
+
loading: Ref<boolean>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ProjectionLoad {
|
|
17
|
+
/** Read the projection again after a write, never sharing a read that started before it. */
|
|
18
|
+
load: () => Promise<void>
|
|
19
|
+
/** Read the projection again, sharing a read already in flight for the same connection. */
|
|
20
|
+
refresh: () => Promise<void>
|
|
21
|
+
/** Read the projection unless it is already loaded for the current connection. */
|
|
22
|
+
ensureLoaded: () => Promise<void>
|
|
23
|
+
/** Record a direct write to the projection, so a read that started before it cannot land. */
|
|
24
|
+
markWritten: () => void
|
|
25
|
+
/** Empty the lists and forget the load in one step. */
|
|
26
|
+
clearProjection: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface InFlightRead {
|
|
30
|
+
key: string
|
|
31
|
+
promise: Promise<void>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The repos / pull requests / issues projection read, and the record of what it is loaded for.
|
|
36
|
+
*
|
|
37
|
+
* One generation counter answers every question about whether a read may land. A read takes the
|
|
38
|
+
* next number when it starts, and every direct write to the projection (`markWritten()`, which
|
|
39
|
+
* `clearProjection()` calls too) takes one as well. A read lands only if its number is still the
|
|
40
|
+
* latest and it was started for the connection that is current when it finishes, so a result that
|
|
41
|
+
* a newer read, a write, a disconnect or a board switch overtook is dropped.
|
|
42
|
+
*
|
|
43
|
+
* The projection counts as loaded for a key made of the workspace and the connection. A connected
|
|
44
|
+
* workspace with no linked repos is loaded: the empty list is its answer. A different workspace or
|
|
45
|
+
* a new connection is a different key, so a reinstall is read again without anyone telling this
|
|
46
|
+
* module that it happened.
|
|
47
|
+
*
|
|
48
|
+
* There are three doors. `ensureLoaded()` reads only when nothing is loaded, `refresh()` is a
|
|
49
|
+
* deliberate re-read that still shares a read in flight, and `load()` is the re-read after a write,
|
|
50
|
+
* which starts its own read so it never returns what the write replaced.
|
|
51
|
+
*/
|
|
52
|
+
export function createGitHubProjectionLoad(deps: ProjectionLoadDeps): ProjectionLoad {
|
|
53
|
+
const { api, workspace, connection, repos, pulls, issues, loading } = deps
|
|
54
|
+
|
|
55
|
+
let generation = 0
|
|
56
|
+
let loadedKey: string | null = null
|
|
57
|
+
let inFlight: InFlightRead | null = null
|
|
58
|
+
|
|
59
|
+
/** What the projection is read for. Null while there is nothing to read: no board or no connection. */
|
|
60
|
+
function currentKey(): string | null {
|
|
61
|
+
const conn = connection.value
|
|
62
|
+
if (!workspace.workspaceId || !conn) return null
|
|
63
|
+
return `${workspace.workspaceId}:${conn.provider}:${conn.installationId}:${conn.connectedAt}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function read(mine: number, key: string, askedFor: string): Promise<void> {
|
|
67
|
+
const [r, p, i] = await Promise.all([
|
|
68
|
+
api.listGitHubRepos(askedFor),
|
|
69
|
+
api.listGitHubPullRequests(askedFor),
|
|
70
|
+
api.listGitHubIssues(askedFor),
|
|
71
|
+
])
|
|
72
|
+
if (mine !== generation || key !== currentKey()) return
|
|
73
|
+
repos.value = r
|
|
74
|
+
pulls.value = p
|
|
75
|
+
issues.value = i
|
|
76
|
+
loadedKey = key
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function start(key: string): Promise<void> {
|
|
80
|
+
const mine = ++generation
|
|
81
|
+
loading.value = true
|
|
82
|
+
const entry: InFlightRead = {
|
|
83
|
+
key,
|
|
84
|
+
promise: read(mine, key, workspace.requireId()).finally(() => {
|
|
85
|
+
// Only the read that still owns the slot settles the flag. One that a write, a clear or a
|
|
86
|
+
// newer read replaced must not report the projection as settled.
|
|
87
|
+
if (inFlight === entry) {
|
|
88
|
+
inFlight = null
|
|
89
|
+
loading.value = false
|
|
90
|
+
}
|
|
91
|
+
}),
|
|
92
|
+
}
|
|
93
|
+
inFlight = entry
|
|
94
|
+
return entry.promise
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function markWritten(): void {
|
|
98
|
+
generation++
|
|
99
|
+
inFlight = null
|
|
100
|
+
loading.value = false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
load: () => {
|
|
105
|
+
const key = currentKey()
|
|
106
|
+
return key ? start(key) : Promise.resolve()
|
|
107
|
+
},
|
|
108
|
+
refresh: () => {
|
|
109
|
+
const key = currentKey()
|
|
110
|
+
if (!key) return Promise.resolve()
|
|
111
|
+
return inFlight?.key === key ? inFlight.promise : start(key)
|
|
112
|
+
},
|
|
113
|
+
ensureLoaded: () => {
|
|
114
|
+
const key = currentKey()
|
|
115
|
+
if (!key || loadedKey === key) return Promise.resolve()
|
|
116
|
+
return inFlight?.key === key ? inFlight.promise : start(key)
|
|
117
|
+
},
|
|
118
|
+
markWritten,
|
|
119
|
+
clearProjection: () => {
|
|
120
|
+
markWritten()
|
|
121
|
+
loadedKey = null
|
|
122
|
+
repos.value = []
|
|
123
|
+
pulls.value = []
|
|
124
|
+
issues.value = []
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -16,7 +16,7 @@ import { pullKey, type GitHubStoreContext } from './context'
|
|
|
16
16
|
*/
|
|
17
17
|
export function createGitHubRepoActions(ctx: GitHubStoreContext) {
|
|
18
18
|
const { api, workspace, connected, repos, availableRepos, loadingAvailable, savingRepos } = ctx
|
|
19
|
-
const { branches, repoFiles, upsertPull, getPull, load } = ctx
|
|
19
|
+
const { branches, repoFiles, upsertPull, getPull, load, markWritten } = ctx
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Load the repos the installation can access, with this workspace's link state.
|
|
@@ -55,6 +55,7 @@ export function createGitHubRepoActions(ctx: GitHubStoreContext) {
|
|
|
55
55
|
savingRepos.value = true
|
|
56
56
|
try {
|
|
57
57
|
repos.value = await api.setGitHubLinkedRepos(workspace.requireId(), repoGithubIds)
|
|
58
|
+
markWritten()
|
|
58
59
|
// Reflect the new link state in the picker and refresh PRs/issues.
|
|
59
60
|
const linked = new Set(repoGithubIds)
|
|
60
61
|
availableRepos.value = availableRepos.value.map((r) => ({
|
|
@@ -117,6 +118,7 @@ export function createGitHubRepoActions(ctx: GitHubStoreContext) {
|
|
|
117
118
|
async function openPullRequest(repoGithubId: number, input: OpenPullRequestInput) {
|
|
118
119
|
const pr = await api.openGitHubPullRequest(workspace.requireId(), repoGithubId, input)
|
|
119
120
|
upsertPull(pr)
|
|
121
|
+
markWritten()
|
|
120
122
|
return pr
|
|
121
123
|
}
|
|
122
124
|
|
|
@@ -128,7 +130,10 @@ export function createGitHubRepoActions(ctx: GitHubStoreContext) {
|
|
|
128
130
|
await api.mergeGitHubPullRequest(workspace.requireId(), repoGithubId, number, input)
|
|
129
131
|
// Optimistically reflect the merge until the next sync confirms it.
|
|
130
132
|
const existing = getPull(pullKey(repoGithubId, number))
|
|
131
|
-
if (existing)
|
|
133
|
+
if (existing) {
|
|
134
|
+
upsertPull({ ...existing, state: 'closed', merged: true })
|
|
135
|
+
markWritten()
|
|
136
|
+
}
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
function comment(repoGithubId: number, number: number, body: string) {
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, vi, type Mock } from 'vitest'
|
|
2
2
|
import { useGitHubStore } from '~/stores/github'
|
|
3
3
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
GitHubConnection,
|
|
6
|
+
GitHubPatCheck,
|
|
7
|
+
GitHubPullRequest,
|
|
8
|
+
GitHubRepo,
|
|
9
|
+
VcsConnectOption,
|
|
10
|
+
} from '~/types/domain'
|
|
5
11
|
|
|
6
12
|
// The VCS connect surface of the (single, GitHub-shaped) repo store: which connect methods the
|
|
7
13
|
// deployment offers, the per-workspace GitLab PAT connect, and the provider-routed disconnect.
|
|
@@ -393,3 +399,351 @@ describe('github store — GitHub PAT credential check', () => {
|
|
|
393
399
|
await vi.waitFor(() => expect(getGitHubPatCheck).toHaveBeenCalledTimes(2))
|
|
394
400
|
})
|
|
395
401
|
})
|
|
402
|
+
|
|
403
|
+
// Two inspector panels call `ensureLoaded()` on the same frame selection. The probe is
|
|
404
|
+
// single-flighted, so both callers share it, but the projection read behind it must be shared too.
|
|
405
|
+
describe('github store: ensureLoaded', () => {
|
|
406
|
+
const repo: GitHubRepo = {
|
|
407
|
+
githubId: 1,
|
|
408
|
+
installationId: 42,
|
|
409
|
+
owner: 'acme',
|
|
410
|
+
name: 'api',
|
|
411
|
+
defaultBranch: 'main',
|
|
412
|
+
private: false,
|
|
413
|
+
syncedAt: 0,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
it('loads the projection once when two callers ask at the same time', async () => {
|
|
417
|
+
// One linked repo, so the second caller cannot load again only because the list is empty.
|
|
418
|
+
const api = stubApi({
|
|
419
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
420
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
421
|
+
listGitHubRepos: vi.fn().mockResolvedValue([repo]),
|
|
422
|
+
})
|
|
423
|
+
const github = storeWithWorkspace()
|
|
424
|
+
|
|
425
|
+
await Promise.all([github.ensureLoaded(), github.ensureLoaded()])
|
|
426
|
+
|
|
427
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(1)
|
|
428
|
+
expect(api.listGitHubPullRequests).toHaveBeenCalledTimes(1)
|
|
429
|
+
expect(api.listGitHubIssues).toHaveBeenCalledTimes(1)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
// A connected workspace with no linked repos counts as loaded: the empty list is its answer, so
|
|
433
|
+
// a later call does not read it again.
|
|
434
|
+
it('does not load again for a connected workspace with no repos', async () => {
|
|
435
|
+
const api = stubApi({
|
|
436
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
437
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
438
|
+
listGitHubRepos: vi.fn().mockResolvedValue([]),
|
|
439
|
+
})
|
|
440
|
+
const github = storeWithWorkspace()
|
|
441
|
+
|
|
442
|
+
await github.ensureLoaded()
|
|
443
|
+
await github.ensureLoaded()
|
|
444
|
+
|
|
445
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(1)
|
|
446
|
+
expect(api.listGitHubPullRequests).toHaveBeenCalledTimes(1)
|
|
447
|
+
expect(api.listGitHubIssues).toHaveBeenCalledTimes(1)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
// A board switch resets the store, and a switch back lands on the same workspace id. The
|
|
451
|
+
// cleared projection must be read again, even though that id was loaded before.
|
|
452
|
+
it('loads again after a reset clears the projection', async () => {
|
|
453
|
+
const api = stubApi({
|
|
454
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
455
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
456
|
+
listGitHubRepos: vi.fn().mockResolvedValue([repo]),
|
|
457
|
+
})
|
|
458
|
+
const github = storeWithWorkspace()
|
|
459
|
+
|
|
460
|
+
await github.ensureLoaded()
|
|
461
|
+
github.reset()
|
|
462
|
+
await github.ensureLoaded()
|
|
463
|
+
|
|
464
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
465
|
+
expect(github.repos).toEqual([repo])
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('loads again after a load that failed', async () => {
|
|
469
|
+
const api = stubApi({
|
|
470
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
471
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
472
|
+
listGitHubRepos: vi.fn().mockRejectedValueOnce(new Error('500')).mockResolvedValue([repo]),
|
|
473
|
+
})
|
|
474
|
+
const github = storeWithWorkspace()
|
|
475
|
+
|
|
476
|
+
await expect(github.ensureLoaded()).rejects.toThrow('500')
|
|
477
|
+
await github.ensureLoaded()
|
|
478
|
+
|
|
479
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
480
|
+
expect(github.repos).toEqual([repo])
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
// `load()` is how every write refreshes the projection. A write that lands while an
|
|
484
|
+
// `ensureLoaded()` read is in flight must not get that older read back.
|
|
485
|
+
it('reads again on load() while an ensureLoaded() read is in flight', async () => {
|
|
486
|
+
const api = stubApi({
|
|
487
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
488
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
489
|
+
})
|
|
490
|
+
const github = storeWithWorkspace()
|
|
491
|
+
await github.probe()
|
|
492
|
+
|
|
493
|
+
const ensured = github.ensureLoaded()
|
|
494
|
+
const reloaded = github.load()
|
|
495
|
+
await Promise.all([ensured, reloaded])
|
|
496
|
+
|
|
497
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
// A disconnect clears the projection outside a load. A probe that finds the workspace connected
|
|
501
|
+
// again (an App installed from another tab) must load it, not trust the earlier load.
|
|
502
|
+
it('loads again after a disconnect and a later probe that finds a connection', async () => {
|
|
503
|
+
const api = stubApi({
|
|
504
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
505
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
506
|
+
listGitHubRepos: vi.fn().mockResolvedValue([repo]),
|
|
507
|
+
disconnectGitHub: vi.fn().mockResolvedValue(undefined),
|
|
508
|
+
})
|
|
509
|
+
const github = storeWithWorkspace()
|
|
510
|
+
|
|
511
|
+
await github.ensureLoaded()
|
|
512
|
+
await github.disconnect()
|
|
513
|
+
await github.probe()
|
|
514
|
+
await github.ensureLoaded()
|
|
515
|
+
|
|
516
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
517
|
+
expect(github.repos).toEqual([repo])
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
// The reads behind `ensureLoaded()` and a later `load()` overlap, and the older one can finish
|
|
521
|
+
// last. Its result is older than what is already shown, so it does not replace it.
|
|
522
|
+
it('keeps the newer read when an older one finishes after it', async () => {
|
|
523
|
+
let finishOlderRead: (repos: GitHubRepo[]) => void = () => {}
|
|
524
|
+
const newer: GitHubRepo = { ...repo, githubId: 2, name: 'web' }
|
|
525
|
+
stubApi({
|
|
526
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
527
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
528
|
+
listGitHubRepos: vi
|
|
529
|
+
.fn()
|
|
530
|
+
.mockReturnValueOnce(
|
|
531
|
+
new Promise<GitHubRepo[]>((resolve) => {
|
|
532
|
+
finishOlderRead = resolve
|
|
533
|
+
}),
|
|
534
|
+
)
|
|
535
|
+
.mockResolvedValueOnce([newer]),
|
|
536
|
+
})
|
|
537
|
+
const github = storeWithWorkspace()
|
|
538
|
+
await github.probe()
|
|
539
|
+
|
|
540
|
+
const ensured = github.ensureLoaded()
|
|
541
|
+
await github.load()
|
|
542
|
+
expect(github.repos).toEqual([newer])
|
|
543
|
+
// The older read is still running, but the newer one owns the flag and has finished.
|
|
544
|
+
expect(github.loading).toBe(false)
|
|
545
|
+
|
|
546
|
+
finishOlderRead([repo])
|
|
547
|
+
await ensured
|
|
548
|
+
|
|
549
|
+
expect(github.repos).toEqual([newer])
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it('drops a read that finishes after a switch to another board', async () => {
|
|
553
|
+
let finishRead: (repos: GitHubRepo[]) => void = () => {}
|
|
554
|
+
stubApi({
|
|
555
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
556
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
557
|
+
listGitHubRepos: vi.fn().mockReturnValueOnce(
|
|
558
|
+
new Promise<GitHubRepo[]>((resolve) => {
|
|
559
|
+
finishRead = resolve
|
|
560
|
+
}),
|
|
561
|
+
),
|
|
562
|
+
})
|
|
563
|
+
const github = storeWithWorkspace()
|
|
564
|
+
await github.probe()
|
|
565
|
+
|
|
566
|
+
const ensured = github.ensureLoaded()
|
|
567
|
+
useWorkspaceStore().workspaceId = 'ws-2'
|
|
568
|
+
github.reset()
|
|
569
|
+
finishRead([repo])
|
|
570
|
+
await ensured
|
|
571
|
+
|
|
572
|
+
expect(github.repos).toEqual([])
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
// In the usual order the older read finishes first. A newer read has started by then, so the older
|
|
576
|
+
// result is dropped, and the projection is not settled until the newer read finishes.
|
|
577
|
+
it('stays loading until the newest read finishes', async () => {
|
|
578
|
+
const finish: Array<(repos: GitHubRepo[]) => void> = []
|
|
579
|
+
const newer: GitHubRepo = { ...repo, githubId: 2, name: 'web' }
|
|
580
|
+
stubApi({
|
|
581
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
582
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
583
|
+
listGitHubRepos: vi.fn(
|
|
584
|
+
() =>
|
|
585
|
+
new Promise<GitHubRepo[]>((resolve) => {
|
|
586
|
+
finish.push(resolve)
|
|
587
|
+
}),
|
|
588
|
+
),
|
|
589
|
+
})
|
|
590
|
+
const github = storeWithWorkspace()
|
|
591
|
+
await github.probe()
|
|
592
|
+
|
|
593
|
+
const ensured = github.ensureLoaded()
|
|
594
|
+
const reloaded = github.load()
|
|
595
|
+
finish[0]?.([repo])
|
|
596
|
+
await ensured
|
|
597
|
+
|
|
598
|
+
expect(github.repos).toEqual([])
|
|
599
|
+
expect(github.loading).toBe(true)
|
|
600
|
+
|
|
601
|
+
finish[1]?.([newer])
|
|
602
|
+
await reloaded
|
|
603
|
+
|
|
604
|
+
expect(github.repos).toEqual([newer])
|
|
605
|
+
expect(github.loading).toBe(false)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
// A disconnect clears the projection in the same workspace, so the board id cannot tell the
|
|
609
|
+
// read apart. A read that started before the disconnect must not put the old rows back.
|
|
610
|
+
it('drops a read that finishes after a disconnect', async () => {
|
|
611
|
+
let finishRead: (repos: GitHubRepo[]) => void = () => {}
|
|
612
|
+
stubApi({
|
|
613
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
614
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
615
|
+
listGitHubRepos: vi.fn().mockReturnValueOnce(
|
|
616
|
+
new Promise<GitHubRepo[]>((resolve) => {
|
|
617
|
+
finishRead = resolve
|
|
618
|
+
}),
|
|
619
|
+
),
|
|
620
|
+
disconnectGitHub: vi.fn().mockResolvedValue(undefined),
|
|
621
|
+
})
|
|
622
|
+
const github = storeWithWorkspace()
|
|
623
|
+
await github.probe()
|
|
624
|
+
|
|
625
|
+
const ensured = github.ensureLoaded()
|
|
626
|
+
await github.disconnect()
|
|
627
|
+
// Nothing is left to wait for, so no spinner stays up for a read whose result is thrown away.
|
|
628
|
+
expect(github.loading).toBe(false)
|
|
629
|
+
finishRead([repo])
|
|
630
|
+
await ensured
|
|
631
|
+
|
|
632
|
+
expect(github.repos).toEqual([])
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
// `load()` reads nothing while the workspace is not connected, so it must not record the
|
|
636
|
+
// projection as loaded. A later probe that finds a connection must lead to a read.
|
|
637
|
+
it('loads after a connection appears when load() ran while not connected', async () => {
|
|
638
|
+
const api = stubApi({
|
|
639
|
+
getGitHubConnection: vi
|
|
640
|
+
.fn()
|
|
641
|
+
.mockResolvedValueOnce({ connection: null })
|
|
642
|
+
.mockResolvedValue({ connection: connection() }),
|
|
643
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
644
|
+
listGitHubRepos: vi.fn().mockResolvedValue([repo]),
|
|
645
|
+
})
|
|
646
|
+
const github = storeWithWorkspace()
|
|
647
|
+
await github.probe()
|
|
648
|
+
|
|
649
|
+
await github.load()
|
|
650
|
+
await github.probe()
|
|
651
|
+
await github.ensureLoaded()
|
|
652
|
+
|
|
653
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(1)
|
|
654
|
+
expect(github.repos).toEqual([repo])
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
// The App is uninstalled and installed again from another tab. The probe sees the new
|
|
658
|
+
// connection, and the earlier load belongs to the old one.
|
|
659
|
+
it('loads again when the probe finds a different connection', async () => {
|
|
660
|
+
const getGitHubConnection = vi
|
|
661
|
+
.fn()
|
|
662
|
+
.mockResolvedValueOnce({ connection: connection() })
|
|
663
|
+
.mockResolvedValueOnce({ connection: null })
|
|
664
|
+
.mockResolvedValue({ connection: connection({ installationId: 43, connectedAt: 2 }) })
|
|
665
|
+
const api = stubApi({
|
|
666
|
+
getGitHubConnection,
|
|
667
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
668
|
+
listGitHubRepos: vi.fn().mockResolvedValue([repo]),
|
|
669
|
+
})
|
|
670
|
+
const github = storeWithWorkspace()
|
|
671
|
+
|
|
672
|
+
await github.ensureLoaded()
|
|
673
|
+
await github.probe()
|
|
674
|
+
await github.probe()
|
|
675
|
+
await github.ensureLoaded()
|
|
676
|
+
|
|
677
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
// `refresh()` is the plain re-read for a surface opening while another read runs. It shares that
|
|
681
|
+
// read, where `load()` is the after-write door and starts its own.
|
|
682
|
+
it('shares an in-flight read on refresh() and starts a new one on load()', async () => {
|
|
683
|
+
const api = stubApi({
|
|
684
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
685
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
686
|
+
})
|
|
687
|
+
const github = storeWithWorkspace()
|
|
688
|
+
await github.probe()
|
|
689
|
+
|
|
690
|
+
await Promise.all([github.ensureLoaded(), github.refresh()])
|
|
691
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(1)
|
|
692
|
+
|
|
693
|
+
await Promise.all([github.ensureLoaded(), github.load()])
|
|
694
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
// Saving the linked repos sets the projection directly. A read that started before the save
|
|
698
|
+
// must not put the earlier rows back, even if the reload that follows the save fails.
|
|
699
|
+
it('keeps the saved repos when a read that started before the save finishes afterwards', async () => {
|
|
700
|
+
let finishRead: (repos: GitHubRepo[]) => void = () => {}
|
|
701
|
+
const saved: GitHubRepo = { ...repo, githubId: 2, name: 'web' }
|
|
702
|
+
stubApi({
|
|
703
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
704
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
705
|
+
listGitHubRepos: vi
|
|
706
|
+
.fn()
|
|
707
|
+
.mockReturnValueOnce(
|
|
708
|
+
new Promise<GitHubRepo[]>((resolve) => {
|
|
709
|
+
finishRead = resolve
|
|
710
|
+
}),
|
|
711
|
+
)
|
|
712
|
+
.mockRejectedValueOnce(new Error('500')),
|
|
713
|
+
setGitHubLinkedRepos: vi.fn().mockResolvedValue([saved]),
|
|
714
|
+
})
|
|
715
|
+
const github = storeWithWorkspace()
|
|
716
|
+
await github.probe()
|
|
717
|
+
|
|
718
|
+
const ensured = github.ensureLoaded()
|
|
719
|
+
await expect(github.setLinkedRepos([2])).rejects.toThrow('500')
|
|
720
|
+
finishRead([repo])
|
|
721
|
+
await ensured
|
|
722
|
+
|
|
723
|
+
expect(github.repos).toEqual([saved])
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('keeps a pull request opened while a read was in flight', async () => {
|
|
727
|
+
let finishPulls: (pulls: GitHubPullRequest[]) => void = () => {}
|
|
728
|
+
const opened = { repoGithubId: 1, number: 7 } as GitHubPullRequest
|
|
729
|
+
stubApi({
|
|
730
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
731
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
732
|
+
listGitHubPullRequests: vi.fn().mockReturnValueOnce(
|
|
733
|
+
new Promise<GitHubPullRequest[]>((resolve) => {
|
|
734
|
+
finishPulls = resolve
|
|
735
|
+
}),
|
|
736
|
+
),
|
|
737
|
+
openGitHubPullRequest: vi.fn().mockResolvedValue(opened),
|
|
738
|
+
})
|
|
739
|
+
const github = storeWithWorkspace()
|
|
740
|
+
await github.probe()
|
|
741
|
+
|
|
742
|
+
const ensured = github.ensureLoaded()
|
|
743
|
+
await github.openPullRequest(1, {} as never)
|
|
744
|
+
finishPulls([])
|
|
745
|
+
await ensured
|
|
746
|
+
|
|
747
|
+
expect(github.pulls).toEqual([opened])
|
|
748
|
+
})
|
|
749
|
+
})
|