Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/components/SettingsMarketplace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,123 @@ describe('SettingsMarketplace — cài đặt (dialog xác nhận ngay tại tra
expect(installButton.disabled).toBe(false);
});

it('plugin có bản beta: chưa cài thì hiện CẢ hai nút Install và Install Beta', async () => {
manifestByUrl['https://example.com/demo-plugin-beta.json'] = { ...demoPluginManifest(), version: '1.1.0-beta.1' };
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse(
demoCatalog({
beta: { version: '1.1.0-beta.1', pluginManifestUrl: 'https://example.com/demo-plugin-beta.json' },
}),
),
),
);

await renderMarketplace();
await settle();
await waitFor(() => expect(screen.getByText(/demo@1\.0\.0/)).toBeTruthy());

expect(screen.getByRole('button', { name: /^Install$|^Cài đặt$/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /Install Beta|Cài bản Beta/ })).toBeTruthy();
});

it('bấm "Install Beta" mở dialog xem trước ĐÚNG URL của bản beta, không phải bản stable', async () => {
manifestByUrl['https://example.com/demo-plugin-beta.json'] = { ...demoPluginManifest(), version: '1.1.0-beta.1' };
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse(
demoCatalog({
beta: { version: '1.1.0-beta.1', pluginManifestUrl: 'https://example.com/demo-plugin-beta.json' },
}),
),
),
);

await renderMarketplace();
await settle();
await waitFor(() => expect(screen.getByText(/demo@1\.0\.0/)).toBeTruthy());

fireEvent.click(screen.getByRole('button', { name: /Install Beta|Cài bản Beta/ }));

const dialog = await screen.findByRole('dialog');
await waitFor(() => expect(within(dialog).getByText(/demo@1\.1\.0-beta\.1/)).toBeTruthy());

fireEvent.click(within(dialog).getByRole('button', { name: /^Install$|^Cài đặt$/ }));

await waitFor(() => expect(installMock).toHaveBeenCalledTimes(1));
expect(installMock).toHaveBeenCalledWith({
sourceUrl: 'https://example.com/demo-plugin-beta.json',
marketId: 'official',
});
});

it('đã cài bản beta thì hiện nút "Switch to Stable", KHÔNG phải "Switch to Beta"', async () => {
installedArtifacts = [
{
kind: 'plugin',
manifest: { ...demoPluginManifest(), version: '1.1.0-beta.1' },
source_url: 'https://example.com/demo-plugin-beta.json',
bundle_path: '/tmp/demo/bundle.mjs',
installed_at: 1_700_000_000_000,
market_id: 'official',
},
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse(
demoCatalog({
beta: { version: '1.1.0-beta.1', pluginManifestUrl: 'https://example.com/demo-plugin-beta.json' },
}),
),
),
);

await renderMarketplace();
await settle();

// Hàng hiện version THẬT đã cài (bản beta), không phải version stable của
// catalog — cùng lý do đã ghi trong MarketPluginCard.
await waitFor(() => expect(screen.getByText(/demo@1\.1\.0-beta\.1/)).toBeTruthy());
expect(screen.getByRole('button', { name: /Switch to Stable|Chuyển về Stable/ })).toBeTruthy();
expect(screen.queryByRole('button', { name: /Switch to Beta|Chuyển sang Beta/ })).toBeNull();
// Nút chính (cập nhật bản beta) bị vô hiệu vì đã là bản beta mới nhất.
expect((screen.getByRole('button', { name: /^Installed$|^Đã cài$/ }) as HTMLButtonElement).disabled).toBe(true);
});

it('đã cài bản stable, có beta mới hơn thì hiện nút "Switch to Beta"', async () => {
manifestByUrl['https://example.com/demo-plugin-beta.json'] = { ...demoPluginManifest(), version: '1.1.0-beta.1' };
installedArtifacts = [
{
kind: 'plugin',
manifest: demoPluginManifest(),
source_url: 'https://example.com/demo-plugin.json',
bundle_path: '/tmp/demo/bundle.mjs',
installed_at: 1_700_000_000_000,
market_id: 'official',
},
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse(
demoCatalog({
beta: { version: '1.1.0-beta.1', pluginManifestUrl: 'https://example.com/demo-plugin-beta.json' },
}),
),
),
);

await renderMarketplace();
await settle();

await waitFor(() => expect(screen.getByText(/demo@1\.0\.0/)).toBeTruthy());
expect(screen.getByRole('button', { name: /Switch to Beta|Chuyển sang Beta/ })).toBeTruthy();
expect(screen.queryByRole('button', { name: /Switch to Stable|Chuyển về Stable/ })).toBeNull();
});

