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
641 changes: 641 additions & 0 deletions docs/konnect-control-planes-organization-plan.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/insomnia-data/src/database/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface SpecificQuery {
$in?: (string | null)[];
$nin?: string[];
$ne?: string | null;
// NeDB's `$ne` also matches documents where the field is absent, so optional keys need this too.
$exists?: boolean;
}

export type Query<T extends BaseModel = BaseModel> = {
Expand Down
29 changes: 28 additions & 1 deletion packages/insomnia-data/src/models/organization.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
import type { PersonalPlanType } from 'insomnia-api';
import type { Organization, PersonalPlanType } from 'insomnia-api';

export const SCRATCHPAD_ORGANIZATION_ID = 'org_scratchpad';
export const isScratchpadOrganizationId = (organizationId: string) => organizationId === SCRATCHPAD_ORGANIZATION_ID;

export const KONNECT_ORGANIZATION_ID_PREFIX = 'org_konnect_';
export const KONNECT_ORGANIZATION_NAME = 'Control Planes';

// The Konnect organization is local-only, but the database is not partitioned per user, so the
// account id is baked into the id to keep one account's Konnect data out of another's.
export const getKonnectOrganizationId = (accountId: string) => `${KONNECT_ORGANIZATION_ID_PREFIX}${accountId}`;
export const isKonnectOrganizationId = (organizationId: string) =>
organizationId.startsWith(KONNECT_ORGANIZATION_ID_PREFIX);

/** Organizations that exist only on this machine and must never be used for organization-scoped API calls. */
export const isLocalOrganizationId = (organizationId: string) =>
isScratchpadOrganizationId(organizationId) || isKonnectOrganizationId(organizationId);

export const buildKonnectOrganization = (accountId: string): Organization => ({
id: getKonnectOrganizationId(accountId),
name: KONNECT_ORGANIZATION_NAME,
picture: null,
owner_first_name: null,
owner_last_name: null,
owner_email: null,
total_members: 1,
total_invites: 0,
// Every account owns at least one organization, and this one only exists on their machine.
is_owner: true,
can_leave: false,
});

export const formatCurrentPlanType = (type: PersonalPlanType) => {
switch (type) {
case 'free': {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@
}

// ===========================================================================
// Tab controls
// Organization switching
// ===========================================================================

async clickProjectsTab(): Promise<void> {
await this.root.getByTestId('sidebar-tab-projects').click();
async selectOrganization(name: string): Promise<void> {
await this.page.getByRole('button', { name: 'Organizations' }).click();
await this.page.getByRole('option', { name }).click();
}

async clickKonnectTab(): Promise<void> {
await this.root.getByTestId('sidebar-tab-konnect').click();
async openControlPlanesOrganization(): Promise<void> {
await this.selectOrganization('Control Planes');
}

// ===========================================================================
Expand Down Expand Up @@ -186,7 +187,7 @@
const actionsButton = requestRow.getByLabel('Request Group Actions');
// Sometimes the dropdown button can be a bit tricky to click if the hover state isn't properly triggered, so we'll add some retries here to make it more robust
for (let attempt = 0; attempt < 3; attempt++) {
await requestRow.hover();

Check failure on line 190 in packages/insomnia-smoke-test/playwright/pages/components/navigation-sidebar.ts

View workflow job for this annotation

GitHub Actions / test (1, 6)

[Smoke] › tests/smoke/debug-sidebar-interactions.test.ts:9:7 › Debug-Sidebar › Requests

1) [Smoke] › tests/smoke/debug-sidebar-interactions.test.ts:9:7 › Debug-Sidebar › Requests ─────── Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── TimeoutError: locator.hover: Timeout 30000ms exceeded. Call log: - waiting for getByTestId('global-navigation-sidebar').getByTestId('request-node-test folder') - locator resolved to <div data-selected="false" data-workspace="simple" data-project="Personal Workspace" data-testid="request-node-test folder" class="relative flex h-(--line-height-xs) w-full items-center gap-1 overflow-hidden text-[rgba(var(--color-font-rgb),0.8)] outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-aria-selected:bg-(--hl-xs) group-focus:bg-(--hl-sm) group-aria-selected:text-(--color-font) pr-4 ">…</div> - attempting hover action 2 × waiting for element to be visible and stable - element is visible and stable - scrolling into view if needed - done scrolling - <div data-close-modal="true" class="modal__backdrop overlay theme--transparent-overlay"></div> from <div data-rac="" class="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30">…</div> subtree intercepts pointer events - retrying hover action - waiting 20ms 2 × waiting for element to be visible and stable - element is visible and stable - scrolling into view if needed - done scrolling - <div data-close-modal="true" class="modal__backdrop overlay theme--transparent-overlay"></div> from <div data-rac="" class="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30">…</div> subtree intercepts pointer events - retrying hover action - waiting 100ms 58 × waiting for element to be visible and stable - element is visible and stable - scrolling into view if needed - done scrolling - <div data-close-modal="true" class="modal__backdrop overlay theme--transparent-overlay"></div> from <div data-rac="" class="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30">…</div> subtree intercepts pointer events - retrying hover action - waiting 500ms at ../playwright/pages/components/navigation-sidebar.ts:190 188 | // Sometimes the dropdown button can be a bit tricky to click if the hover state isn't properly triggered, so we'll add some retries here to make it more robust 189 | for (let attempt = 0; attempt < 3; attempt++) { > 190 | await requestRow.hover(); | ^ 191 | if (attempt > 0) { 192 | console.log(`Retrying to open request group actions dropdown for "${requestName}", attempt ${attempt + 1}`); 193 | } at NavigationSidebar.openRequestGroupActionsDropdown (/home/runner/work/insomnia/insomnia/packages/insomnia-smoke-test/playwright/pages/components/navigation-sidebar.ts:190:24) at /home/runner/work/insomnia/insomnia/packages/insomnia-smoke-test/tests/smoke/debug-sidebar-interactions.test.ts:36:38

Check failure on line 190 in packages/insomnia-smoke-test/playwright/pages/components/navigation-sidebar.ts

View workflow job for this annotation

GitHub Actions / test (1, 6)

[Smoke] › tests/smoke/debug-sidebar-interactions.test.ts:9:7 › Debug-Sidebar › Requests

1) [Smoke] › tests/smoke/debug-sidebar-interactions.test.ts:9:7 › Debug-Sidebar › Requests ─────── TimeoutError: locator.hover: Timeout 30000ms exceeded. Call log: - waiting for getByTestId('global-navigation-sidebar').getByTestId('request-node-test folder') - locator resolved to <div data-selected="false" data-workspace="simple" data-project="Personal Workspace" data-testid="request-node-test folder" class="relative flex h-(--line-height-xs) w-full items-center gap-1 overflow-hidden text-[rgba(var(--color-font-rgb),0.8)] outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-aria-selected:bg-(--hl-xs) group-focus:bg-(--hl-sm) group-aria-selected:text-(--color-font) pr-4 ">…</div> - attempting hover action 2 × waiting for element to be visible and stable - element is visible and stable - scrolling into view if needed - done scrolling - <div data-close-modal="true" class="modal__backdrop overlay theme--transparent-overlay"></div> from <div data-rac="" class="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30">…</div> subtree intercepts pointer events - retrying hover action - waiting 20ms 2 × waiting for element to be visible and stable - element is visible and stable - scrolling into view if needed - done scrolling - <div data-close-modal="true" class="modal__backdrop overlay theme--transparent-overlay"></div> from <div data-rac="" class="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30">…</div> subtree intercepts pointer events - retrying hover action - waiting 100ms 58 × waiting for element to be visible and stable - element is visible and stable - scrolling into view if needed - done scrolling - <div data-close-modal="true" class="modal__backdrop overlay theme--transparent-overlay"></div> from <div data-rac="" class="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30">…</div> subtree intercepts pointer events - retrying hover action - waiting 500ms at ../playwright/pages/components/navigation-sidebar.ts:190 188 | // Sometimes the dropdown button can be a bit tricky to click if the hover state isn't properly triggered, so we'll add some retries here to make it more robust 189 | for (let attempt = 0; attempt < 3; attempt++) { > 190 | await requestRow.hover(); | ^ 191 | if (attempt > 0) { 192 | console.log(`Retrying to open request group actions dropdown for "${requestName}", attempt ${attempt + 1}`); 193 | } at NavigationSidebar.openRequestGroupActionsDropdown (/home/runner/work/insomnia/insomnia/packages/insomnia-smoke-test/playwright/pages/components/navigation-sidebar.ts:190:24) at /home/runner/work/insomnia/insomnia/packages/insomnia-smoke-test/tests/smoke/debug-sidebar-interactions.test.ts:36:38
if (attempt > 0) {
console.log(`Retrying to open request group actions dropdown for "${requestName}", attempt ${attempt + 1}`);
}
Expand Down
17 changes: 9 additions & 8 deletions packages/insomnia-smoke-test/tests/smoke/konnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import { test } from '../../playwright/test';

test.describe('Konnect sidebar tab', () => {
test('shows intro card without a PAT, configure it, then sync', async ({ page, insomnia }) => {
await page.getByTestId('sidebar-tab-konnect').click();
test.describe('Control Planes organization', () => {
test('shows intro card without a PAT, configure it, then sync', async ({ page }) => {
await page.getByRole('button', { name: 'Organizations' }).click();
await page.getByRole('option', { name: 'Control Planes' }).click();
await expect.soft(page.getByText('Auto-sync your gateway service routes')).toBeVisible();

await page.getByRole('button', { name: 'Configure' }).click();
Expand All @@ -12,10 +13,9 @@
await page.getByRole('button', { name: 'Connect & Sync' }).click();
await expect.soft(page.getByRole('heading', { name: 'Kong Konnect settings' })).toBeHidden();

await expect.soft(page.getByRole('button', { name: 'Sync Konnect' })).toBeVisible();

Check failure on line 16 in packages/insomnia-smoke-test/tests/smoke/konnect.test.ts

View workflow job for this annotation

GitHub Actions / test (4, 6)

[Smoke] › tests/smoke/konnect.test.ts:6:7 › Control Planes organization › shows intro card without a PAT

1) [Smoke] › tests/smoke/konnect.test.ts:6:7 › Control Planes organization › shows intro card without a PAT, configure it, then sync Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByRole('button', { name: 'Sync Konnect' }) Expected: visible Timeout: 25000ms Error: element(s) not found Call log: - Expect "soft toBeVisible" with timeout 25000ms - waiting for getByRole('button', { name: 'Sync Konnect' }) 14 | await expect.soft(page.getByRole('heading', { name: 'Kong Konnect settings' })).toBeHidden(); 15 | > 16 | await expect.soft(page.getByRole('button', { name: 'Sync Konnect' })).toBeVisible(); | ^ 17 | // The Konnect organization never offers manual project creation. 18 | await expect.soft(page.getByRole('button', { name: 'Create new Project' })).toBeHidden(); 19 | }); at /home/runner/work/insomnia/insomnia/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts:16:75

Check failure on line 16 in packages/insomnia-smoke-test/tests/smoke/konnect.test.ts

View workflow job for this annotation

GitHub Actions / test (4, 6)

[Smoke] › tests/smoke/konnect.test.ts:6:7 › Control Planes organization › shows intro card without a PAT

1) [Smoke] › tests/smoke/konnect.test.ts:6:7 › Control Planes organization › shows intro card without a PAT, configure it, then sync Error: expect(locator).toBeVisible() failed Locator: getByRole('button', { name: 'Sync Konnect' }) Expected: visible Timeout: 25000ms Error: element(s) not found Call log: - Expect "soft toBeVisible" with timeout 25000ms - waiting for getByRole('button', { name: 'Sync Konnect' }) 14 | await expect.soft(page.getByRole('heading', { name: 'Kong Konnect settings' })).toBeHidden(); 15 | > 16 | await expect.soft(page.getByRole('button', { name: 'Sync Konnect' })).toBeVisible(); | ^ 17 | // The Konnect organization never offers manual project creation. 18 | await expect.soft(page.getByRole('button', { name: 'Create new Project' })).toBeHidden(); 19 | }); at /home/runner/work/insomnia/insomnia/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts:16:75

await page.getByTestId('sidebar-tab-projects').click();
await expect.soft(page.getByRole('button', { name: 'Create new Project' })).toBeVisible();
// The Konnect organization never offers manual project creation.
await expect.soft(page.getByRole('button', { name: 'Create new Project' })).toBeHidden();
});

test.describe('with konnectSync feature flag disabled', () => {
Expand All @@ -31,9 +31,10 @@
});
});

test('hides the Konnect tab', async ({ page }) => {
test('hides the Control Planes organization', async ({ page }) => {
await page.reload({ waitUntil: 'networkidle' });
await expect.soft(page.getByTestId('sidebar-tab-konnect')).toBeHidden();
await page.getByRole('button', { name: 'Organizations' }).click();
await expect.soft(page.getByRole('option', { name: 'Control Planes' })).toBeHidden();
});
});
});
2 changes: 1 addition & 1 deletion packages/insomnia/src/common/organization-storage-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export async function fetchAndCacheOrganizationStorageRule(
): Promise<StorageRules> {
invariant(organizationId, 'Organization ID is required');

if (models.organization.isScratchpadOrganizationId(organizationId)) {
if (models.organization.isLocalOrganizationId(organizationId)) {
return {
enableCloudSync: false,
enableLocalVault: true,
Expand Down
12 changes: 12 additions & 0 deletions packages/insomnia/src/entry.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { HydratedRouter } from 'react-router/dom';

import { insomniaFetch } from '~/common/insomnia-fetch';
import { setTemplatingDbAuthToken } from '~/common/templating/liquid-extension-worker';
import { migrateKonnectProjectsIfUnambiguous } from '~/konnect/migrate-konnect-organization';
import { initRuntime } from '~/runtimes';
import { rendererRuntime } from '~/runtimes/runtime.renderer';
import { migrateFromLocalStorage, type SessionData, setSessionData, setVaultSessionData } from '~/ui/account/session';
Expand Down Expand Up @@ -145,6 +146,17 @@ if (appSettings.clearOAuth2SessionOnRestart) {

applyColorScheme(appSettings);

// Runs before the router hydrates so every loader can assume Konnect projects already live under
// the Konnect organization. The ambiguous case is left for the user to resolve in the UI.
try {
const { accountId } = await services.userSession.get();
if (accountId) {
await migrateKonnectProjectsIfUnambiguous(accountId);
}
} catch (e) {
console.log('[konnect] Failed to migrate Konnect projects', e);
}

const initialEntry = await getInitialEntry();

if (typeof initialEntry === 'string' && window.location.pathname !== initialEntry) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* Tests run against the in-memory NeDB initialized by setup-vitest.ts.
* localStorage is stubbed per-test to supply the cached organization list.
*/

import { initDatabase, models, services } from 'insomnia-data';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { mainDatabase } from '../../main/database.main';
import {
detectKonnectOrgMigration,
migrateKonnectProjectsIfUnambiguous,
runKonnectOrgMigration,
} from '../migrate-konnect-organization';

const ACCOUNT_ID = 'acct_1';
const ORG_A = 'org_a';
const ORG_B = 'org_b';
const KONNECT_ORG_ID = models.organization.getKonnectOrganizationId(ACCOUNT_ID);

function stubLocalStorage(initial: Record<string, string> = {}) {
const store = new Map(Object.entries(initial));
vi.stubGlobal('localStorage', {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
});
return store;
}

async function createKonnectProject(parentId: string, controlPlaneId: string) {
const project = await services.project.create({
name: `CP ${controlPlaneId}`,
parentId,
konnectControlPlaneId: controlPlaneId,
});
await services.workspace.create({
name: `Service of ${controlPlaneId}`,
parentId: project._id,
scope: 'collection',
konnectServiceId: `svc-${controlPlaneId}`,
});
return project;
}

const listKonnectProjects = () => services.project.list({ konnectControlPlaneId: { $exists: true, $ne: null } });

beforeEach(async () => {
await initDatabase(mainDatabase, { inMemoryOnly: true }, true);
stubLocalStorage({
[`${ACCOUNT_ID}:spaces`]: JSON.stringify([
{ id: ORG_A, name: 'Org A' },
{ id: ORG_B, name: 'Org B' },
]),
});
});

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

describe('detectKonnectOrgMigration', () => {
it('reports nothing to do when there are no Konnect projects', async () => {
expect(await detectKonnectOrgMigration({ accountId: ACCOUNT_ID })).toEqual({ status: 'none', groups: [] });
});

it('reports a single source organization as auto-migratable', async () => {
await createKonnectProject(ORG_A, 'cp-1');
await createKonnectProject(ORG_A, 'cp-2');

const plan = await detectKonnectOrgMigration({ accountId: ACCOUNT_ID });

expect(plan.status).toBe('auto');
expect(plan.groups).toEqual([
{ organizationId: ORG_A, organizationName: 'Org A', projectCount: 2, workspaceCount: 2 },
]);
});

it('reports multiple source organizations as a conflict', async () => {
await createKonnectProject(ORG_A, 'cp-1');
await createKonnectProject(ORG_B, 'cp-2');

const plan = await detectKonnectOrgMigration({ accountId: ACCOUNT_ID });

expect(plan.status).toBe('conflict');
expect(plan.groups.map(g => g.organizationId).sort()).toEqual([ORG_A, ORG_B]);
});

it('ignores Konnect projects owned by another account on the same machine', async () => {
await createKonnectProject('org_someone_else', 'cp-1');

expect(await detectKonnectOrgMigration({ accountId: ACCOUNT_ID })).toEqual({ status: 'none', groups: [] });
});

it('ignores Konnect projects that already live under the Konnect organization', async () => {
await createKonnectProject(KONNECT_ORG_ID, 'cp-1');

expect(await detectKonnectOrgMigration({ accountId: ACCOUNT_ID })).toEqual({ status: 'none', groups: [] });
});

it('ignores regular projects, which omit konnectControlPlaneId entirely', async () => {
await services.project.create({ name: 'Regular', parentId: ORG_A });

expect(await detectKonnectOrgMigration({ accountId: ACCOUNT_ID })).toEqual({ status: 'none', groups: [] });
});
});

describe('runKonnectOrgMigration', () => {
it('re-parents the chosen organization and deletes the rest with their descendants', async () => {
await createKonnectProject(ORG_A, 'cp-1');
const discarded = await createKonnectProject(ORG_B, 'cp-2');

await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A });

const projects = await listKonnectProjects();
expect(projects).toHaveLength(1);
expect(projects[0].parentId).toBe(KONNECT_ORG_ID);
expect(projects[0].konnectControlPlaneId).toBe('cp-1');

expect(await services.workspace.count({ parentId: discarded._id })).toBe(0);
});

it("leaves another account's Konnect projects untouched", async () => {
await createKonnectProject(ORG_A, 'cp-1');
await createKonnectProject('org_someone_else', 'cp-other');

await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A });

const projects = await listKonnectProjects();
expect(projects.map(p => p.parentId).sort()).toEqual([KONNECT_ORG_ID, 'org_someone_else'].sort());
});

it('carries the last-synced timestamp over to the Konnect organization', async () => {
const store = stubLocalStorage({
[`${ACCOUNT_ID}:spaces`]: JSON.stringify([{ id: ORG_A, name: 'Org A' }]),
[`${ORG_A}:konnect-last-synced-at`]: '1700000000000',
});
await createKonnectProject(ORG_A, 'cp-1');

await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A });

expect(store.get(`${KONNECT_ORG_ID}:konnect-last-synced-at`)).toBe('1700000000000');
expect(store.get(`${ORG_A}:konnect-last-synced-at`)).toBeUndefined();
});

it('clears the last-synced timestamp of every source organization but leaves orphans alone', async () => {
const store = stubLocalStorage({
[`${ACCOUNT_ID}:spaces`]: JSON.stringify([
{ id: ORG_A, name: 'Org A' },
{ id: ORG_B, name: 'Org B' },
]),
[`${ORG_A}:konnect-last-synced-at`]: '1700000000000',
[`${ORG_B}:konnect-last-synced-at`]: '1600000000000',
['org_someone_else:konnect-last-synced-at']: '1500000000000',
});
await createKonnectProject(ORG_A, 'cp-1');
await createKonnectProject(ORG_B, 'cp-2');
await createKonnectProject('org_someone_else', 'cp-other');

await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A });

expect(store.get(`${KONNECT_ORG_ID}:konnect-last-synced-at`)).toBe('1700000000000');
expect(store.get(`${ORG_A}:konnect-last-synced-at`)).toBeUndefined();
expect(store.get(`${ORG_B}:konnect-last-synced-at`)).toBeUndefined();
expect(store.get('org_someone_else:konnect-last-synced-at')).toBe('1500000000000');
});

it('neither re-parents nor deletes regular projects', async () => {
await createKonnectProject(ORG_A, 'cp-1');
const keptRegular = await services.project.create({ name: 'Regular A', parentId: ORG_A });
const discardedRegular = await services.project.create({ name: 'Regular B', parentId: ORG_B });

await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A });

expect((await services.project.getById(keptRegular._id))?.parentId).toBe(ORG_A);
expect((await services.project.getById(discardedRegular._id))?.parentId).toBe(ORG_B);
});
});

describe('migrateKonnectProjectsIfUnambiguous', () => {
it('migrates automatically and is idempotent on a second run', async () => {
await createKonnectProject(ORG_A, 'cp-1');

expect(await migrateKonnectProjectsIfUnambiguous(ACCOUNT_ID)).toEqual({ status: 'none', groups: [] });

const afterFirstRun = await listKonnectProjects();
expect(afterFirstRun.map(p => p.parentId)).toEqual([KONNECT_ORG_ID]);

expect(await migrateKonnectProjectsIfUnambiguous(ACCOUNT_ID)).toEqual({ status: 'none', groups: [] });
expect(await listKonnectProjects()).toHaveLength(1);
});

it('defers to the user when the source organization is ambiguous', async () => {
await createKonnectProject(ORG_A, 'cp-1');
await createKonnectProject(ORG_B, 'cp-2');

const plan = await migrateKonnectProjectsIfUnambiguous(ACCOUNT_ID);

expect(plan.status).toBe('conflict');
// Nothing moved until the user picks one.
const projects = await listKonnectProjects();
expect(projects.map(p => p.parentId).sort()).toEqual([ORG_A, ORG_B]);
});
});
Loading
Loading