@cat-factory/app 0.259.3 → 0.261.0
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 +34 -0
- package/app/components/auth/LoginScreen.vue +4 -3
- package/app/components/board/BoardCanvas.vue +9 -1
- package/app/components/board/TaskDependencyEdges.vue +35 -26
- package/app/components/github/AddServiceFromRepoModal.vue +132 -29
- package/app/components/settings/McpAuthorizeScreen.vue +262 -0
- package/app/composables/api/mcpAuthorization.ts +30 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useBoardActivity.ts +111 -0
- package/app/composables/useSettlingRaf.ts +32 -0
- package/app/composables/useTaskExpansion.ts +29 -11
- package/app/pages/mcp-authorize.vue +7 -0
- package/app/stores/auth/session.ts +14 -2
- package/app/stores/board/placement.ts +12 -3
- package/app/stores/board.spec.ts +22 -2
- package/app/utils/edgeSegments.spec.ts +49 -0
- package/app/utils/edgeSegments.ts +49 -0
- package/app/utils/monorepoImport.spec.ts +120 -0
- package/app/utils/monorepoImport.ts +154 -0
- package/app/utils/postSignIn.spec.ts +29 -0
- package/app/utils/postSignIn.ts +29 -0
- package/app/utils/settlingLoop.spec.ts +236 -0
- package/app/utils/settlingLoop.ts +101 -0
- package/i18n/locales/de.json +45 -1
- package/i18n/locales/en.json +45 -1
- package/i18n/locales/es.json +45 -1
- package/i18n/locales/fr.json +45 -1
- package/i18n/locales/he.json +45 -1
- package/i18n/locales/it.json +45 -1
- package/i18n/locales/ja.json +45 -1
- package/i18n/locales/pl.json +45 -1
- package/i18n/locales/tr.json +45 -1
- package/i18n/locales/uk.json +45 -1
- package/package.json +2 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { shallowRef } from 'vue'
|
|
3
|
+
import { commitSegments, sameSegments, type EdgeSegment } from './edgeSegments'
|
|
4
|
+
|
|
5
|
+
const line = (over: Partial<EdgeSegment> = {}): EdgeSegment => ({
|
|
6
|
+
id: 'a__b',
|
|
7
|
+
x1: 0,
|
|
8
|
+
y1: 0,
|
|
9
|
+
x2: 10,
|
|
10
|
+
y2: 10,
|
|
11
|
+
...over,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('sameSegments', () => {
|
|
15
|
+
it('accepts a freshly measured list that resolved to the same overlay', () => {
|
|
16
|
+
expect(sameSegments([line()], [line()])).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('rejects a moved endpoint, however slightly', () => {
|
|
20
|
+
expect(sameSegments([line()], [line({ y2: 10.5 })])).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('rejects a changed link set', () => {
|
|
24
|
+
expect(sameSegments([line()], [])).toBe(false)
|
|
25
|
+
expect(sameSegments([line()], [line({ id: 'a__c' })])).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('rejects a dependency whose source finished, since it restyles the arrow', () => {
|
|
29
|
+
expect(sameSegments([line({ done: false })], [line({ done: true })])).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('commitSegments', () => {
|
|
34
|
+
it('publishes a changed list and reports it', () => {
|
|
35
|
+
const target = shallowRef<EdgeSegment[]>([line()])
|
|
36
|
+
const next = [line({ x2: 20 })]
|
|
37
|
+
expect(commitSegments(target, next)).toBe(true)
|
|
38
|
+
expect(target.value).toBe(next)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('leaves the published array untouched when nothing moved', () => {
|
|
42
|
+
const published = [line()]
|
|
43
|
+
const target = shallowRef<EdgeSegment[]>(published)
|
|
44
|
+
// Identity has to survive, not just the values: reassigning an equal-but-new array is
|
|
45
|
+
// what re-rendered the whole overlay on every frame of an idle board.
|
|
46
|
+
expect(commitSegments(target, [line()])).toBe(false)
|
|
47
|
+
expect(target.value).toBe(published)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One drawable link on the board's screen-space overlay: a border-to-border line between two
|
|
5
|
+
* block cards. `done` rides only on dependency edges, where it picks the stroke and arrowhead.
|
|
6
|
+
*/
|
|
7
|
+
export type EdgeSegment = {
|
|
8
|
+
id: string
|
|
9
|
+
x1: number
|
|
10
|
+
y1: number
|
|
11
|
+
x2: number
|
|
12
|
+
y2: number
|
|
13
|
+
done?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Whether two resolved segment lists would draw exactly the same overlay. */
|
|
17
|
+
export function sameSegments(a: readonly EdgeSegment[], b: readonly EdgeSegment[]): boolean {
|
|
18
|
+
if (a.length !== b.length) return false
|
|
19
|
+
for (let i = 0; i < a.length; i++) {
|
|
20
|
+
const left = a[i]!
|
|
21
|
+
const right = b[i]!
|
|
22
|
+
if (
|
|
23
|
+
left.id !== right.id ||
|
|
24
|
+
left.x1 !== right.x1 ||
|
|
25
|
+
left.y1 !== right.y1 ||
|
|
26
|
+
left.x2 !== right.x2 ||
|
|
27
|
+
left.y2 !== right.y2 ||
|
|
28
|
+
left.done !== right.done
|
|
29
|
+
) {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Publish a freshly measured list, and report whether it moved anything. Writing an
|
|
38
|
+
* equal-but-new array every frame is what re-rendered the whole overlay 60 times a second on
|
|
39
|
+
* a board where nothing was moving, and it is also the signal the settling frame loop reads
|
|
40
|
+
* to decide it can park.
|
|
41
|
+
*
|
|
42
|
+
* The target is a `shallowRef`: the lists are replaced wholesale, so deep-proxying every
|
|
43
|
+
* segment object would be pure overhead.
|
|
44
|
+
*/
|
|
45
|
+
export function commitSegments(target: Ref<EdgeSegment[]>, next: EdgeSegment[]): boolean {
|
|
46
|
+
if (sameSegments(target.value, next)) return false
|
|
47
|
+
target.value = next
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { CreatedMonorepoFrame } from './monorepoImport'
|
|
3
|
+
import {
|
|
4
|
+
canDesignateFrontend,
|
|
5
|
+
planFrontendConfigPatches,
|
|
6
|
+
planMonorepoImport,
|
|
7
|
+
} from './monorepoImport'
|
|
8
|
+
|
|
9
|
+
/** Pair a plan with block ids, the way the modal does as each create call returns. */
|
|
10
|
+
function created(entries: ReturnType<typeof planMonorepoImport>): CreatedMonorepoFrame[] {
|
|
11
|
+
return entries.map((entry, i) => ({ blockId: `blk_${i}`, entry }))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('canDesignateFrontend', () => {
|
|
15
|
+
it('offers the mark for two or more backend services', () => {
|
|
16
|
+
expect(canDesignateFrontend('service', 2)).toBe(true)
|
|
17
|
+
expect(canDesignateFrontend('service', 5)).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('withholds it below two directories: there is no rest to bind to', () => {
|
|
21
|
+
expect(canDesignateFrontend('service', 1)).toBe(false)
|
|
22
|
+
expect(canDesignateFrontend('service', 0)).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('withholds it for roles a backend binding cannot point at', () => {
|
|
26
|
+
// A binding's `service` source names a `service` frame; a library/document frame is not one,
|
|
27
|
+
// and when everything is already a frontend the mark divides nothing.
|
|
28
|
+
expect(canDesignateFrontend('library', 3)).toBe(false)
|
|
29
|
+
expect(canDesignateFrontend('document', 3)).toBe(false)
|
|
30
|
+
expect(canDesignateFrontend('frontend', 3)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('planMonorepoImport', () => {
|
|
35
|
+
it('creates the marked directory as a frontend and the rest with the picked role', () => {
|
|
36
|
+
expect(
|
|
37
|
+
planMonorepoImport(['apps/web', 'services/api', 'services/auth'], 'service', 'apps/web'),
|
|
38
|
+
).toEqual([
|
|
39
|
+
{ directory: 'apps/web', type: 'frontend', designatedFrontend: true },
|
|
40
|
+
{ directory: 'services/api', type: 'service', designatedFrontend: false },
|
|
41
|
+
{ directory: 'services/auth', type: 'service', designatedFrontend: false },
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('keeps the picked order, wherever the marked directory sits in it', () => {
|
|
46
|
+
const plan = planMonorepoImport(['services/api', 'apps/web'], 'service', 'apps/web')
|
|
47
|
+
expect(plan.map((e) => e.directory)).toEqual(['services/api', 'apps/web'])
|
|
48
|
+
expect(plan[1]?.type).toBe('frontend')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('gives every directory the picked role when nothing is marked', () => {
|
|
52
|
+
expect(planMonorepoImport(['a', 'b'], 'service', undefined)).toEqual([
|
|
53
|
+
{ directory: 'a', type: 'service', designatedFrontend: false },
|
|
54
|
+
{ directory: 'b', type: 'service', designatedFrontend: false },
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('ignores a mark on a directory that is not being created', () => {
|
|
59
|
+
// The cart entry was removed (or was already on the board and got filtered out) after being
|
|
60
|
+
// marked. Designating nothing is right; promoting some other frame to frontend would not be.
|
|
61
|
+
expect(planMonorepoImport(['a', 'b'], 'service', 'apps/web')).toEqual([
|
|
62
|
+
{ directory: 'a', type: 'service', designatedFrontend: false },
|
|
63
|
+
{ directory: 'b', type: 'service', designatedFrontend: false },
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('designates nobody when the whole cart is imported under the frontend role', () => {
|
|
68
|
+
// Every entry is `type: 'frontend'` here, so the flag is the ONLY thing that separates
|
|
69
|
+
// "the app the others talk to" from "a cart of frontends". A mark is never on offer for this
|
|
70
|
+
// role (`canDesignateFrontend`), and one carried over from a role change must not act.
|
|
71
|
+
const plan = planMonorepoImport(['apps/web', 'apps/admin'], 'frontend', 'apps/web')
|
|
72
|
+
expect(plan.every((e) => e.type === 'frontend')).toBe(true)
|
|
73
|
+
expect(plan.some((e) => e.designatedFrontend)).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('planFrontendConfigPatches', () => {
|
|
78
|
+
it('binds the designated frontend to every other frame created beside it', () => {
|
|
79
|
+
const plan = planMonorepoImport(
|
|
80
|
+
['apps/web', 'services/api', 'services/auth'],
|
|
81
|
+
'service',
|
|
82
|
+
'apps/web',
|
|
83
|
+
)
|
|
84
|
+
expect(planFrontendConfigPatches(created(plan))).toEqual([
|
|
85
|
+
{
|
|
86
|
+
blockId: 'blk_0',
|
|
87
|
+
config: {
|
|
88
|
+
directory: 'apps/web',
|
|
89
|
+
backendBindings: [
|
|
90
|
+
{ envVar: '', source: { kind: 'service', serviceBlockId: 'blk_1' } },
|
|
91
|
+
{ envVar: '', source: { kind: 'service', serviceBlockId: 'blk_2' } },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('leaves every env var name empty rather than inventing one', () => {
|
|
99
|
+
const plan = planMonorepoImport(['apps/web', 'services/api'], 'service', 'apps/web')
|
|
100
|
+
const [patch] = planFrontendConfigPatches(created(plan))
|
|
101
|
+
expect(patch?.config.backendBindings.every((b) => b.envVar === '')).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('carries the subdirectory to every frontend frame, designated or not', () => {
|
|
105
|
+
// `frontendConfig.directory` is what the harness's install/build/serve reads; the service-level
|
|
106
|
+
// directory that scopes an agent's checkout is a different field and does not stand in. A cart
|
|
107
|
+
// imported under the `frontend` role has no designated frame, and every frame in it would
|
|
108
|
+
// otherwise build the repo root.
|
|
109
|
+
const plan = planMonorepoImport(['apps/web', 'apps/admin'], 'frontend', undefined)
|
|
110
|
+
expect(planFrontendConfigPatches(created(plan))).toEqual([
|
|
111
|
+
{ blockId: 'blk_0', config: { directory: 'apps/web', backendBindings: [] } },
|
|
112
|
+
{ blockId: 'blk_1', config: { directory: 'apps/admin', backendBindings: [] } },
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('patches nothing when the import creates no frontend frame', () => {
|
|
117
|
+
const plan = planMonorepoImport(['services/api', 'services/auth'], 'service', undefined)
|
|
118
|
+
expect(planFrontendConfigPatches(created(plan))).toEqual([])
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { FrameRepoType, FrontendBackendBinding, FrontendConfig } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Planning rules for a monorepo import: turning the picked subdirectories into the frames to
|
|
5
|
+
* create, and the `frontendConfig` patches those frames owe once they have ids.
|
|
6
|
+
*
|
|
7
|
+
* The relationship model already exists: a `frontend` frame's `backendBindings` ARE the
|
|
8
|
+
* frontend→service board link (`@cat-factory/contracts`'s `frontendBackendBindingSchema`), the
|
|
9
|
+
* frontend counterpart of a service frame's `serviceConnections`. All this adds is declaring it
|
|
10
|
+
* at IMPORT time, while the user still has the whole selection in front of them, instead of
|
|
11
|
+
* making them open the frontend's inspector afterwards and re-pick services one row at a time.
|
|
12
|
+
*
|
|
13
|
+
* Pure and frontend-only: the modal is the sole caller, so these rules stay here rather than in
|
|
14
|
+
* contracts (nothing on the backend has to agree about them, since it sees ordinary frame creates
|
|
15
|
+
* and ordinary `frontendConfig` patches).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether the import may offer a frontend mark for the directories it is about to create.
|
|
20
|
+
*
|
|
21
|
+
* Two conditions, both about the mark meaning something:
|
|
22
|
+
*
|
|
23
|
+
* - The picked role is `service`. A backend binding may only point at a `service` frame, so
|
|
24
|
+
* marking a frontend among libraries or document repos would wire nothing, and when the role
|
|
25
|
+
* already IS `frontend`, every frame is one and singling one out says nothing.
|
|
26
|
+
* - At least two directories are being CREATED. The mark divides that set into "the frontend" and
|
|
27
|
+
* "the backends it talks to"; with one there is no rest to bind to, and the role select above
|
|
28
|
+
* already covers importing a lone frontend. The count is of what the add will actually create,
|
|
29
|
+
* never of the raw cart: a cart entry whose frame already exists (a retry after a partial
|
|
30
|
+
* failure) is filtered out of the import, so counting it would offer a mark that binds nothing.
|
|
31
|
+
*/
|
|
32
|
+
export function canDesignateFrontend(type: FrameRepoType, directoryCount: number): boolean {
|
|
33
|
+
return type === 'service' && directoryCount >= 2
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One frame the import will create: its repo subdirectory and the role it takes. */
|
|
37
|
+
export interface MonorepoImportEntry {
|
|
38
|
+
/** Repo-root-relative subdirectory the frame is pinned to. */
|
|
39
|
+
directory: string
|
|
40
|
+
/** The frame's repo role: `frontend` for the marked directory, the picked role for the rest. */
|
|
41
|
+
type: FrameRepoType
|
|
42
|
+
/**
|
|
43
|
+
* Whether this is the ONE directory marked as the frontend for the others, so its config binds
|
|
44
|
+
* every other frame the import creates.
|
|
45
|
+
*
|
|
46
|
+
* Stated here rather than left to be re-derived from `type`, because `type === 'frontend'` does
|
|
47
|
+
* NOT identify it: when the picked ROLE is `frontend`, every entry carries that type and no mark
|
|
48
|
+
* was ever on offer (see {@link canDesignateFrontend}), so a type test picks an arbitrary frame
|
|
49
|
+
* and binds it to a backend set that does not exist.
|
|
50
|
+
*/
|
|
51
|
+
designatedFrontend: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The frames to create for a monorepo import, in the order the user picked them.
|
|
56
|
+
*
|
|
57
|
+
* A `frontendDirectory` is honoured only when the mark would MEAN something, and this is the one
|
|
58
|
+
* place that decides. Two ways it can be stale, both of which designate nothing rather than
|
|
59
|
+
* promoting some other frame:
|
|
60
|
+
*
|
|
61
|
+
* - The role or the size of the import no longer admits a mark ({@link canDesignateFrontend}), so
|
|
62
|
+
* the picker is not even on screen. Enforced here rather than trusted to the caller, because a
|
|
63
|
+
* caller that re-derives the condition can drift from it.
|
|
64
|
+
* - The marked directory is not among the ones being created: it was removed from the cart, or an
|
|
65
|
+
* earlier add already created it.
|
|
66
|
+
*/
|
|
67
|
+
export function planMonorepoImport(
|
|
68
|
+
directories: readonly string[],
|
|
69
|
+
backendType: FrameRepoType,
|
|
70
|
+
frontendDirectory: string | undefined,
|
|
71
|
+
): MonorepoImportEntry[] {
|
|
72
|
+
const marked =
|
|
73
|
+
canDesignateFrontend(backendType, directories.length) && frontendDirectory !== undefined
|
|
74
|
+
const frontend = marked && directories.includes(frontendDirectory) ? frontendDirectory : undefined
|
|
75
|
+
return directories.map((directory) => ({
|
|
76
|
+
directory,
|
|
77
|
+
type: directory === frontend ? 'frontend' : backendType,
|
|
78
|
+
designatedFrontend: directory === frontend,
|
|
79
|
+
}))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A frame the import created, paired with the plan entry it was created from. */
|
|
83
|
+
export interface CreatedMonorepoFrame {
|
|
84
|
+
/** Id of the block the create call minted. */
|
|
85
|
+
blockId: string
|
|
86
|
+
/** The entry that produced it, carrying its role and whether it is the designated frontend. */
|
|
87
|
+
entry: MonorepoImportEntry
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One `frontendConfig` write the import owes a frame it created. */
|
|
91
|
+
export interface FrontendConfigPatch {
|
|
92
|
+
blockId: string
|
|
93
|
+
config: FrontendConfig
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The `frontendConfig` each created `frontend` frame needs, once the create calls have minted
|
|
98
|
+
* their ids.
|
|
99
|
+
*
|
|
100
|
+
* EVERY frontend frame gets one, not only a designated one, because the patch carries two
|
|
101
|
+
* separable facts:
|
|
102
|
+
*
|
|
103
|
+
* - **`directory`, for all of them.** A frame's monorepo subdirectory is a SERVICE-level fact (the
|
|
104
|
+
* repo projection's `directory`, which scopes an agent's checkout); the harness's frontend
|
|
105
|
+
* install/build/serve reads `frontendConfig.directory` instead, and defaults to the repo root
|
|
106
|
+
* when it is absent. So a monorepo frontend whose config does not repeat it builds the wrong
|
|
107
|
+
* tree, silently. That is true of every frontend frame an import creates, including a whole cart
|
|
108
|
+
* imported under the `frontend` role, where no mark is ever offered. The user picked the
|
|
109
|
+
* directory, so it is copied, never guessed.
|
|
110
|
+
* - **`backendBindings`, for the designated one only.** The mark is what says "these other frames
|
|
111
|
+
* are the backends this app talks to"; an undesignated frontend frame is bound to nothing, which
|
|
112
|
+
* is the same empty list the inspector would have shown.
|
|
113
|
+
*/
|
|
114
|
+
export function planFrontendConfigPatches(
|
|
115
|
+
created: readonly CreatedMonorepoFrame[],
|
|
116
|
+
): FrontendConfigPatch[] {
|
|
117
|
+
const designated = created.find((frame) => frame.entry.designatedFrontend)
|
|
118
|
+
const backendBlockIds = designated
|
|
119
|
+
? created.filter((frame) => frame !== designated).map((frame) => frame.blockId)
|
|
120
|
+
: []
|
|
121
|
+
return created
|
|
122
|
+
.filter((frame) => frame.entry.type === 'frontend')
|
|
123
|
+
.map((frame) => ({
|
|
124
|
+
blockId: frame.blockId,
|
|
125
|
+
config: frontendConfigForImport(
|
|
126
|
+
frame.entry.directory,
|
|
127
|
+
frame === designated ? backendBlockIds : [],
|
|
128
|
+
),
|
|
129
|
+
}))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The `frontendConfig` for one created frontend frame: where the app lives in the repo, and one
|
|
134
|
+
* backend binding per frame it was marked the frontend for.
|
|
135
|
+
*
|
|
136
|
+
* **`envVar` is left EMPTY.** The env var a frontend reads for an upstream URL is a fact about the
|
|
137
|
+
* frontend's own source, which an import that never looks inside the repo cannot know, and
|
|
138
|
+
* inventing a plausible name (`PUB_API_URL`) would inject a variable nothing reads while looking
|
|
139
|
+
* configured. Empty is the contract's designed inert state: the job-body builder filters those
|
|
140
|
+
* bindings out of the injected env, `duplicateBindingEnvVars` ignores them, `frontendOriginsForService`
|
|
141
|
+
* skips them, and the board still draws the frontend→service edge. What the import DOES know (which
|
|
142
|
+
* services this frontend talks to) is recorded; the names are left for the inspector's "Detect from
|
|
143
|
+
* repo" (which reads the repo's dotenv examples) or the user, and the modal says so.
|
|
144
|
+
*/
|
|
145
|
+
function frontendConfigForImport(
|
|
146
|
+
directory: string,
|
|
147
|
+
backendBlockIds: readonly string[],
|
|
148
|
+
): FrontendConfig {
|
|
149
|
+
const backendBindings: FrontendBackendBinding[] = backendBlockIds.map((serviceBlockId) => ({
|
|
150
|
+
envVar: '',
|
|
151
|
+
source: { kind: 'service', serviceBlockId },
|
|
152
|
+
}))
|
|
153
|
+
return { directory, backendBindings }
|
|
154
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { postSignInUrl } from './postSignIn'
|
|
3
|
+
|
|
4
|
+
// The regression this exists for: every sign-in path reloaded to `location.pathname`, so a flow
|
|
5
|
+
// whose subject rides the query string lost it the moment a person signed in. The MCP consent
|
|
6
|
+
// screen is the one that fails hardest, and signing in first is the ordinary way a first connect
|
|
7
|
+
// goes, so the loss is on the common path rather than an edge of it.
|
|
8
|
+
|
|
9
|
+
describe('postSignInUrl', () => {
|
|
10
|
+
it('keeps the query string the destination needs', () => {
|
|
11
|
+
expect(postSignInUrl({ pathname: '/mcp-authorize', search: '?request=sealed-value' })).toBe(
|
|
12
|
+
'/mcp-authorize?request=sealed-value',
|
|
13
|
+
)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('drops the invite token, which the signup call already spent', () => {
|
|
17
|
+
// Not a matter of tidiness: a consumed invite left in the address bar is a token in every
|
|
18
|
+
// place a URL gets pasted, and it buys the reader nothing because it no longer works.
|
|
19
|
+
expect(postSignInUrl({ pathname: '/', search: '?invite=tok_1' })).toBe('/')
|
|
20
|
+
expect(postSignInUrl({ pathname: '/', search: '?invite=tok_1&ws=ws_9' })).toBe('/?ws=ws_9')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('answers a bare path unchanged, and drops a fragment', () => {
|
|
24
|
+
expect(postSignInUrl({ pathname: '/', search: '' })).toBe('/')
|
|
25
|
+
// The fragment is absent by construction: it is never read here, so a stale one would only
|
|
26
|
+
// scroll the freshly booted app to an anchor the previous screen owned.
|
|
27
|
+
expect(postSignInUrl({ pathname: '/boards', search: '?a=1' })).toBe('/boards?a=1')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the browser reloads to once a sign-in succeeds.
|
|
3
|
+
*
|
|
4
|
+
* Every sign-in path reloads rather than routing, because the app has to boot with the new session
|
|
5
|
+
* rather than patch itself around it. What it reloads TO is the question this answers, and the
|
|
6
|
+
* naive `location.pathname` gets it wrong: the login screen renders at whatever URL the person
|
|
7
|
+
* arrived at, so the query string belongs to the destination, not to the sign-in. Dropping it
|
|
8
|
+
* silently strands any flow that carries its subject there. The MCP consent screen is the case that
|
|
9
|
+
* bites hardest (`/mcp-authorize?request=<sealed>`): signing in first is the COMMON path for a
|
|
10
|
+
* first connect, and landing back with no `request` leaves a person looking at "this page was
|
|
11
|
+
* opened without an authorization request" with no way forward except restarting from the host.
|
|
12
|
+
*
|
|
13
|
+
* `invite` is the one parameter dropped, and it is dropped because it has already been SPENT: the
|
|
14
|
+
* signup call consumed it, so keeping it would leave a consumed token in the address bar and in
|
|
15
|
+
* every place a URL gets pasted. Everything else is the destination's business, not this module's,
|
|
16
|
+
* which is why the rule is a named exception rather than an allowlist nobody remembers to extend.
|
|
17
|
+
*/
|
|
18
|
+
const SPENT_PARAMS = ['invite']
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The post-sign-in URL for one location: its path, its query minus the spent parameters, and no
|
|
22
|
+
* fragment (nothing in this app puts state there, and a stale one would scroll to nowhere).
|
|
23
|
+
*/
|
|
24
|
+
export function postSignInUrl(location: { pathname: string; search: string }): string {
|
|
25
|
+
const params = new URLSearchParams(location.search)
|
|
26
|
+
for (const spent of SPENT_PARAMS) params.delete(spent)
|
|
27
|
+
const query = params.toString()
|
|
28
|
+
return query ? `${location.pathname}?${query}` : location.pathname
|
|
29
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createSettlingLoop, type FrameScheduler } from './settlingLoop'
|
|
3
|
+
|
|
4
|
+
/** A hand-driven frame clock: `flush()` runs exactly one scheduled frame. */
|
|
5
|
+
function fakeScheduler() {
|
|
6
|
+
let nextHandle = 1
|
|
7
|
+
const pending = new Map<number, () => void>()
|
|
8
|
+
const scheduler: FrameScheduler = {
|
|
9
|
+
schedule(run) {
|
|
10
|
+
const handle = nextHandle++
|
|
11
|
+
pending.set(handle, run)
|
|
12
|
+
return handle
|
|
13
|
+
},
|
|
14
|
+
cancel(handle) {
|
|
15
|
+
pending.delete(handle)
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
scheduler,
|
|
20
|
+
pending: () => pending.size,
|
|
21
|
+
/** Run every currently scheduled frame; frames they schedule wait for the next flush. */
|
|
22
|
+
flush() {
|
|
23
|
+
const due = [...pending.entries()]
|
|
24
|
+
pending.clear()
|
|
25
|
+
for (const [, run] of due) run()
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('createSettlingLoop', () => {
|
|
31
|
+
it('does not run until poked', () => {
|
|
32
|
+
const clock = fakeScheduler()
|
|
33
|
+
let frames = 0
|
|
34
|
+
const loop = createSettlingLoop({
|
|
35
|
+
compute: () => {
|
|
36
|
+
frames++
|
|
37
|
+
return false
|
|
38
|
+
},
|
|
39
|
+
scheduler: clock.scheduler,
|
|
40
|
+
settleFrames: 3,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(loop.awake()).toBe(false)
|
|
44
|
+
clock.flush()
|
|
45
|
+
expect(frames).toBe(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('keeps running while the output changes, and parks once it holds still', () => {
|
|
49
|
+
const clock = fakeScheduler()
|
|
50
|
+
let changed = true
|
|
51
|
+
let frames = 0
|
|
52
|
+
const loop = createSettlingLoop({
|
|
53
|
+
compute: () => {
|
|
54
|
+
frames++
|
|
55
|
+
return changed
|
|
56
|
+
},
|
|
57
|
+
scheduler: clock.scheduler,
|
|
58
|
+
settleFrames: 3,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
loop.poke()
|
|
62
|
+
for (let i = 0; i < 10; i++) clock.flush()
|
|
63
|
+
expect(frames).toBe(10)
|
|
64
|
+
expect(loop.awake()).toBe(true)
|
|
65
|
+
|
|
66
|
+
// The animation ends: three unchanged frames later the loop is parked and the frame
|
|
67
|
+
// count stops moving no matter how many times the clock ticks.
|
|
68
|
+
changed = false
|
|
69
|
+
clock.flush()
|
|
70
|
+
clock.flush()
|
|
71
|
+
expect(loop.awake()).toBe(true)
|
|
72
|
+
clock.flush()
|
|
73
|
+
expect(loop.awake()).toBe(false)
|
|
74
|
+
|
|
75
|
+
const settledAt = frames
|
|
76
|
+
for (let i = 0; i < 10; i++) clock.flush()
|
|
77
|
+
expect(frames).toBe(settledAt)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('runs the settle tail after a poke that changed nothing, then parks', () => {
|
|
81
|
+
const clock = fakeScheduler()
|
|
82
|
+
let frames = 0
|
|
83
|
+
const loop = createSettlingLoop({
|
|
84
|
+
compute: () => {
|
|
85
|
+
frames++
|
|
86
|
+
return false
|
|
87
|
+
},
|
|
88
|
+
scheduler: clock.scheduler,
|
|
89
|
+
settleFrames: 3,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// A signal fires one frame BEFORE the transition it starts produces geometry, so a wake
|
|
93
|
+
// that measures no change still owes the tail rather than parking immediately.
|
|
94
|
+
loop.poke()
|
|
95
|
+
clock.flush()
|
|
96
|
+
expect(loop.awake()).toBe(true)
|
|
97
|
+
clock.flush()
|
|
98
|
+
clock.flush()
|
|
99
|
+
expect(frames).toBe(3)
|
|
100
|
+
expect(loop.awake()).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('wakes a parked loop again on the next poke', () => {
|
|
104
|
+
const clock = fakeScheduler()
|
|
105
|
+
let frames = 0
|
|
106
|
+
const loop = createSettlingLoop({
|
|
107
|
+
compute: () => {
|
|
108
|
+
frames++
|
|
109
|
+
return false
|
|
110
|
+
},
|
|
111
|
+
scheduler: clock.scheduler,
|
|
112
|
+
settleFrames: 1,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
loop.poke()
|
|
116
|
+
clock.flush()
|
|
117
|
+
expect(loop.awake()).toBe(false)
|
|
118
|
+
|
|
119
|
+
loop.poke()
|
|
120
|
+
clock.flush()
|
|
121
|
+
expect(frames).toBe(2)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('restarts the countdown on a poke without scheduling a second frame', () => {
|
|
125
|
+
const clock = fakeScheduler()
|
|
126
|
+
let frames = 0
|
|
127
|
+
const loop = createSettlingLoop({
|
|
128
|
+
compute: () => {
|
|
129
|
+
frames++
|
|
130
|
+
return false
|
|
131
|
+
},
|
|
132
|
+
scheduler: clock.scheduler,
|
|
133
|
+
settleFrames: 2,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
loop.poke()
|
|
137
|
+
loop.poke()
|
|
138
|
+
loop.poke()
|
|
139
|
+
expect(clock.pending()).toBe(1)
|
|
140
|
+
clock.flush()
|
|
141
|
+
expect(frames).toBe(1)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('does not schedule a second frame when the compute itself pokes', () => {
|
|
145
|
+
const clock = fakeScheduler()
|
|
146
|
+
let frames = 0
|
|
147
|
+
// A compute that writes to a store can wake watchers that poke back synchronously. That
|
|
148
|
+
// must reset the countdown, not double the frame rate.
|
|
149
|
+
const loop = createSettlingLoop({
|
|
150
|
+
compute: () => {
|
|
151
|
+
frames++
|
|
152
|
+
loop.poke()
|
|
153
|
+
return false
|
|
154
|
+
},
|
|
155
|
+
scheduler: clock.scheduler,
|
|
156
|
+
settleFrames: 2,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
loop.poke()
|
|
160
|
+
for (let i = 0; i < 5; i++) {
|
|
161
|
+
expect(clock.pending()).toBe(1)
|
|
162
|
+
clock.flush()
|
|
163
|
+
}
|
|
164
|
+
expect(frames).toBe(5)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('parks a throwing compute so a later poke still wakes it', () => {
|
|
168
|
+
const clock = fakeScheduler()
|
|
169
|
+
let frames = 0
|
|
170
|
+
let broken = true
|
|
171
|
+
const loop = createSettlingLoop({
|
|
172
|
+
compute: () => {
|
|
173
|
+
frames++
|
|
174
|
+
if (broken) throw new Error('measured a card that just unmounted')
|
|
175
|
+
return false
|
|
176
|
+
},
|
|
177
|
+
scheduler: clock.scheduler,
|
|
178
|
+
settleFrames: 3,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// The frame the throw escaped from is already spent. Staying awake would leave the loop
|
|
182
|
+
// holding a stream it can never schedule on again, and every later poke a no-op.
|
|
183
|
+
loop.poke()
|
|
184
|
+
expect(() => clock.flush()).toThrow('measured a card that just unmounted')
|
|
185
|
+
expect(loop.awake()).toBe(false)
|
|
186
|
+
expect(clock.pending()).toBe(0)
|
|
187
|
+
|
|
188
|
+
broken = false
|
|
189
|
+
loop.poke()
|
|
190
|
+
clock.flush()
|
|
191
|
+
expect(frames).toBe(2)
|
|
192
|
+
expect(loop.awake()).toBe(true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('stays parked when the compute stops the loop', () => {
|
|
196
|
+
const clock = fakeScheduler()
|
|
197
|
+
let frames = 0
|
|
198
|
+
// A compute whose store write unmounts the board runs `stop()` from inside the frame it
|
|
199
|
+
// is halfway through. The frame it was about to schedule must not resurrect it.
|
|
200
|
+
const loop = createSettlingLoop({
|
|
201
|
+
compute: () => {
|
|
202
|
+
frames++
|
|
203
|
+
loop.stop()
|
|
204
|
+
return true
|
|
205
|
+
},
|
|
206
|
+
scheduler: clock.scheduler,
|
|
207
|
+
settleFrames: 3,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
loop.poke()
|
|
211
|
+
clock.flush()
|
|
212
|
+
expect(loop.awake()).toBe(false)
|
|
213
|
+
expect(clock.pending()).toBe(0)
|
|
214
|
+
clock.flush()
|
|
215
|
+
expect(frames).toBe(1)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('drops the pending frame on stop', () => {
|
|
219
|
+
const clock = fakeScheduler()
|
|
220
|
+
let frames = 0
|
|
221
|
+
const loop = createSettlingLoop({
|
|
222
|
+
compute: () => {
|
|
223
|
+
frames++
|
|
224
|
+
return true
|
|
225
|
+
},
|
|
226
|
+
scheduler: clock.scheduler,
|
|
227
|
+
settleFrames: 3,
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
loop.poke()
|
|
231
|
+
loop.stop()
|
|
232
|
+
expect(loop.awake()).toBe(false)
|
|
233
|
+
clock.flush()
|
|
234
|
+
expect(frames).toBe(0)
|
|
235
|
+
})
|
|
236
|
+
})
|