it('đã cài đúng bản mới nhất thì nút Install bị vô hiệu hoá, hiện "Installed"', async () => {
installedArtifacts = [
{
Expand Down
110 changes: 89 additions & 21 deletions src/components/SettingsMarketplace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,24 @@
const [newLabel, setNewLabel] = useState('');
const [newUrl, setNewUrl] = useState('');

// Plugin đang xem trước/xác nhận cài — `null` khi dialog đóng.
const [installTarget, setInstallTarget] = useState<MarketPlugin | null>(null);
// Nhớ lại theo danh tính plugin, không tính mới mỗi lần render — nếu không,
// một re-render bất kỳ trong lúc dialog đang mở (vd `refreshInstalled` sau
// khi cài xong) tạo một mảng URL MỚI mỗi lần, khiến effect fetch bên trong
// `ExtensionInstallDialog` (khoá theo tham chiếu `urls`) chạy lại vô ích.
const installUrls = useMemo(
() => (installTarget ? [installTarget.pluginManifestUrl, installTarget.serviceManifestUrl].filter((u): u is string => Boolean(u)) : []),
[installTarget],
// Plugin + kênh đang xem trước/xác nhận cài — `null` khi dialog đóng. Kênh
// đi kèm plugin (không phải state riêng) vì `installUrls` bên dưới phải
// biết lấy URL từ field phẳng của plugin (stable) hay từ `plugin.beta` —
// xem `MarketPluginCard`'s hai nút Install/Switch channel.
const [installTarget, setInstallTarget] = useState<{ plugin: MarketPlugin; channel: 'stable' | 'beta' } | null>(
null,
);
// Nhớ lại theo danh tính plugin+kênh, không tính mới mỗi lần render — nếu
// không, một re-render bất kỳ trong lúc dialog đang mở (vd
// `refreshInstalled` sau khi cài xong) tạo một mảng URL MỚI mỗi lần, khiến
// effect fetch bên trong `ExtensionInstallDialog` (khoá theo tham chiếu
// `urls`) chạy lại vô ích.
const installUrls = useMemo(() => {
if (!installTarget) return [];
const variant = installTarget.channel === 'beta' ? installTarget.plugin.beta : installTarget.plugin;
if (!variant) return [];
return [variant.pluginManifestUrl, variant.serviceManifestUrl].filter((u): u is string => Boolean(u));
}, [installTarget]);

const refreshInstalled = useCallback(async () => {
if (!isTauri) return;
Expand Down Expand Up @@ -231,7 +239,7 @@
(r) => r.kind === 'plugin' && r.manifest.id === p.id && (r.marketId === selected?.id || r.marketId === undefined),
)}
targetTriple={targetTriple}
onInstall={() => setInstallTarget(p)}
onInstall={(channel) => setInstallTarget({ plugin: p, channel })}
/>
))}
</div>
Expand All @@ -248,7 +256,7 @@
);
}

