Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

/**
* Wiring guard for the package-namespace field (framework#2694) in the
* package-switcher "+ new" dialog: it defaults to the id-derived namespace,
* tracks the id input until the user edits it, and gates submit on a valid
* `^[a-z][a-z0-9_]{1,19}$` value.
* spec-driven create dialog (`CreatePackageDialog` → `PackageFormDialog`): it
* defaults to the id-derived namespace, tracks the id input until the user
* edits it, sanitizes keystrokes to the allowed alphabet, and gates submit on a
* valid `^[a-z][a-z0-9_]{1,19}$` value.
*
* The form is now rendered from the manifest spec via SchemaForm, so fields are
* targeted by their label rather than the old hand-rolled test ids.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
Expand All @@ -17,13 +21,14 @@ afterEach(cleanup);
function open() {
render(<CreatePackageDialog open onOpenChange={vi.fn()} onCreated={vi.fn()} />);
return {
id: screen.getByTestId('package-id-input') as HTMLInputElement,
ns: screen.getByTestId('package-namespace-input') as HTMLInputElement,
name: screen.getByTestId('package-name-input') as HTMLInputElement,
id: screen.getByLabelText(/package id/i) as HTMLInputElement,
ns: screen.getByLabelText(/object namespace/i) as HTMLInputElement,
name: screen.getByLabelText(/display name/i) as HTMLInputElement,
submit: screen.getByTestId('package-form-submit') as HTMLButtonElement,
};
}

describe('CreatePackageDialog — namespace field', () => {
describe('CreatePackageDialog — namespace field (spec form)', () => {
it('derives the namespace from the package id and tracks it', () => {
const { id, ns } = open();
fireEvent.change(id, { target: { value: 'com.example.leave' } });
Expand All @@ -50,10 +55,9 @@ describe('CreatePackageDialog — namespace field', () => {
});

it('disables submit while the namespace is invalid, enables it when valid', () => {
const { id, ns, name } = open();
const { id, ns, name, submit } = open();
fireEvent.change(id, { target: { value: 'com.example.leave' } });
fireEvent.change(name, { target: { value: 'Leave' } });
const submit = screen.getByRole('button', { name: /create package|创建软件包/i });
expect(submit).toBeEnabled();
// Single char is below the 2-char minimum → invalid.
fireEvent.change(ns, { target: { value: 'a' } });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { EditPackageDialog, type InstalledPackage } from './PackagesPage';

// PackagesPage.apiJson uses the raw global fetch (fetch → res.text() → JSON.parse),
// so we stub global fetch and mirror the runtime's { success, data } envelope —
// exactly what `PATCH /api/v1/packages/:id` returns (framework http-dispatcher).
// EditPackageDialog is now a thin wrapper over the spec-driven PackageFormDialog
// (edit mode). It renders the manifest form via SchemaForm and PATCHes only the
// three fields the REST surface persists (name / description / version).
// PackageFormDialog's apiJson uses the raw global fetch (fetch → res.text() →
// JSON.parse), so we stub global fetch and mirror the runtime's { success, data }
// envelope — exactly what `PATCH /api/v1/packages/:id` returns.
const PKG: InstalledPackage = {
manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', description: 'old', type: 'app' },
enabled: true,
Expand All @@ -22,12 +25,8 @@ beforeEach(() => {
'fetch',
vi.fn(async (url: string, init: RequestInit = {}) => {
calls.push({ url, init });
// Echo the patched manifest back, the way the dispatcher does.
const body = init.body ? JSON.parse(init.body as string) : {};
const updated: InstalledPackage = {
...PKG,
manifest: { ...PKG.manifest, ...body },
};
const updated: InstalledPackage = { ...PKG, manifest: { ...PKG.manifest, ...body } };
return {
ok: true,
status: 200,
Expand All @@ -39,57 +38,47 @@ beforeEach(() => {

afterEach(() => vi.unstubAllGlobals());

describe('EditPackageDialog', () => {
it('prefills from the manifest and PATCHes only the edited fields', async () => {
describe('EditPackageDialog (spec-form wrapper)', () => {
it('prefills from the manifest and PATCHes only the server-persisted fields', async () => {
const onSaved = vi.fn();
const onOpenChange = vi.fn();
render(<EditPackageDialog pkg={PKG} open onOpenChange={onOpenChange} onSaved={onSaved} />);

const nameInput = (await screen.findByTestId('package-edit-name-input')) as HTMLInputElement;
expect(nameInput.value).toBe('Acme CRM'); // prefilled

fireEvent.change(nameInput, { target: { value: 'Acme CRM v2' } });
fireEvent.click(screen.getByTestId('package-edit-save'));
// Stable submit affordance regardless of the SchemaForm field internals.
fireEvent.click(await screen.findByTestId('package-form-submit'));

await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));

// Issued a PATCH to the package's REST id with the edited manifest fields.
// One PATCH to the package's REST id, carrying ONLY name/description/version.
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('/api/v1/packages/com.acme.crm');
expect(calls[0].init.method).toBe('PATCH');
const sent = JSON.parse(calls[0].init.body as string);
expect(sent).toMatchObject({ name: 'Acme CRM v2', version: '1.0.0' });
expect(JSON.parse(calls[0].init.body as string)).toEqual({
name: 'Acme CRM',
description: 'old',
version: '1.0.0',
});

// onSaved receives the server's updated package; the dialog closes.
expect(onSaved.mock.calls[0][0].manifest.name).toBe('Acme CRM v2');
expect(onSaved.mock.calls[0][0].manifest.name).toBe('Acme CRM');
expect(onOpenChange).toHaveBeenCalledWith(false);
});

it('blocks save on a non-semantic version and never calls the API', async () => {
render(<EditPackageDialog pkg={PKG} open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
const versionInput = await screen.findByLabelText(/version/i);
fireEvent.change(versionInput, { target: { value: '1.2' } });

const save = screen.getByTestId('package-edit-save') as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.click(save);
expect(calls).toHaveLength(0);
});

it('surfaces a server error and keeps the dialog open', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: false,
status: 400,
text: async () => JSON.stringify({ success: false, error: { message: 'version must be semantic (e.g. 1.0.0)' } }),
text: async () =>
JSON.stringify({ success: false, error: { message: 'version must be semantic (e.g. 1.0.0)' } }),
})),
);
const onSaved = vi.fn();
const onOpenChange = vi.fn();
render(<EditPackageDialog pkg={PKG} open onOpenChange={onOpenChange} onSaved={onSaved} />);

fireEvent.click(screen.getByTestId('package-edit-save'));
fireEvent.click(await screen.findByTestId('package-form-submit'));

expect(await screen.findByText(/version must be semantic/i)).toBeInTheDocument();
expect(onSaved).not.toHaveBeenCalled();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { PackageFormDialog } from './PackageFormDialog';

// PackageFormDialog renders the manifest form from the spec (package-schema)
// via SchemaForm and talks to `/api/v1/packages`. apiJson uses the raw global
// fetch (fetch → res.text() → JSON.parse), so we stub fetch and mirror the
// runtime's { success, data } envelope.
let calls: Array<{ url: string; init: RequestInit }>;

function stubOk() {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init: RequestInit = {}) => {
calls.push({ url, init });
const body = init.body ? JSON.parse(init.body as string) : {};
const manifest = body.manifest ?? body;
return {
ok: true,
status: 201,
text: async () => JSON.stringify({ success: true, data: { manifest, status: 'installed' } }),
} as Response;
}),
);
}

beforeEach(() => {
calls = [];
stubOk();
});
afterEach(() => vi.unstubAllGlobals());

describe('PackageFormDialog — create mode', () => {
it('POSTs { manifest } from the spec form and reports the new id', async () => {
const onSaved = vi.fn();
render(<PackageFormDialog mode="create" open onOpenChange={vi.fn()} onSaved={onSaved} />);

// Fields come from the spec-derived FormView (package-schema); target them by label.
fireEvent.change(await screen.findByLabelText(/package id/i), { target: { value: 'com.acme.new' } });
fireEvent.change(screen.getByLabelText(/display name/i), { target: { value: 'New App' } });
// version prefills to 0.1.0; type prefills to 'app'.

fireEvent.click(screen.getByTestId('package-form-submit'));

await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('/api/v1/packages');
expect(calls[0].init.method).toBe('POST');
expect(JSON.parse(calls[0].init.body as string).manifest).toMatchObject({
id: 'com.acme.new',
name: 'New App',
version: '0.1.0',
type: 'app',
});
expect(onSaved.mock.calls[0][0].id).toBe('com.acme.new');
});

it('maps a 409 duplicate id to a friendly "already exists" message and stays open', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: false,
status: 409,
text: async () => JSON.stringify({ success: false, error: { message: "Package 'com.acme.new' already exists" } }),
})),
);
const onSaved = vi.fn();
const onOpenChange = vi.fn();
render(<PackageFormDialog mode="create" open onOpenChange={onOpenChange} onSaved={onSaved} />);

fireEvent.change(await screen.findByLabelText(/package id/i), { target: { value: 'com.acme.new' } });
fireEvent.change(screen.getByLabelText(/display name/i), { target: { value: 'Dup App' } });
fireEvent.click(screen.getByTestId('package-form-submit'));

expect(await screen.findByText(/already exists/i)).toBeInTheDocument();
expect(onSaved).not.toHaveBeenCalled();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
});
Loading
Loading