@kabmo-labs/kabmo 0.6.1 → 0.8.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/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/index-gtbpewr7.css +1 -0
- package/dist/client/static/js/{index-aq0e78gv.js → index-qwpvd9fv.js} +779 -779
- package/package.json +2 -1
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +8 -2
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/timeline/index.ts +9 -5
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +74 -1
- package/src/client/hooks/ui/updates/shell-download.ts +112 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/worktree/index.ts +150 -0
- package/src/client/tailwind.css +19 -0
- package/src/server/index.ts +7 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +263 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +129 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +109 -0
- package/src/server/lib/updates/shell-pages.test.ts +156 -0
- package/src/server/lib/updates/shell-pages.ts +145 -0
- package/src/server/lib/updates/shell-release.test.ts +126 -50
- package/src/server/lib/updates/shell-release.ts +139 -88
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/desktop.ts +37 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +124 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +309 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PUBLIC release pages, for when the GitHub API is unavailable.
|
|
3
|
+
*
|
|
4
|
+
* Split from `shell-release.ts` because it is a second, independent read: the API
|
|
5
|
+
* path parses JSON, this one parses an Atom feed and an HTML fragment, and the two
|
|
6
|
+
* share nothing but the asset-naming rules in `shell-assets`.
|
|
7
|
+
*
|
|
8
|
+
* ## Why it exists
|
|
9
|
+
*
|
|
10
|
+
* The REST API is unauthenticated at 60 requests an hour **per source IP** — and
|
|
11
|
+
* on a mobile network that IP is shared with an entire carrier NAT. Measured on
|
|
12
|
+
* the author's machine: `remaining: 0`, so the probe answered `null` and the
|
|
13
|
+
* console drew no update button for an APK two versions behind the published one.
|
|
14
|
+
* A missing button is indistinguishable from "you are up to date", which is why
|
|
15
|
+
* that failed silently for as long as it did.
|
|
16
|
+
*
|
|
17
|
+
* `github.com/<repo>/releases.atom` is a plain Atom feed of published releases
|
|
18
|
+
* (newest first) and `github.com/<repo>/releases/expanded_assets/<tag>` is the
|
|
19
|
+
* HTML fragment the release page itself loads. Both are served without an API
|
|
20
|
+
* budget — verified live: 200 on both, and the fragment carries every asset link.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { REPO } from '@/server/lib/updates/release-url';
|
|
24
|
+
import { SHELL_ASSETS, versionFromAssetName } from '@/server/lib/updates/shell-assets';
|
|
25
|
+
import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
26
|
+
|
|
27
|
+
/** How many tags to walk before giving up. */
|
|
28
|
+
const PAGE_SCAN = 6;
|
|
29
|
+
|
|
30
|
+
/** A tag from the Atom feed, with the release page it names. */
|
|
31
|
+
export interface ReleaseTag {
|
|
32
|
+
name: string;
|
|
33
|
+
url: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One asset found in a release's fragment. */
|
|
37
|
+
export interface PageAsset {
|
|
38
|
+
name: string;
|
|
39
|
+
downloadUrl: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the pages read concluded.
|
|
44
|
+
*
|
|
45
|
+
* THREE cases, not two, because "the feed could not be read" and "the feed was
|
|
46
|
+
* read and carries no artifact of this shell" are different facts: the first
|
|
47
|
+
* keeps the last good answer, the second is a cacheable empty result. Collapsing
|
|
48
|
+
* them makes a momentary outage look like "no update published", which is the
|
|
49
|
+
* same silence the API path's `null` exists to avoid.
|
|
50
|
+
*/
|
|
51
|
+
export type PagesResult =
|
|
52
|
+
| { kind: 'found'; asset: PageAsset; releaseUrl: string | null }
|
|
53
|
+
| { kind: 'none' }
|
|
54
|
+
| { kind: 'unreachable' };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The newest tag that carries this shell's artifact.
|
|
58
|
+
*
|
|
59
|
+
* The FIRST tag with a matching asset wins, because the feed is newest-first and
|
|
60
|
+
* a release that carries no artifact of this shell (a `kabmo-sdk/v*` package
|
|
61
|
+
* release, which the pipeline publishes before the app) must not stop the walk.
|
|
62
|
+
*/
|
|
63
|
+
export async function findAssetInPages(
|
|
64
|
+
kind: ShellKind,
|
|
65
|
+
fetchImpl: typeof fetch,
|
|
66
|
+
headers: Record<string, string>,
|
|
67
|
+
): Promise<PagesResult> {
|
|
68
|
+
const feed = await fetchImpl(`https://github.com/${REPO}/releases.atom`, {
|
|
69
|
+
headers: { accept: 'application/atom+xml, text/xml', ...headers },
|
|
70
|
+
});
|
|
71
|
+
if (!feed.ok) return { kind: 'unreachable' };
|
|
72
|
+
|
|
73
|
+
const tags = releaseTags(await feed.text());
|
|
74
|
+
if (tags.length === 0) return { kind: 'none' };
|
|
75
|
+
|
|
76
|
+
for (const tag of tags.slice(0, PAGE_SCAN)) {
|
|
77
|
+
const page = await fetchImpl(
|
|
78
|
+
`https://github.com/${REPO}/releases/expanded_assets/${encodeURIComponent(tag.name)}`,
|
|
79
|
+
{ headers: { accept: 'text/html', ...headers } },
|
|
80
|
+
);
|
|
81
|
+
if (!page.ok) continue;
|
|
82
|
+
|
|
83
|
+
const asset = shellAssetFromPage(kind, await page.text());
|
|
84
|
+
if (!asset) continue;
|
|
85
|
+
|
|
86
|
+
return { kind: 'found', asset, releaseUrl: tag.url };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { kind: 'none' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The feed's entries, in the order GitHub lists them (newest first).
|
|
94
|
+
*
|
|
95
|
+
* Parsed with two regexes rather than an XML parser: the feed is a fixed shape
|
|
96
|
+
* GitHub emits, and a dependency for two fields would be the larger risk. The
|
|
97
|
+
* entity decode covers the one escape that appears in practice.
|
|
98
|
+
*/
|
|
99
|
+
export function releaseTags(xml: string): ReleaseTag[] {
|
|
100
|
+
const tags: ReleaseTag[] = [];
|
|
101
|
+
const entries = xml.split('<entry>').slice(1);
|
|
102
|
+
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const title = /<title>([^<]*)<\/title>/.exec(entry)?.[1];
|
|
105
|
+
if (!title) continue;
|
|
106
|
+
const href = /<link[^>]*rel="alternate"[^>]*href="([^"]+)"/.exec(entry)?.[1] ?? null;
|
|
107
|
+
tags.push({ name: decodeEntities(title.trim()), url: href ? decodeEntities(href) : null });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return tags;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The asset links inside a release's `expanded_assets` fragment.
|
|
115
|
+
*
|
|
116
|
+
* The name is read out of the DOWNLOAD URL rather than the link's text: the
|
|
117
|
+
* fragment wraps it in a `<span class="text-bold">` whose markup has changed
|
|
118
|
+
* before, while the URL is the release's own path and carries the same name.
|
|
119
|
+
*
|
|
120
|
+
* The CURRENT naming scheme wins over the legacy one, for the reason
|
|
121
|
+
* `shell-release`'s API path documents: a release carrying both spellings must
|
|
122
|
+
* not resolve to the older version.
|
|
123
|
+
*/
|
|
124
|
+
export function shellAssetFromPage(kind: ShellKind, html: string): PageAsset | null {
|
|
125
|
+
const found: PageAsset[] = [];
|
|
126
|
+
const pattern = /href="(\/[^"]+\/releases\/download\/[^"]+)"/g;
|
|
127
|
+
|
|
128
|
+
for (const match of html.matchAll(pattern)) {
|
|
129
|
+
const path = decodeEntities(match[1] ?? '');
|
|
130
|
+
const name = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1));
|
|
131
|
+
if (versionFromAssetName(kind, name) !== null) found.push({ name, downloadUrl: `https://github.com${path}` });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return found.find((asset) => SHELL_ASSETS[kind].current.test(asset.name)) ?? found[0] ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The entities that appear in a feed title or a link. */
|
|
138
|
+
function decodeEntities(text: string): string {
|
|
139
|
+
return text
|
|
140
|
+
.replace(/</g, '<')
|
|
141
|
+
.replace(/>/g, '>')
|
|
142
|
+
.replace(/"/g, '"')
|
|
143
|
+
.replace(/'/g, "'")
|
|
144
|
+
.replace(/&/g, '&');
|
|
145
|
+
}
|
|
@@ -5,44 +5,16 @@
|
|
|
5
5
|
|
|
6
6
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
7
7
|
|
|
8
|
-
import { fetchShellRelease, resetShellReleaseCache
|
|
8
|
+
import { fetchShellRelease, resetShellReleaseCache } from '@/server/lib/updates/shell-release';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
* release TAG names the SERVER version, and the two move independently. A parser
|
|
13
|
-
* that read the tag would compare a server version against a shell version.
|
|
11
|
+
* A fetch double that answers one body, and counts its calls.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `text()` is provided as well as `json()`: the public-pages fallback reads Atom
|
|
14
|
+
* and HTML, and a stub that only answered `json()` would throw inside it and make
|
|
15
|
+
* a broken parse look like a clean "no release" — the exact confusion these
|
|
16
|
+
* tests exist to prevent.
|
|
17
17
|
*/
|
|
18
|
-
describe('versionFromAssetName', () => {
|
|
19
|
-
test('reads the version the release workflows write', () => {
|
|
20
|
-
expect(versionFromAssetName('android', 'kabmo-android-v1.2.3.apk')).toBe('1.2.3');
|
|
21
|
-
expect(versionFromAssetName('ios', 'kabmo-ios-v1.2.3.ipa')).toBe('1.2.3');
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test('accepts a hand-uploaded name without the v', () => {
|
|
25
|
-
expect(versionFromAssetName('android', 'kabmo-android-1.2.3.apk')).toBe('1.2.3');
|
|
26
|
-
expect(versionFromAssetName('ios', 'kabmo-ios-1.2.3.ipa')).toBe('1.2.3');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test('ignores anything that is not that shell’s artifact', () => {
|
|
30
|
-
expect(versionFromAssetName('android', 'kabmo-android-v1.2.3.apk.zip')).toBeNull();
|
|
31
|
-
expect(versionFromAssetName('android', 'source.tar.gz')).toBeNull();
|
|
32
|
-
expect(versionFromAssetName('android', 'kabmo-android-latest.apk')).toBeNull();
|
|
33
|
-
expect(versionFromAssetName('android', '')).toBeNull();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test('does not accept one shell’s asset for the other', () => {
|
|
37
|
-
// The two probes read the SAME release list, so a parser that ignored the
|
|
38
|
-
// kind would let an iOS probe answer with the APK (and offer an `.apk` to an
|
|
39
|
-
// iPhone).
|
|
40
|
-
expect(versionFromAssetName('ios', 'kabmo-android-v1.2.3.apk')).toBeNull();
|
|
41
|
-
expect(versionFromAssetName('android', 'kabmo-ios-v1.2.3.ipa')).toBeNull();
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
/** A fetch double that answers one body, and counts its calls. */
|
|
46
18
|
function stubFetch(body: unknown, ok = true) {
|
|
47
19
|
const calls: string[] = [];
|
|
48
20
|
const impl = (async (input: string | URL | Request) => {
|
|
@@ -50,11 +22,45 @@ function stubFetch(body: unknown, ok = true) {
|
|
|
50
22
|
return {
|
|
51
23
|
ok,
|
|
52
24
|
json: async () => body,
|
|
25
|
+
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
53
26
|
} as Response;
|
|
54
27
|
}) as unknown as typeof fetch;
|
|
55
28
|
return { impl, calls };
|
|
56
29
|
}
|
|
57
30
|
|
|
31
|
+
/**
|
|
32
|
+
* A double that routes by URL, for the two-path probe.
|
|
33
|
+
*
|
|
34
|
+
* `api` answers the REST read (or fails, which is what a spent quota does) and
|
|
35
|
+
* `pages` answers the Atom feed and each release's asset fragment. Returning
|
|
36
|
+
* `null` for a route means "this request fails", which is how the fallback is
|
|
37
|
+
* entered at all.
|
|
38
|
+
*/
|
|
39
|
+
function routedFetch(routes: {
|
|
40
|
+
api?: unknown;
|
|
41
|
+
atom?: string;
|
|
42
|
+
assets?: Record<string, string>;
|
|
43
|
+
}) {
|
|
44
|
+
const calls: string[] = [];
|
|
45
|
+
const impl = (async (input: string | URL | Request) => {
|
|
46
|
+
const url = String(input);
|
|
47
|
+
calls.push(url);
|
|
48
|
+
|
|
49
|
+
if (url.includes('/repos/')) {
|
|
50
|
+
if (routes.api === undefined) return { ok: false, json: async () => ({}), text: async () => '' } as Response;
|
|
51
|
+
return { ok: true, json: async () => routes.api, text: async () => JSON.stringify(routes.api) } as Response;
|
|
52
|
+
}
|
|
53
|
+
if (url.endsWith('/releases.atom')) {
|
|
54
|
+
const body = routes.atom ?? '';
|
|
55
|
+
return { ok: Boolean(routes.atom), json: async () => ({}), text: async () => body } as Response;
|
|
56
|
+
}
|
|
57
|
+
const tag = url.slice(url.lastIndexOf('/') + 1);
|
|
58
|
+
const body = routes.assets?.[decodeURIComponent(tag)];
|
|
59
|
+
return { ok: body !== undefined, json: async () => ({}), text: async () => body ?? '' } as Response;
|
|
60
|
+
}) as unknown as typeof fetch;
|
|
61
|
+
return { impl, calls };
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
describe('fetchShellRelease', () => {
|
|
59
65
|
// The cache is process-wide (see `resetShellReleaseCache`), so every case
|
|
60
66
|
// starts from a cold one or it reads the previous case's answer.
|
|
@@ -67,15 +73,15 @@ describe('fetchShellRelease', () => {
|
|
|
67
73
|
assets: [
|
|
68
74
|
{ name: 'source.tar.gz', browser_download_url: 'https://x/src.tgz' },
|
|
69
75
|
{
|
|
70
|
-
name: '
|
|
71
|
-
browser_download_url: 'https://x/
|
|
76
|
+
name: 'Kabmo-1.2.3-android.apk',
|
|
77
|
+
browser_download_url: 'https://x/Kabmo-1.2.3-android.apk',
|
|
72
78
|
},
|
|
73
79
|
],
|
|
74
80
|
},
|
|
75
81
|
]);
|
|
76
82
|
|
|
77
83
|
expect(await fetchShellRelease('android', impl)).toEqual({
|
|
78
|
-
url: 'https://x/
|
|
84
|
+
url: 'https://x/Kabmo-1.2.3-android.apk',
|
|
79
85
|
latest: '1.2.3',
|
|
80
86
|
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
|
|
81
87
|
});
|
|
@@ -86,8 +92,8 @@ describe('fetchShellRelease', () => {
|
|
|
86
92
|
{
|
|
87
93
|
html_url: 'https://x/app',
|
|
88
94
|
assets: [
|
|
89
|
-
{ name: '
|
|
90
|
-
{ name: '
|
|
95
|
+
{ name: 'Kabmo-0.9.0-android.apk', browser_download_url: 'https://x/a.apk' },
|
|
96
|
+
{ name: 'Kabmo-0.4.0-ios.ipa', browser_download_url: 'https://x/i.ipa' },
|
|
91
97
|
],
|
|
92
98
|
},
|
|
93
99
|
]);
|
|
@@ -99,14 +105,54 @@ describe('fetchShellRelease', () => {
|
|
|
99
105
|
});
|
|
100
106
|
});
|
|
101
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A release that carries BOTH spellings must answer with the current one.
|
|
110
|
+
*
|
|
111
|
+
* That state is real: a rename lands, and the previous release's asset stays
|
|
112
|
+
* beside the new one until `--clobber` replaces it (or a hand-upload adds
|
|
113
|
+
* one). Measured before the fix: a release holding both
|
|
114
|
+
* `kabmo-android-v0.4.0.apk` and `Kabmo-0.6.0-android.apk` answered `0.4.0` —
|
|
115
|
+
* the console offered an update to the version the user already had, and hid
|
|
116
|
+
* the newer one.
|
|
117
|
+
*/
|
|
118
|
+
test('prefers the current scheme when a release carries both', async () => {
|
|
119
|
+
const { impl } = stubFetch([
|
|
120
|
+
{
|
|
121
|
+
html_url: 'https://x/r',
|
|
122
|
+
assets: [
|
|
123
|
+
{ name: 'kabmo-android-v0.4.0.apk', browser_download_url: 'https://x/old.apk' },
|
|
124
|
+
{ name: 'Kabmo-0.6.0-android.apk', browser_download_url: 'https://x/new.apk' },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
expect(await fetchShellRelease('android', impl)).toEqual({
|
|
130
|
+
url: 'https://x/new.apk',
|
|
131
|
+
latest: '0.6.0',
|
|
132
|
+
releaseUrl: 'https://x/r',
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/** A release carrying ONLY the old spelling must keep answering. */
|
|
137
|
+
test('still resolves a release that only has the legacy spelling', async () => {
|
|
138
|
+
const { impl } = stubFetch([
|
|
139
|
+
{
|
|
140
|
+
html_url: 'https://x/r2',
|
|
141
|
+
assets: [{ name: 'kabmo-android-v0.4.0.apk', browser_download_url: 'https://x/old.apk' }],
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
expect((await fetchShellRelease('android', impl))?.latest).toBe('0.4.0');
|
|
146
|
+
});
|
|
147
|
+
|
|
102
148
|
test('the two shells cache separately', async () => {
|
|
103
149
|
// One shared slot would make the second probe answer with the first one's
|
|
104
150
|
// artifact — the release list is the same read, the asset is not.
|
|
105
151
|
const release = {
|
|
106
152
|
html_url: 'https://x/app',
|
|
107
153
|
assets: [
|
|
108
|
-
{ name: '
|
|
109
|
-
{ name: '
|
|
154
|
+
{ name: 'Kabmo-1.0.0-android.apk', browser_download_url: 'https://x/a.apk' },
|
|
155
|
+
{ name: 'Kabmo-2.0.0-ios.ipa', browser_download_url: 'https://x/i.ipa' },
|
|
110
156
|
],
|
|
111
157
|
};
|
|
112
158
|
expect((await fetchShellRelease('android', stubFetch([release]).impl))?.latest).toBe('1.0.0');
|
|
@@ -129,7 +175,7 @@ describe('fetchShellRelease', () => {
|
|
|
129
175
|
{ html_url: 'https://x/kabmo-ui', assets: [] },
|
|
130
176
|
{
|
|
131
177
|
html_url: 'https://x/app',
|
|
132
|
-
assets: [{ name: '
|
|
178
|
+
assets: [{ name: 'Kabmo-0.3.0-android.apk', browser_download_url: 'https://x/a.apk' }],
|
|
133
179
|
},
|
|
134
180
|
]);
|
|
135
181
|
|
|
@@ -145,16 +191,16 @@ describe('fetchShellRelease', () => {
|
|
|
145
191
|
{
|
|
146
192
|
draft: true,
|
|
147
193
|
html_url: 'https://x/draft',
|
|
148
|
-
assets: [{ name: '
|
|
194
|
+
assets: [{ name: 'Kabmo-9.9.9-android.apk', browser_download_url: 'https://x/draft.apk' }],
|
|
149
195
|
},
|
|
150
196
|
{
|
|
151
197
|
prerelease: true,
|
|
152
198
|
html_url: 'https://x/pre',
|
|
153
|
-
assets: [{ name: '
|
|
199
|
+
assets: [{ name: 'Kabmo-8.0.0-android.apk', browser_download_url: 'https://x/pre.apk' }],
|
|
154
200
|
},
|
|
155
201
|
{
|
|
156
202
|
html_url: 'https://x/app',
|
|
157
|
-
assets: [{ name: '
|
|
203
|
+
assets: [{ name: 'Kabmo-0.3.0-android.apk', browser_download_url: 'https://x/a.apk' }],
|
|
158
204
|
},
|
|
159
205
|
]);
|
|
160
206
|
|
|
@@ -170,15 +216,45 @@ describe('fetchShellRelease', () => {
|
|
|
170
216
|
});
|
|
171
217
|
|
|
172
218
|
test('answers null when the probe fails', async () => {
|
|
173
|
-
// An offline box
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
219
|
+
// An offline box must not take the sidebar down, and a failure must not be
|
|
220
|
+
// read as "no newer version" either — it is simply no offer. BOTH paths are
|
|
221
|
+
// down here: the API refuses and the feed is unreachable, which is the only
|
|
222
|
+
// shape that means "cannot ask" rather than "nothing published".
|
|
223
|
+
const { impl } = routedFetch({});
|
|
177
224
|
expect(await fetchShellRelease('android', impl)).toBeNull();
|
|
178
225
|
});
|
|
179
226
|
|
|
180
227
|
test('answers null when the body is malformed', async () => {
|
|
181
|
-
|
|
228
|
+
// The API answers 200 with something that is not a release list, and the
|
|
229
|
+
// feed is unreachable — the API's answer is a failed probe, not an empty
|
|
230
|
+
// repository.
|
|
231
|
+
const { impl } = routedFetch({ api: { assets: 'nope' } });
|
|
182
232
|
expect(await fetchShellRelease('android', impl)).toBeNull();
|
|
183
233
|
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The desktop trio reads the same release list and answers the RELEASE PAGE.
|
|
237
|
+
*
|
|
238
|
+
* Every desktop artifact is per-architecture, and this server cannot know which
|
|
239
|
+
* architecture the window asking is running on — it may be a remote console on
|
|
240
|
+
* another machine. Naming one arch's file would hand half the readers a build
|
|
241
|
+
* that will not launch, so the link is the page that lists all of them.
|
|
242
|
+
*/
|
|
243
|
+
test('a desktop probe answers the release page, never one architecture', async () => {
|
|
244
|
+
const { impl } = stubFetch([
|
|
245
|
+
{
|
|
246
|
+
html_url: 'https://x/app',
|
|
247
|
+
assets: [
|
|
248
|
+
{ name: 'Kabmo-0.2.0-mac-arm64.zip', browser_download_url: 'https://x/mac-arm.zip' },
|
|
249
|
+
{ name: 'Kabmo-0.2.0-mac-x64.dmg', browser_download_url: 'https://x/mac-x64.dmg' },
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
expect(await fetchShellRelease('macos', impl)).toEqual({
|
|
255
|
+
url: 'https://x/app',
|
|
256
|
+
latest: '0.2.0',
|
|
257
|
+
releaseUrl: 'https://x/app',
|
|
258
|
+
});
|
|
259
|
+
});
|
|
184
260
|
});
|