function MarketPluginCard({

Check failure on line 259 in src/components/SettingsMarketplace.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=DianaSensei_developer-desktop-utils&issues=AaC1p7PtCtfOw3YGqEcT&open=AaC1p7PtCtfOw3YGqEcT&pullRequest=157
plugin,
installedRecord,
targetTriple,
Expand All @@ -257,27 +265,51 @@
plugin: MarketPlugin;
installedRecord: InstalledArtifactRecord | undefined;
targetTriple: string | null;
onInstall: () => void;
onInstall: (channel: 'stable' | 'beta') => void;
}) {
const { t } = useLocale();
const installedVersion =
installedRecord?.kind === 'plugin' ? installedRecord.manifest.version : undefined;
const upToDate = installedVersion === plugin.version;
const hasBeta = !!plugin.beta;
// Suy ra kênh ĐANG CÀI từ version đã cài khớp field nào — `installer.ts`
// không lưu channel tường minh (một plugin chỉ có MỘT bản ghi cài, dù stable
// hay beta, xem doc comment `MarketPlugin.beta`), nên đây là cách duy nhất
// biết được mà không cần đổi hình dạng InstalledPluginRecord phía Rust.
// Khớp bản beta thì coi là 'beta'; mọi trường hợp khác (khớp stable, hoặc
// version cũ không còn trong catalog) mặc định 'stable' — đúng giả định gốc
// trước khi có beta: mọi bản cài đều là stable.
const installedChannel: 'stable' | 'beta' | undefined =
installedVersion === undefined ? undefined : installedVersion === plugin.beta?.version ? 'beta' : 'stable';

Check warning on line 282 in src/components/SettingsMarketplace.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=DianaSensei_developer-desktop-utils&issues=AaC1p7PtCtfOw3YGqEcU&open=AaC1p7PtCtfOw3YGqEcU&pullRequest=157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A beta that is behind the catalog's current beta is classified as stable here. When installedVersion equals neither plugin.version nor the current plugin.beta.version, the card renders a "Stable" badge next to e.g. demo@1.1.0-beta.1, and the primary button becomes Update to {plugin.version} — a downgrade presented as an update. The comment frames this as only affecting versions removed from the catalog, but it is the normal path every time a new -beta.N ships; a test pinning a stale beta would be worth adding.

Technical details
# Installed beta behind the catalog beta is misread as stable

## Affected sites
- `src/components/SettingsMarketplace.tsx:281-282` — `installedChannel` only returns `'beta'` on exact equality with the current `plugin.beta.version`; every other non-stable version falls through to `'stable'`.
- `src/components/SettingsMarketplace.tsx:305-311` — the badge then labels that version `channelStable`.
- `src/components/SettingsMarketplace.tsx:366-369` — the primary action is enabled and labelled `updateTo: plugin.version` (stable), so it replaces the installed beta with the older stable build.

## Required outcome
- An installed prerelease/beta that is not the catalog's latest beta should still be recognised as the beta channel, so the badge, highlight, and primary action (update to `plugin.beta.version`) match the installed build, and switching to stable stays an explicit secondary action.

## Suggested approach (optional)
- Either persist the channel on the installed record (the PR deliberately avoids a Rust schema change), or infer beta-ness from the installed version string's prerelease component rather than exact equality against one catalog value. A version-compare helper is preferable to raw `includes('-')` if one already exists in the repo.

## Open questions for the human (optional)
- Is `plugin.beta` guaranteed to always hold the latest beta (older betas absent from the catalog)? If older betas can appear too, the exact-equality match needs to consider the full set.

const upToDateStable = installedVersion === plugin.version;
const upToDateBeta = hasBeta && installedVersion === plugin.beta!.version;
const upToDateCurrent = installedChannel === 'beta' ? upToDateBeta : installedChannel === 'stable' && upToDateStable;
const unsupported =
!!plugin.targets && plugin.targets.length > 0 && !!targetTriple && !plugin.targets.includes(targetTriple);

return (
<div
className={cn(
'flex h-full flex-col gap-2 rounded-lg border p-3 transition-colors',
upToDate ? 'border-acc/30 bg-acc/5' : 'hover:border-fg-mute/30',
upToDateCurrent ? 'border-acc/30 bg-acc/5' : 'hover:border-fg-mute/30',
)}
>
<div className="flex items-start gap-2">
<Package className="h-4 w-4 shrink-0 mt-0.5 text-acc" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{plugin.label}</p>
<p className="truncate font-mono text-[11px] text-fg-mute/60">{plugin.id}@{plugin.version}</p>
<p className="flex items-center gap-1.5 truncate font-mono text-[11px] text-fg-mute/60">
{plugin.id}@{installedVersion ?? plugin.version}
{/* Chỉ hiện nhãn kênh khi plugin THẬT SỰ có bản beta — một plugin
chưa từng phát hành beta thì "Stable" trên mọi thẻ chỉ là chữ
thừa, không giúp phân biệt gì cả. */}
{hasBeta && installedChannel && (
<span className="shrink-0 rounded border px-1 py-0.5 font-sans text-fg-mute/80">
{installedChannel === 'beta'
? t('settings.extensions.marketplace.channelBeta')
: t('settings.extensions.marketplace.channelStable')}
</span>
)}
</p>
</div>
</div>

Expand All @@ -301,13 +333,49 @@
không chính thức có thể thiếu triple dù bản build thật sự có), cùng
nguyên tắc SettingsExtensionInstaller đã áp cho cùng field này: xem
trước rồi để người dùng tự quyết, không tự ý từ chối thay họ. */}
<Button size="sm" onClick={onInstall} disabled={upToDate} className="self-start">
{upToDate
? t('settings.extensions.marketplace.installed')
: installedVersion
? t('settings.extensions.marketplace.updateTo', { version: plugin.version })
: t('settings.extensions.marketplace.install')}
</Button>
<div className="flex flex-wrap gap-1.5">
{installedChannel === undefined ? (
// Chưa cài: hai lựa chọn ngay từ đầu nếu có bản beta — không ép
// cài stable rồi mới "chuyển kênh" sau, người dùng biết ngay có
// beta để chọn thẳng nếu muốn.
<>
<Button size="sm" onClick={() => onInstall('stable')}>
{t('settings.extensions.marketplace.install')}
</Button>
{hasBeta && (
<Button size="sm" variant="outline" onClick={() => onInstall('beta')}>
{t('settings.extensions.marketplace.installBeta')}
</Button>
)}
</>
) : installedChannel === 'beta' ? (
<>
<Button size="sm" onClick={() => onInstall('beta')} disabled={upToDateBeta}>
{upToDateBeta
? t('settings.extensions.marketplace.installed')
: t('settings.extensions.marketplace.updateTo', { version: plugin.beta!.version })}
</Button>
{/* Bản stable (field phẳng của plugin) luôn tồn tại — chuyển về
stable lúc nào cũng là một lựa chọn hợp lệ khi đang ở beta. */}
<Button size="sm" variant="outline" onClick={() => onInstall('stable')}>
{t('settings.extensions.marketplace.switchToStable')}
</Button>
</>
) : (
<>
<Button size="sm" onClick={() => onInstall('stable')} disabled={upToDateStable}>
{upToDateStable
? t('settings.extensions.marketplace.installed')
: t('settings.extensions.marketplace.updateTo', { version: plugin.version })}
</Button>
{hasBeta && (
<Button size="sm" variant="outline" onClick={() => onInstall('beta')}>
{t('settings.extensions.marketplace.switchToBeta')}
</Button>
)}
</>
)}

Check warning on line 377 in src/components/SettingsMarketplace.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=DianaSensei_developer-desktop-utils&issues=AaC1p7PtCtfOw3YGqEcV&open=AaC1p7PtCtfOw3YGqEcV&pullRequest=157
</div>
</div>
);
}
7 changes: 7 additions & 0 deletions src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,15 @@ export const DICTIONARY = {
en: 'This market has no extensions yet.',
},
'settings.extensions.marketplace.install': { vi: 'Cài đặt', en: 'Install' },
'settings.extensions.marketplace.installBeta': { vi: 'Cài bản Beta', en: 'Install Beta' },
'settings.extensions.marketplace.installed': { vi: 'Đã cài', en: 'Installed' },
'settings.extensions.marketplace.updateTo': { vi: 'Cập nhật lên {{version}}', en: 'Update to {{version}}' },
'settings.extensions.marketplace.switchToBeta': { vi: 'Chuyển sang Beta', en: 'Switch to Beta' },
'settings.extensions.marketplace.switchToStable': { vi: 'Chuyển về Stable', en: 'Switch to Stable' },
// Nhãn kênh cạnh version đã cài — CHỈ hiện khi có bản beta (plugin.beta),
// để không thêm chữ thừa cho plugin chưa từng phát hành beta bao giờ.
'settings.extensions.marketplace.channelStable': { vi: 'Bản ổn định', en: 'Stable' },
'settings.extensions.marketplace.channelBeta': { vi: 'Bản Beta', en: 'Beta' },
'settings.extensions.marketplace.unsupportedPlatform': {
vi: 'Không có bản cho nền tảng máy này',
en: 'No build for this machine\'s platform',
Expand Down
71 changes: 71 additions & 0 deletions src/lib/market.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,77 @@ describe('fetchMarketCatalog', () => {
expect(plugins[0].id).toBe('ok');
});

it('parse đúng field "beta" khi tác giả có phát hành bản beta', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse({
plugins: [
{
id: 'demo',
label: 'Demo',
description: 'A demo plugin',
version: '1.0.0',
pluginManifestUrl: 'https://example.com/demo-plugin.json',
beta: {
version: '1.1.0-beta.1',
pluginManifestUrl: 'https://example.com/demo-plugin-beta.json',
serviceManifestUrl: 'https://example.com/demo-service-beta.json',
},
},
],
}),
),
);

const plugins = await fetchMarketCatalog('https://example.com/catalog.json');
expect(plugins[0].beta).toEqual({
version: '1.1.0-beta.1',
pluginManifestUrl: 'https://example.com/demo-plugin-beta.json',
serviceManifestUrl: 'https://example.com/demo-service-beta.json',
});
});

it('không có "beta" thì field đó vắng mặt, không phải lỗi hình dạng', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse({
plugins: [
{ id: 'demo', label: 'Demo', description: 'd', version: '1.0.0', pluginManifestUrl: 'https://example.com/demo.json' },
],
}),
),
);

const plugins = await fetchMarketCatalog('https://example.com/catalog.json');
expect(plugins[0].beta).toBeUndefined();
});

it('"beta" sai hình dạng (thiếu version) chỉ làm rớt bản beta, không loại cả plugin', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse({
plugins: [
{
id: 'demo',
label: 'Demo',
description: 'd',
version: '1.0.0',
pluginManifestUrl: 'https://example.com/demo.json',
beta: { pluginManifestUrl: 'https://example.com/demo-beta.json' }, // thiếu version
},
],
}),
),
);

const plugins = await fetchMarketCatalog('https://example.com/catalog.json');
expect(plugins).toHaveLength(1);
expect(plugins[0].beta).toBeUndefined();
});

it('ném lỗi rõ ràng khi HTTP lỗi', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(null, false, 404)));
await expect(fetchMarketCatalog('https://example.com/catalog.json')).rejects.toThrow('HTTP 404');
Expand Down
Loading
Loading