diff --git a/docs/todo/conflict-testing.md b/docs/todo/conflict-testing.md index 22fcba47..62adda58 100644 --- a/docs/todo/conflict-testing.md +++ b/docs/todo/conflict-testing.md @@ -428,6 +428,37 @@ local description. Align keeps upstream name + upstream description. | a | Override | User name + user description | - [x] | | b | Align | Upstream name + upstream description | - [x] | +### 1.28 Conditions — dependsOn regex renamed upstream + +**Setup:** A condition references a regex by name. User modifies the condition +(e.g. toggles negate). Upstream renames the referenced regex. + +**Expected:** Conflict (guard_mismatch — condition op references old regex name). +Override should resolve the rename chain and preserve the local condition change. +Align drops local change, upstream rename preserved. + +**E2E spec:** `src/tests/e2e/specs/1.28-cf-condition-depends-on-regex-renamed.spec.ts` + +| # | Strategy | Expected result | Pass | +|---|----------|-----------------|------| +| a | Override | Condition change preserved, regex rename applied | - [ ] | +| b | Align | Upstream rename preserved, local condition change dropped | - [ ] | + +### 1.29 Conditions — dependsOn regex deleted upstream + +**Setup:** A condition references a regex by name. User modifies the condition. +Upstream deletes the referenced regex. + +**Expected:** Conflict. Override resolves but condition referencing deleted regex +is necessarily lost (dependency gone). Align drops local change, regex absent. + +**E2E spec:** `src/tests/e2e/specs/1.29-cf-condition-depends-on-regex-deleted.spec.ts` + +| # | Strategy | Expected result | Pass | +|---|----------|-----------------|------| +| a | Override | Conflict resolves, condition referencing deleted regex lost | - [ ] | +| b | Align | Upstream delete preserved, condition absent | - [ ] | + --- ## 2. Quality Profiles @@ -443,7 +474,7 @@ Comprehensive QP conflict coverage modeled after CF learnings. 5. Qualities conflicts: `2.27`-`2.31` 6. Scoring conflicts: `2.32`-`2.39` 7. Lifecycle (create/delete): `2.40`-`2.43` -8. Dependencies + strategy: `2.44`-`2.46` +8. Dependencies: `2.44`-`2.45` ### 2.1-2.5 Non-Overlapping No-Conflict @@ -518,18 +549,18 @@ Comprehensive QP conflict coverage modeled after CF learnings. | ID | Scenario | Type | Expected | E2E spec | Pass | |---|---|---|---|---|---| -| 2.40 | Create duplicate (general-only payload) | Conflict | Override: local general values. Align: upstream general values | `src/tests/e2e/specs/2.40-qp-create-duplicate-general-only.spec.ts` | - [ ] | -| 2.41 | Local general update while upstream deletes profile | Conflict | Override: profile re-created with local general values. Align: profile stays deleted | `src/tests/e2e/specs/2.41-qp-local-general-update-upstream-deleted.spec.ts` | - [ ] | -| 2.42 | Create duplicate (full payload: general+qualities+scoring) | Conflict | Override: local full desired state. Align: upstream full state | `src/tests/e2e/specs/2.42-qp-create-duplicate-full-payload.spec.ts` | - [ ] | -| 2.43 | Local delete vs upstream general update | No conflict | Delete remains effective; profile absent locally | `src/tests/e2e/specs/2.43-qp-delete-vs-upstream-general-update.spec.ts` | - [ ] | +| 2.40 | Create duplicate (general-only payload) | Conflict | Override: local general values. Align: upstream general values | `src/tests/e2e/specs/2.40-qp-create-duplicate-general-only.spec.ts` | - [x] | +| 2.41 | Local general update while upstream deletes profile | Conflict | Override: profile re-created with local general values. Align: profile stays deleted | `src/tests/e2e/specs/2.41-qp-local-general-update-upstream-deleted.spec.ts` | - [x] | +| 2.42 | Create duplicate (full payload: general+qualities+scoring) | Conflict | Override: local full desired state. Align: upstream full state | `src/tests/e2e/specs/2.42-qp-create-duplicate-full-payload.spec.ts` | - [x] | +| 2.43 | Local delete vs upstream general update | No conflict | Delete remains effective; profile absent locally | `src/tests/e2e/specs/2.43-qp-delete-vs-upstream-general-update.spec.ts` | - [x] | -### 2.44-2.46 Dependencies + Strategy +### 2.44-2.45 Dependencies | ID | Scenario | Type | Expected | E2E spec | Pass | |---|---|---|---|---|---| -| 2.44 | Scoring dependsOn CF renamed upstream | Dependency conflict | Override/align behavior is deterministic and documented for renamed dependency | `src/tests/e2e/specs/2.44-qp-scoring-depends-on-cf-renamed.spec.ts` | - [ ] | -| 2.45 | Scoring dependsOn CF deleted upstream | Dependency conflict | Override/align behavior is deterministic and documented for deleted dependency | `src/tests/e2e/specs/2.45-qp-scoring-depends-on-cf-deleted.spec.ts` | - [ ] | -| 2.46 | DB strategy `conflict_strategy=align` auto-drop | Strategy behavior | Conflicts are auto-dropped during compile and do not surface in UI | `src/tests/e2e/specs/2.46-qp-conflict-strategy-align-auto-drop.spec.ts` | - [ ] | +| 2.44 | Scoring dependsOn CF renamed upstream | Dependency conflict | Override: rename chain resolves, local score preserved. Align: upstream rename, original score | `src/tests/e2e/specs/2.44-qp-scoring-depends-on-cf-renamed.spec.ts` | - [x] | +| 2.45 | Scoring dependsOn CF deleted upstream | Dependency conflict | Override/align: conflict resolves, score lost (CF gone) | `src/tests/e2e/specs/2.45-qp-scoring-depends-on-cf-deleted.spec.ts` | - [x] | +| ~~2.46~~ | ~~DB strategy `conflict_strategy=align` auto-drop~~ | ~~Strategy behavior~~ | Redundant — auto-align is implicitly covered by every align test | N/A | N/A | --- @@ -603,421 +634,27 @@ change description). --- -## 4. Delay Profiles - -### 4.1 Preferred protocol conflict - -**Setup:** User changes `preferred_protocol` from `prefer_usenet` to -`prefer_torrent`. Upstream changes it to `only_usenet`. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | Protocol is `prefer_torrent`. Delay values adjusted per protocol constraints. | - [ ] | -| b | Align | Protocol is `only_usenet`. | - [ ] | - -### 4.2 Delay value conflict - -**Setup:** User changes `usenet_delay` from 60 to 120. Upstream changes it to -30. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | `usenet_delay` is 120. | - [ ] | -| b | Align | `usenet_delay` is 30. | - [ ] | - -### 4.3 Bypass flag conflict - -**Setup:** User enables `bypass_if_above_custom_format_score` and sets -`minimum_custom_format_score` to 50. Upstream changes -`bypass_if_highest_quality`. - -**Conflict:** If both changes are in the same op, guard mismatch on whichever -field upstream changed. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's bypass + minimum score settings. | - [ ] | -| b | Align | Upstream's values. | - [ ] | - -### 4.4 Protocol change nullifies delay - -**Setup:** User changes `usenet_delay` to 120. Upstream changes -`preferred_protocol` to `only_torrent` (which NULLs `usenet_delay`). - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | Protocol stays as upstream's `only_torrent`, but user's desired `usenet_delay` is written. The update function will NULL it because of protocol constraints. Verify the final state makes sense. | - [ ] | -| b | Align | `usenet_delay` is NULL, protocol is `only_torrent`. | - [ ] | - -### 4.5 Name rename conflict - -**Setup:** User renames delay profile. Upstream also renames it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's desired name. | - [ ] | -| b | Align | Upstream's name. | - [ ] | - -### 4.6 Create conflict — duplicate key - -**Setup:** User creates delay profile "Fast". Upstream also creates "Fast". - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | "Fast" has user's desired protocol/delay/bypass values. | - [ ] | -| b | Align | "Fast" has upstream's values. | - [ ] | - -### 4.7 Delete conflict - -**Setup:** User deletes delay profile. Upstream changes it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | Delete op dropped. Profile persists. | - [ ] | -| b | Align | Delete op dropped. Profile persists. | - [ ] | - ---- - -## 5. Naming (Radarr) - -### 5.1 Movie format conflict - -**Setup:** User changes `movie_format` string. Upstream also changes it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's format string. | - [ ] | -| b | Align | Upstream's format string. | - [ ] | - -### 5.2 Rename toggle conflict - -**Setup:** User enables `rename`. Upstream disables it (or both change it). - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | `rename` matches user's desired value. | - [ ] | -| b | Align | `rename` matches upstream. | - [ ] | - -### 5.3 Colon replacement conflict - -**Setup:** User changes `colon_replacement_format` from `dash` to `smart`. -Upstream changes it to `spaceDash`. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | `colon_replacement_format` is `smart`. | - [ ] | -| b | Align | `colon_replacement_format` is `spaceDash`. | - [ ] | - -### 5.4 Multiple field conflict - -**Setup:** User changes `movie_format` and `movie_folder_format`. Upstream -changes `movie_format` to something else. - -**Conflict:** Guard mismatch on `movie_format`. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | Both fields have user's desired values. | - [ ] | -| b | Align | Both fields have upstream's values. | - [ ] | - -### 5.5 Name rename conflict - -**Setup:** User renames naming config. Upstream also renames it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's desired name. | - [ ] | -| b | Align | Upstream's name. | - [ ] | - -### 5.6 Create conflict — duplicate key - -**Setup:** User creates naming config "Default". Upstream also creates "Default". - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | "Default" has user's desired format strings and settings. | - [ ] | -| b | Align | "Default" has upstream's values. | - [ ] | - -### 5.7 Delete conflict - -**Setup:** User deletes naming config. Upstream changes it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | Delete op dropped. Config persists. | - [ ] | -| b | Align | Delete op dropped. Config persists. | - [ ] | - ---- - -## 6. Naming (Sonarr) - -### 6.1 Episode format conflict - -**Setup:** User changes `standard_episode_format`. Upstream also changes it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's format string. | - [ ] | -| b | Align | Upstream's format string. | - [ ] | - -### 6.2 Multi-episode style conflict - -**Setup:** User changes `multi_episode_style` from `extend` to `range`. -Upstream changes it to `scene`. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | `multi_episode_style` is `range`. | - [ ] | -| b | Align | `multi_episode_style` is `scene`. | - [ ] | - -### 6.3 Custom colon replacement conflict - -**Setup:** User sets `colon_replacement_format` to `custom` with a -`custom_colon_replacement_format` value. Upstream changes colon replacement -to `dash`. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's custom colon replacement. | - [ ] | -| b | Align | Upstream's `dash` replacement, custom value NULL. | - [ ] | - -### 6.4 Multiple format fields conflict - -**Setup:** User changes `daily_episode_format` and `anime_episode_format`. -Upstream changes `daily_episode_format`. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | Both have user's desired values. | - [ ] | -| b | Align | Both have upstream's values. | - [ ] | - -### 6.5 Folder format conflict - -**Setup:** User changes `series_folder_format`. Upstream changes it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's folder format. | - [ ] | -| b | Align | Upstream's folder format. | - [ ] | - -### 6.6 Create conflict — duplicate key - -**Setup:** User creates sonarr naming config "Default". Upstream also creates -"Default". - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | "Default" has user's desired format strings and settings. | - [ ] | -| b | Align | "Default" has upstream's values. | - [ ] | - -### 6.7 Delete conflict - -**Setup:** User deletes sonarr naming config. Upstream changes it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | Delete op dropped. Config persists. | - [ ] | -| b | Align | Delete op dropped. Config persists. | - [ ] | - -### 6.8 Name rename conflict - -**Setup:** User renames sonarr naming config. Upstream also renames it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's desired name. | - [ ] | -| b | Align | Upstream's name. | - [ ] | - ---- - -## 7. Media Settings (Radarr) - -### 7.1 Propers/repacks conflict - -**Setup:** User changes `propers_repacks` from `doNotPrefer` to -`preferAndUpgrade`. Upstream changes it to `doNotUpgradeAutomatically`. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | `propers_repacks` is `preferAndUpgrade`. | - [ ] | -| b | Align | `propers_repacks` is `doNotUpgradeAutomatically`. | - [ ] | - -### 7.2 Enable media info conflict - -**Setup:** User toggles `enable_media_info`. Upstream also toggles it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's desired value. | - [ ] | -| b | Align | Upstream's value. | - [ ] | - -### 7.3 Name rename conflict - -**Setup:** User renames media settings config. Upstream also renames it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's desired name. | - [ ] | -| b | Align | Upstream's name. | - [ ] | - -### 7.4 Create conflict — duplicate key - -**Setup:** User creates config "Standard". Upstream also creates "Standard". - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | "Standard" has user's propers_repacks and enable_media_info. | - [ ] | -| b | Align | "Standard" has upstream's values. | - [ ] | - -### 7.5 Delete conflict - -**Setup:** User deletes media settings config. Upstream changes it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | Delete op dropped. Config persists. | - [ ] | -| b | Align | Delete op dropped. Config persists. | - [ ] | - ---- - -## 8. Media Settings (Sonarr) - -### 8.1 Propers/repacks conflict - -**Setup:** Same as 7.1 but for sonarr_media_settings. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | `propers_repacks` is user's desired value. | - [ ] | -| b | Align | `propers_repacks` is upstream's value. | - [ ] | - -### 8.2 Enable media info conflict - -**Setup:** Same as 7.2 but for sonarr_media_settings. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's desired value. | - [ ] | -| b | Align | Upstream's value. | - [ ] | - -### 8.3 Name rename conflict - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's desired name. | - [ ] | -| b | Align | Upstream's name. | - [ ] | - -### 8.4 Create conflict — duplicate key - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's desired values. | - [ ] | -| b | Align | Upstream's values. | - [ ] | - -### 8.5 Delete conflict - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | Delete op dropped. Config persists. | - [ ] | -| b | Align | Delete op dropped. Config persists. | - [ ] | - ---- - -## 9. Quality Definitions (Radarr) - -### 9.1 Entry size change conflict - -**Setup:** User changes `preferred_size` for quality "Bluray-1080p" from 35 to -40. Upstream changes it to 30. - -**Conflict:** Quality definitions update is a full-replace (delete all with -value guards, then re-insert). Guard mismatch on the delete step because -upstream changed the guarded size values. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | "Bluray-1080p" has `preferred_size` 40. All other entries match user's desired list. | - [ ] | -| b | Align | All entries match upstream's values. | - [ ] | - -### 9.2 Entry added by upstream, user changes sizes - -**Setup:** Upstream adds a new quality entry (e.g., "WEBDL-2160p"). User's op -changes sizes for existing entries. Since QD updates delete-all + re-insert, -the delete guards won't match the upstream's new entry. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's full entry list replaces everything. If user's list doesn't include the new entry, it's gone. | - [ ] | -| b | Align | Upstream's full list including the new entry. | - [ ] | - -### 9.3 Name rename conflict - -**Setup:** User renames QD config. Upstream also renames it. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's desired name, user's desired entries. | - [ ] | -| b | Align | Upstream's name and entries. | - [ ] | - -### 9.4 Create conflict — duplicate key - -**Setup:** User creates QD config "Custom Sizes". Upstream creates "Custom -Sizes". - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | "Custom Sizes" has user's entry list. | - [ ] | -| b | Align | "Custom Sizes" has upstream's entry list. | - [ ] | - -### 9.5 Delete conflict - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | Delete op dropped. Config persists. | - [ ] | -| b | Align | Delete op dropped. Config persists. | - [ ] | - ---- - -## 10. Quality Definitions (Sonarr) - -### 10.1 Entry size change conflict - -**Setup:** Same as 9.1 but for sonarr_quality_definitions. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's desired sizes. | - [ ] | -| b | Align | Upstream's sizes. | - [ ] | - -### 10.2 Entry added by upstream, user changes sizes - -**Setup:** Same as 9.2 but for sonarr. - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's full entry list. | - [ ] | -| b | Align | Upstream's full list. | - [ ] | - -### 10.3 Name rename conflict - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's desired name and entries. | - [ ] | -| b | Align | Upstream's name and entries. | - [ ] | - -### 10.4 Create conflict — duplicate key - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | User's entry list. | - [ ] | -| b | Align | Upstream's entry list. | - [ ] | - -### 10.5 Delete conflict - -| # | Strategy | Expected result | Pass | -|---|----------|-----------------|------| -| a | Override | Delete op dropped. Config persists. | - [ ] | -| b | Align | Delete op dropped. Config persists. | - [ ] | +## 4–10. Removed — Local Ops Disabled + +**Decision:** Local ops (user-layer edits) will be disabled for the following +entity types. These entities are small enough to be atomic units — if you're +changing one, you're essentially creating a new one. The conflict resolution +overhead isn't justified. + +- **Delay Profiles** (sections 4) +- **Naming — Radarr** (section 5) +- **Naming — Sonarr** (section 6) +- **Media Settings — Radarr** (section 7) +- **Media Settings — Sonarr** (section 8) +- **Quality Definitions — Radarr** (section 9) +- **Quality Definitions — Sonarr** (section 10) + +**Exception:** Regular Expressions (section 3) keep local ops because CF +conditions reference regexes by name. Without local edit, overriding a pattern +would require creating a new regex and updating every dependent CF condition. + +**TODO:** Implement UI/server guards to block local edits on these entities, +similar to how CF tests and entity testing already enforce read-only mode. --- diff --git a/src/tests/e2e/specs/2.40-qp-create-duplicate-general-only.spec.ts b/src/tests/e2e/specs/2.40-qp-create-duplicate-general-only.spec.ts index 4b5a20dc..d84f1b5d 100644 --- a/src/tests/e2e/specs/2.40-qp-create-duplicate-general-only.spec.ts +++ b/src/tests/e2e/specs/2.40-qp-create-duplicate-general-only.spec.ts @@ -1,11 +1,153 @@ /** - * 2.40 Quality Profile — create duplicate with general-only payload + * 2.40 Quality Profile — create duplicate (general-only payload) * - * Scaffold only: implement full e2e flow from docs/todo/conflict-testing.md. + * Setup: Local creates QP "E2E Duplicate QP" with description A. + * Upstream creates QP with same name but description B. + * Conflict: duplicate_key — UNIQUE constraint on name. + * + * a) Override → QP has local's description + * b) Align → QP has upstream's description, local op dropped */ -import { test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import { TEST_REPO_URL, TEST_PAT, TEST_GIT_NAME, TEST_GIT_EMAIL } from '../env'; +import { linkPcd } from '../helpers/linkPcd'; +import { unlinkPcdByName } from '../helpers/unlinkPcd'; +import { pullChanges, exportAndPush } from '../helpers/sync'; +import { + goToConflicts, + expectConflict, + overrideConflict, + alignConflict +} from '../helpers/conflicts'; +import { goToQualityProfileGeneral } from '../helpers/entity'; +import { fillMarkdownInput } from '../helpers/markdown'; +import { getHead, resetToCommit } from '../helpers/reset'; -test.describe('2.40 QP create duplicate with general-only payload', () => { - test.todo('a) override'); - test.todo('b) align'); +const LOCAL_DB_NAME = 'E2E Local'; +const DEV_DB_NAME = 'E2E Dev'; +const TEST_QP_NAME = 'E2E Duplicate QP'; +const LOCAL_DESCRIPTION = 'Local create description'; +const DEV_DESCRIPTION = 'Upstream create description'; + +async function createQualityProfile( + page: import('@playwright/test').Page, + databaseId: number, + name: string, + description: string +): Promise { + await page.goto(`/quality-profiles/${databaseId}/new`); + await page.waitForLoadState('networkidle'); + await page.locator('input[name="name"]').fill(name); + await fillMarkdownInput(page, 'description', description); + await page.getByRole('button', { name: 'Create' }).click(); + await page.waitForURL(new RegExp(`/quality-profiles/${databaseId}/\\d+/scoring`), { + timeout: 15_000 + }); + await page.waitForLoadState('networkidle'); +} + +test.describe('2.40 QP create duplicate (general-only)', () => { + test.describe.configure({ timeout: 120_000 }); + + let localId: number; + let devId: number; + let devHead: string; + + test.beforeEach(async ({ browser }) => { + const page = await browser.newPage(); + + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + + devId = await linkPcd(page, { + name: DEV_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL + }); + + devHead = getHead(devId); + + localId = await linkPcd(page, { + name: LOCAL_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL, + syncStrategy: 'Manual (no auto-sync)', + autoPull: false, + localOpsEnabled: true, + conflictStrategy: 'Ask every time' + }); + + await page.close(); + }); + + test.afterEach(async ({ browser }) => { + if (devId && devHead) { + try { + resetToCommit(devId, devHead, true); + } catch { + // Best-effort reset + } + } + + const page = await browser.newPage(); + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + await page.close(); + }); + + test('a) override — QP has local description', async ({ page }) => { + // Local creates QP + await createQualityProfile(page, localId, TEST_QP_NAME, LOCAL_DESCRIPTION); + + // Dev creates QP with same name but different description + await createQualityProfile(page, devId, TEST_QP_NAME, DEV_DESCRIPTION); + + // Push upstream + await exportAndPush(page, devId, 'e2e: 2.40 qp create duplicate general only'); + + // Pull into local → conflict + await pullChanges(page, localId); + + // Verify conflict + await goToConflicts(page, localId); + await expectConflict(page, TEST_QP_NAME); + + // Override + await overrideConflict(page, TEST_QP_NAME); + + // Verify: local description wins + await goToQualityProfileGeneral(page, localId, TEST_QP_NAME); + const descriptionText = await page.locator('#description').inputValue(); + expect(descriptionText).toContain(LOCAL_DESCRIPTION); + }); + + test('b) align — QP has upstream description', async ({ page }) => { + // Local creates QP + await createQualityProfile(page, localId, TEST_QP_NAME, LOCAL_DESCRIPTION); + + // Dev creates QP with same name but different description + await createQualityProfile(page, devId, TEST_QP_NAME, DEV_DESCRIPTION); + + // Push upstream + await exportAndPush(page, devId, 'e2e: 2.40 qp create duplicate general only'); + + // Pull into local → conflict + await pullChanges(page, localId); + + // Verify conflict + await goToConflicts(page, localId); + await expectConflict(page, TEST_QP_NAME); + + // Align + await alignConflict(page, TEST_QP_NAME); + + // Verify: upstream description wins + await goToQualityProfileGeneral(page, localId, TEST_QP_NAME); + const descriptionText = await page.locator('#description').inputValue(); + expect(descriptionText).toContain(DEV_DESCRIPTION); + }); }); diff --git a/src/tests/e2e/specs/2.41-qp-local-general-update-upstream-deleted.spec.ts b/src/tests/e2e/specs/2.41-qp-local-general-update-upstream-deleted.spec.ts index 4b531a2f..a9d0506c 100644 --- a/src/tests/e2e/specs/2.41-qp-local-general-update-upstream-deleted.spec.ts +++ b/src/tests/e2e/specs/2.41-qp-local-general-update-upstream-deleted.spec.ts @@ -1,11 +1,192 @@ /** * 2.41 Quality Profile — local general update while upstream deletes profile * - * Scaffold only: implement full e2e flow from docs/todo/conflict-testing.md. + * Setup: Dev seeds QP, both sides pull. Local updates description. + * Upstream deletes the QP and pushes. + * Conflict: guard_mismatch — local UPDATE targets a row that no longer exists. + * + * a) Override → profile re-created with local's general values + * b) Align → profile stays deleted, local op dropped */ -import { test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import { TEST_REPO_URL, TEST_PAT, TEST_GIT_NAME, TEST_GIT_EMAIL } from '../env'; +import { linkPcd } from '../helpers/linkPcd'; +import { unlinkPcdByName } from '../helpers/unlinkPcd'; +import { pullChanges, exportAndPush } from '../helpers/sync'; +import { + goToConflicts, + expectConflict, + overrideConflict, + alignConflict +} from '../helpers/conflicts'; +import { + goToQualityProfileGeneral, + updateQpDescription +} from '../helpers/entity'; +import { fillMarkdownInput } from '../helpers/markdown'; +import { getHead, resetToCommit } from '../helpers/reset'; -test.describe('2.41 QP local general update while upstream deletes profile', () => { - test.todo('a) override'); - test.todo('b) align'); +const LOCAL_DB_NAME = 'E2E Local'; +const DEV_DB_NAME = 'E2E Dev'; +const QP_NAME_PREFIX = 'E2E Update Deleted 2.41'; +const SEED_DESCRIPTION = 'Seed description for 2.41'; +const LOCAL_DESCRIPTION = 'Local update description 2.41'; + +async function createQualityProfile( + page: import('@playwright/test').Page, + databaseId: number, + name: string, + description: string +): Promise { + await page.goto(`/quality-profiles/${databaseId}/new`); + await page.waitForLoadState('networkidle'); + await page.locator('input[name="name"]').fill(name); + await fillMarkdownInput(page, 'description', description); + await page.getByRole('button', { name: 'Create' }).click(); + await page.waitForURL(new RegExp(`/quality-profiles/${databaseId}/\\d+/scoring`), { + timeout: 15_000 + }); + await page.waitForLoadState('networkidle'); +} + +async function deleteQualityProfile( + page: import('@playwright/test').Page, + databaseId: number, + name: string +): Promise { + await goToQualityProfileGeneral(page, databaseId, name); + await page.getByRole('button', { name: 'Delete' }).first().click(); + // Confirm in the modal + await page.getByRole('button', { name: 'Delete' }).last().click(); + await page.waitForURL(new RegExp(`/quality-profiles/${databaseId}$`), { + timeout: 15_000 + }); + await page.waitForLoadState('networkidle'); +} + +async function expectQualityProfileMissing( + page: import('@playwright/test').Page, + databaseId: number, + name: string +): Promise { + await page.goto(`/quality-profiles/${databaseId}`); + await page.waitForLoadState('networkidle'); + await page.getByPlaceholder(/search/i).fill(name); + await page.waitForTimeout(500); + await expect(page.locator('table tbody tr', { hasText: name })).toHaveCount(0); +} + +test.describe('2.41 QP local general update while upstream deletes', () => { + test.describe.configure({ timeout: 120_000 }); + + let localId: number; + let devId: number; + let devHead: string; + let testQpName: string; + + test.beforeEach(async ({ browser }) => { + const page = await browser.newPage(); + const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + testQpName = `${QP_NAME_PREFIX} ${runId}`; + + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + + devId = await linkPcd(page, { + name: DEV_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL + }); + + devHead = getHead(devId); + + localId = await linkPcd(page, { + name: LOCAL_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL, + syncStrategy: 'Manual (no auto-sync)', + autoPull: false, + localOpsEnabled: true, + conflictStrategy: 'Ask every time' + }); + + await page.close(); + }); + + test.afterEach(async ({ browser }) => { + if (devId && devHead) { + try { + resetToCommit(devId, devHead, true); + } catch { + // Best-effort reset + } + } + + const page = await browser.newPage(); + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + await page.close(); + }); + + test('a) override — profile re-created with local values', async ({ page }) => { + // Dev creates QP and pushes + await createQualityProfile(page, devId, testQpName, SEED_DESCRIPTION); + await exportAndPush(page, devId, 'e2e: 2.41 seed QP'); + + // Local pulls seed QP + await pullChanges(page, localId); + + // Local updates description + await goToQualityProfileGeneral(page, localId, testQpName); + await updateQpDescription(page, LOCAL_DESCRIPTION); + + // Dev deletes QP and pushes + await deleteQualityProfile(page, devId, testQpName); + await exportAndPush(page, devId, 'e2e: 2.41 delete QP'); + + // Local pulls → conflict + await pullChanges(page, localId); + await goToConflicts(page, localId); + await expectConflict(page, testQpName); + + // Override + await overrideConflict(page, testQpName); + + // Verify: profile exists with local's description + await goToQualityProfileGeneral(page, localId, testQpName); + const descriptionText = await page.locator('#description').inputValue(); + expect(descriptionText).toContain(LOCAL_DESCRIPTION); + }); + + test('b) align — profile stays deleted', async ({ page }) => { + // Dev creates QP and pushes + await createQualityProfile(page, devId, testQpName, SEED_DESCRIPTION); + await exportAndPush(page, devId, 'e2e: 2.41 seed QP'); + + // Local pulls seed QP + await pullChanges(page, localId); + + // Local updates description + await goToQualityProfileGeneral(page, localId, testQpName); + await updateQpDescription(page, LOCAL_DESCRIPTION); + + // Dev deletes QP and pushes + await deleteQualityProfile(page, devId, testQpName); + await exportAndPush(page, devId, 'e2e: 2.41 delete QP'); + + // Local pulls → conflict + await pullChanges(page, localId); + await goToConflicts(page, localId); + await expectConflict(page, testQpName); + + // Align + await alignConflict(page, testQpName); + + // Verify: profile stays deleted + await expectQualityProfileMissing(page, localId, testQpName); + }); }); diff --git a/src/tests/e2e/specs/2.42-qp-create-duplicate-full-payload.spec.ts b/src/tests/e2e/specs/2.42-qp-create-duplicate-full-payload.spec.ts index c55cd5df..4117b0a0 100644 --- a/src/tests/e2e/specs/2.42-qp-create-duplicate-full-payload.spec.ts +++ b/src/tests/e2e/specs/2.42-qp-create-duplicate-full-payload.spec.ts @@ -1,11 +1,336 @@ /** - * 2.42 Quality Profile — create duplicate with full payload + * 2.42 Quality Profile — create duplicate (full payload: general+qualities+scoring) * - * Scaffold only: implement full e2e flow from docs/todo/conflict-testing.md. + * Setup: Local creates QP, then edits scoring (minimumScore=50) and + * reorders qualities (move first down one). + * Dev creates same-named QP with different description, + * minimumScore=100, and a different quality reorder (first down two). + * Dev pushes. + * Conflict: duplicate_key on CREATE + guard_mismatch on qualities + scoring. + * + * a) Override all → local description, local minimumScore, local qualities order + * b) Align all → upstream description, upstream minimumScore, upstream qualities order */ -import { test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import { TEST_REPO_URL, TEST_PAT, TEST_GIT_NAME, TEST_GIT_EMAIL } from '../env'; +import { linkPcd } from '../helpers/linkPcd'; +import { unlinkPcdByName } from '../helpers/unlinkPcd'; +import { pullChanges, exportAndPush } from '../helpers/sync'; +import { + goToConflicts, + expectConflict, + overrideConflict, + alignConflict, + findConflictRow +} from '../helpers/conflicts'; +import { + goToQualityProfileGeneral, + goToQualityProfileQualities, + goToQualityProfileScoring +} from '../helpers/entity'; +import { fillMarkdownInput } from '../helpers/markdown'; +import { getHead, resetToCommit } from '../helpers/reset'; -test.describe('2.42 QP create duplicate with full payload', () => { - test.todo('a) override'); - test.todo('b) align'); +const LOCAL_DB_NAME = 'E2E Local'; +const DEV_DB_NAME = 'E2E Dev'; +const QP_NAME_PREFIX = 'E2E Dup Full 2.42'; +const LOCAL_DESCRIPTION = 'Local full payload desc 2.42'; +const DEV_DESCRIPTION = 'Dev full payload desc 2.42'; +const LOCAL_MIN_SCORE = 50; +const DEV_MIN_SCORE = 100; +const MOBILE_VIEWPORT = { width: 600, height: 800 }; +const DESKTOP_VIEWPORT = { width: 1280, height: 720 }; + +async function createQualityProfile( + page: import('@playwright/test').Page, + databaseId: number, + name: string, + description: string +): Promise { + await page.goto(`/quality-profiles/${databaseId}/new`); + await page.waitForLoadState('networkidle'); + await page.locator('input[name="name"]').fill(name); + await fillMarkdownInput(page, 'description', description); + await page.getByRole('button', { name: 'Create' }).click(); + await page.waitForURL(new RegExp(`/quality-profiles/${databaseId}/\\d+/scoring`), { + timeout: 15_000 + }); + await page.waitForLoadState('networkidle'); +} + +/** Read the ordered quality names from the qualities page. */ +async function getQualityOrder(page: import('@playwright/test').Page): Promise { + const rows = page.locator('div.space-y-4 > div[role="button"]'); + const count = await rows.count(); + const names: string[] = []; + for (let i = 0; i < count; i++) { + const name = (await rows.nth(i).locator('div.font-medium').first().innerText()).trim(); + names.push(name); + } + return names; +} + +/** Click the move-down button on a quality row (mobile view). */ +async function moveQualityDown( + page: import('@playwright/test').Page, + index: number +): Promise { + const row = page.locator('div.space-y-4 > div[role="button"]').nth(index); + const mobileButtons = row.locator('.md\\:hidden button'); + await mobileButtons.last().click(); + await page.waitForTimeout(200); +} + +/** Override or align all conflict rows for an entity until none remain. + * Reloads the conflicts page between rounds so recompile-generated + * conflicts are picked up (avoids race where count briefly hits 0). + * Returns the number of rounds executed. + */ +async function resolveAllConflicts( + page: import('@playwright/test').Page, + databaseId: number, + entityName: string, + action: 'Override' | 'Align' +): Promise { + const resolve = action === 'Override' ? overrideConflict : alignConflict; + let round = 0; + for (; round < 10; round++) { + await page.goto(`/databases/${databaseId}/conflicts`); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + const count = await findConflictRow(page, entityName).count(); + if (count === 0) break; + await resolve(page, entityName); + } + return round; +} + +test.describe('2.42 QP create duplicate (full payload)', () => { + test.describe.configure({ timeout: 180_000 }); + + let localId: number; + let devId: number; + let devHead: string; + let testQpName: string; + + test.beforeEach(async ({ browser }) => { + const page = await browser.newPage(); + const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + testQpName = `${QP_NAME_PREFIX} ${runId}`; + + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + + devId = await linkPcd(page, { + name: DEV_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL + }); + + devHead = getHead(devId); + + localId = await linkPcd(page, { + name: LOCAL_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL, + syncStrategy: 'Manual (no auto-sync)', + autoPull: false, + localOpsEnabled: true, + conflictStrategy: 'Ask every time' + }); + + await page.close(); + }); + + test.afterEach(async ({ browser }) => { + if (devId && devHead) { + try { + resetToCommit(devId, devHead, true); + } catch { + // Best-effort reset + } + } + + const page = await browser.newPage(); + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + await page.close(); + }); + + test('a) override — local full state wins', async ({ page }) => { + // --- Local: create QP → edit scoring → edit qualities --- + + await createQualityProfile(page, localId, testQpName, LOCAL_DESCRIPTION); + + // Already on scoring page after create — change minimumScore + const localMinInput = page.locator('input[name="minimumScore"]:not([type="hidden"])'); + await expect(localMinInput).toBeVisible({ timeout: 10_000 }); + await localMinInput.fill(String(LOCAL_MIN_SCORE)); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Switch to qualities page (mobile viewport for move buttons) + await goToQualityProfileQualities(page, localId, testQpName); + await page.setViewportSize(MOBILE_VIEWPORT); + await page.waitForTimeout(300); + const originalOrder = await getQualityOrder(page); + expect(originalOrder.length).toBeGreaterThanOrEqual(3); + + // Local: move first quality down one position + await moveQualityDown(page, 0); + const localOrder = await getQualityOrder(page); + expect(localOrder[0]).toBe(originalOrder[1]); + expect(localOrder[1]).toBe(originalOrder[0]); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + await page.setViewportSize(DESKTOP_VIEWPORT); + + // Verify quality reorder was saved by reloading + await goToQualityProfileQualities(page, localId, testQpName); + const savedOrder = await getQualityOrder(page); + expect(savedOrder[0]).toBe(localOrder[0]); + expect(savedOrder[1]).toBe(localOrder[1]); + + // --- Dev: create same-named QP → edit scoring → edit qualities --- + + await createQualityProfile(page, devId, testQpName, DEV_DESCRIPTION); + + // Dev: change minimumScore + const devMinInput = page.locator('input[name="minimumScore"]:not([type="hidden"])'); + await expect(devMinInput).toBeVisible({ timeout: 10_000 }); + await devMinInput.fill(String(DEV_MIN_SCORE)); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Dev: reorder qualities (move first down two) + await goToQualityProfileQualities(page, devId, testQpName); + await page.setViewportSize(MOBILE_VIEWPORT); + await page.waitForTimeout(300); + await moveQualityDown(page, 0); + await moveQualityDown(page, 1); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + await page.setViewportSize(DESKTOP_VIEWPORT); + + // --- Push upstream --- + await exportAndPush(page, devId, 'e2e: 2.42 create duplicate full payload'); + + // --- Pull into local → conflict(s) --- + await pullChanges(page, localId); + await goToConflicts(page, localId); + await expectConflict(page, testQpName); + + // Expect at least 2 conflicts (CREATE + scoring; qualities may also conflict) + const initialConflicts = await findConflictRow(page, testQpName).count(); + expect(initialConflicts).toBeGreaterThanOrEqual(2); + + // Override all conflicts for this entity + const rounds = await resolveAllConflicts(page, localId, testQpName, 'Override'); + expect(rounds).toBeGreaterThanOrEqual(2); + + // Verify all conflicts resolved + await page.goto(`/databases/${localId}/conflicts`); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + expect(await findConflictRow(page, testQpName).count()).toBe(0); + + // --- Verify: local full state --- + + // General: local description + await goToQualityProfileGeneral(page, localId, testQpName); + const descText = await page.locator('#description').inputValue(); + expect(descText).toContain(LOCAL_DESCRIPTION); + + // Scoring: local minimumScore + await goToQualityProfileScoring(page, localId, testQpName); + const finalMin = page.locator('input[name="minimumScore"]:not([type="hidden"])'); + expect(Number(await finalMin.inputValue())).toBe(LOCAL_MIN_SCORE); + + // Qualities: local order (first two swapped from original) + await goToQualityProfileQualities(page, localId, testQpName); + const finalOrder = await getQualityOrder(page); + expect(finalOrder[0]).toBe(localOrder[0]); + expect(finalOrder[1]).toBe(localOrder[1]); + }); + + test('b) align — upstream full state wins', async ({ page }) => { + // --- Local: create QP → edit scoring → edit qualities --- + + await createQualityProfile(page, localId, testQpName, LOCAL_DESCRIPTION); + + const localMinInput = page.locator('input[name="minimumScore"]:not([type="hidden"])'); + await expect(localMinInput).toBeVisible({ timeout: 10_000 }); + await localMinInput.fill(String(LOCAL_MIN_SCORE)); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + await goToQualityProfileQualities(page, localId, testQpName); + await page.setViewportSize(MOBILE_VIEWPORT); + await page.waitForTimeout(300); + await moveQualityDown(page, 0); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + await page.setViewportSize(DESKTOP_VIEWPORT); + + // --- Dev: create same-named QP → edit scoring → edit qualities --- + + await createQualityProfile(page, devId, testQpName, DEV_DESCRIPTION); + + const devMinInput = page.locator('input[name="minimumScore"]:not([type="hidden"])'); + await expect(devMinInput).toBeVisible({ timeout: 10_000 }); + await devMinInput.fill(String(DEV_MIN_SCORE)); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + await goToQualityProfileQualities(page, devId, testQpName); + await page.setViewportSize(MOBILE_VIEWPORT); + await page.waitForTimeout(300); + await moveQualityDown(page, 0); + await moveQualityDown(page, 1); + const devOrder = await getQualityOrder(page); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + await page.setViewportSize(DESKTOP_VIEWPORT); + + // --- Push upstream --- + await exportAndPush(page, devId, 'e2e: 2.42 create duplicate full payload'); + + // --- Pull into local → conflict(s) --- + await pullChanges(page, localId); + await goToConflicts(page, localId); + await expectConflict(page, testQpName); + + // Align all conflicts for this entity + const rounds = await resolveAllConflicts(page, localId, testQpName, 'Align'); + expect(rounds).toBeGreaterThanOrEqual(1); + + // Verify all conflicts resolved + await page.goto(`/databases/${localId}/conflicts`); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + expect(await findConflictRow(page, testQpName).count()).toBe(0); + + // --- Verify: upstream full state --- + + // General: upstream description + await goToQualityProfileGeneral(page, localId, testQpName); + const descText = await page.locator('#description').inputValue(); + expect(descText).toContain(DEV_DESCRIPTION); + + // Scoring: upstream minimumScore + await goToQualityProfileScoring(page, localId, testQpName); + const finalMin = page.locator('input[name="minimumScore"]:not([type="hidden"])'); + expect(Number(await finalMin.inputValue())).toBe(DEV_MIN_SCORE); + + // Qualities: upstream order + await goToQualityProfileQualities(page, localId, testQpName); + const finalOrder = await getQualityOrder(page); + expect(finalOrder[0]).toBe(devOrder[0]); + expect(finalOrder[1]).toBe(devOrder[1]); + expect(finalOrder[2]).toBe(devOrder[2]); + }); }); diff --git a/src/tests/e2e/specs/2.43-qp-delete-vs-upstream-general-update.spec.ts b/src/tests/e2e/specs/2.43-qp-delete-vs-upstream-general-update.spec.ts index 315d1ec7..53bec695 100644 --- a/src/tests/e2e/specs/2.43-qp-delete-vs-upstream-general-update.spec.ts +++ b/src/tests/e2e/specs/2.43-qp-delete-vs-upstream-general-update.spec.ts @@ -1,10 +1,125 @@ /** * 2.43 Quality Profile — local delete vs upstream general update * - * Scaffold only: implement full e2e flow from docs/todo/conflict-testing.md. + * Setup: Both sides have a QP. Local deletes it. + * Upstream updates description and pushes. + * Expected: No conflict. Delete guard (name) still matches. + * Profile absent locally. */ -import { test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import { TEST_REPO_URL, TEST_PAT, TEST_GIT_NAME, TEST_GIT_EMAIL } from '../env'; +import { linkPcd } from '../helpers/linkPcd'; +import { unlinkPcdByName } from '../helpers/unlinkPcd'; +import { pullChanges, exportAndPush } from '../helpers/sync'; +import { goToConflicts, expectNoConflict } from '../helpers/conflicts'; +import { + openFirstQualityProfileGeneral, + goToQualityProfileGeneral, + updateQpDescription +} from '../helpers/entity'; +import { getHead, resetToCommit } from '../helpers/reset'; + +const LOCAL_DB_NAME = 'E2E Local'; +const DEV_DB_NAME = 'E2E Dev'; +const DEV_DESCRIPTION = 'Upstream description update 2.43'; + +async function deleteQualityProfile( + page: import('@playwright/test').Page, + databaseId: number, + name: string +): Promise { + await goToQualityProfileGeneral(page, databaseId, name); + await page.getByRole('button', { name: 'Delete' }).first().click(); + await page.getByRole('button', { name: 'Delete' }).last().click(); + await page.waitForURL(new RegExp(`/quality-profiles/${databaseId}$`), { timeout: 15_000 }); + await page.waitForLoadState('networkidle'); +} + +async function expectQualityProfileMissing( + page: import('@playwright/test').Page, + databaseId: number, + name: string +): Promise { + await page.goto(`/quality-profiles/${databaseId}`); + await page.waitForLoadState('networkidle'); + await page.getByPlaceholder(/search/i).fill(name); + await page.waitForTimeout(500); + await expect(page.locator('table tbody tr', { hasText: name })).toHaveCount(0); +} test.describe('2.43 QP local delete vs upstream general update', () => { - test.todo('no conflict'); + test.describe.configure({ timeout: 120_000 }); + + let localId: number; + let devId: number; + let devHead: string; + let profileName: string; + + test.beforeEach(async ({ browser }) => { + const page = await browser.newPage(); + + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + + devId = await linkPcd(page, { + name: DEV_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL + }); + + devHead = getHead(devId); + + localId = await linkPcd(page, { + name: LOCAL_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL, + syncStrategy: 'Manual (no auto-sync)', + autoPull: false, + localOpsEnabled: true, + conflictStrategy: 'Ask every time' + }); + + profileName = await openFirstQualityProfileGeneral(page, localId); + await page.close(); + }); + + test.afterEach(async ({ browser }) => { + if (devId && devHead) { + try { + resetToCommit(devId, devHead, true); + } catch { + // Best-effort reset + } + } + + const page = await browser.newPage(); + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + await page.close(); + }); + + test('no conflict — delete remains effective', async ({ page }) => { + // Local deletes quality profile + await deleteQualityProfile(page, localId, profileName); + + // Dev updates description on the same profile + await goToQualityProfileGeneral(page, devId, profileName); + await updateQpDescription(page, DEV_DESCRIPTION); + + // Push upstream update + await exportAndPush(page, devId, 'e2e: 2.43 upstream description update'); + + // Pull into local → no conflict expected + await pullChanges(page, localId); + + await goToConflicts(page, localId); + await expectNoConflict(page, profileName); + + // Verify profile is absent locally + await expectQualityProfileMissing(page, localId, profileName); + }); }); diff --git a/src/tests/e2e/specs/2.44-qp-scoring-depends-on-cf-renamed.spec.ts b/src/tests/e2e/specs/2.44-qp-scoring-depends-on-cf-renamed.spec.ts index 9460b99c..8a3fe78f 100644 --- a/src/tests/e2e/specs/2.44-qp-scoring-depends-on-cf-renamed.spec.ts +++ b/src/tests/e2e/specs/2.44-qp-scoring-depends-on-cf-renamed.spec.ts @@ -1,11 +1,226 @@ /** - * 2.44 Quality Profile — scoring depends on custom format renamed upstream + * 2.44 Quality Profile — scoring dependsOn CF renamed upstream * - * Scaffold only: implement full e2e flow from docs/todo/conflict-testing.md. + * Setup: Local changes a CF's score in a QP (score → original+10). + * Upstream renames that same CF. + * Conflict: guard_mismatch — local scoring op references old CF name + * which no longer exists in the scoring table after rename. + * + * a) Override → rename chain resolves old CF name to new name, + * local score is preserved on the renamed CF. + * b) Align → upstream rename preserved, original score */ -import { test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import { TEST_REPO_URL, TEST_PAT, TEST_GIT_NAME, TEST_GIT_EMAIL } from '../env'; +import { linkPcd } from '../helpers/linkPcd'; +import { unlinkPcdByName } from '../helpers/unlinkPcd'; +import { pullChanges, exportAndPush } from '../helpers/sync'; +import { + goToConflicts, + expectConflict, + expectNoConflict, + alignConflict +} from '../helpers/conflicts'; +import { + openFirstQualityProfileGeneral, + goToQualityProfileScoring, + goToCustomFormatGeneral, + updateCfName +} from '../helpers/entity'; +import { getHead, resetToCommit } from '../helpers/reset'; -test.describe('2.44 QP scoring depends on custom format renamed upstream', () => { - test.todo('a) override'); - test.todo('b) align'); +const LOCAL_DB_NAME = 'E2E Local'; +const DEV_DB_NAME = 'E2E Dev'; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Find the first enabled scoring row and return its format name + cell locators. */ +async function findFirstEnabledScoringRow(page: Page): Promise<{ + formatName: string; + scoreInput: Locator; +}> { + const rows = page.locator('table tbody tr'); + await expect(rows.first()).toBeVisible({ timeout: 15_000 }); + + const count = await rows.count(); + for (let i = 0; i < count; i++) { + const row = rows.nth(i); + const nameCell = row.locator('td').first(); + const text = (await nameCell.innerText()).trim(); + if (!text) continue; + + const scoreCell = row.locator('td').nth(1); + const enabledCheckbox = scoreCell.locator('[role="checkbox"]').first(); + const scoreInput = scoreCell.locator('input[type="number"]').first(); + if (!(await enabledCheckbox.isVisible()) || !(await scoreInput.isVisible())) continue; + + const enabled = (await enabledCheckbox.getAttribute('aria-checked')) === 'true'; + if (!enabled) continue; + + return { formatName: text, scoreInput }; + } + + throw new Error('No enabled scoring row found'); +} + +/** Find a scoring cell by format name and return its score input. */ +async function findScoringCellByFormat( + page: Page, + formatName: string +): Promise<{ scoreInput: Locator }> { + const exact = new RegExp(`^${escapeRegex(formatName)}$`); + const row = page + .locator('table tbody tr') + .filter({ has: page.locator('td').first().filter({ hasText: exact }) }) + .first(); + await expect(row).toBeVisible({ timeout: 15_000 }); + const scoreCell = row.locator('td').nth(1); + return { + scoreInput: scoreCell.locator('input[type="number"]').first() + }; +} + +test.describe('2.44 QP scoring dependsOn CF renamed upstream', () => { + test.describe.configure({ timeout: 120_000 }); + + let localId: number; + let devId: number; + let devHead: string; + let profileName: string; + + test.beforeEach(async ({ browser }) => { + const page = await browser.newPage(); + + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + + devId = await linkPcd(page, { + name: DEV_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL + }); + + devHead = getHead(devId); + + localId = await linkPcd(page, { + name: LOCAL_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL, + syncStrategy: 'Manual (no auto-sync)', + autoPull: false, + localOpsEnabled: true, + conflictStrategy: 'Ask every time' + }); + + profileName = await openFirstQualityProfileGeneral(page, localId); + await page.close(); + }); + + test.afterEach(async ({ browser }) => { + if (devId && devHead) { + try { + resetToCommit(devId, devHead, true); + } catch { + // Best-effort reset + } + } + + const page = await browser.newPage(); + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + await page.close(); + }); + + test('a) override — local score preserved on renamed CF', async ({ page }) => { + // Find first enabled scoring row on local + await goToQualityProfileScoring(page, localId, profileName); + const { formatName, scoreInput } = await findFirstEnabledScoringRow(page); + const original = Number(await scoreInput.inputValue()); + const localValue = original + 10; + const renamedCfName = `${formatName} E2E Renamed`; + + // Local: change score + await scoreInput.fill(String(localValue)); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Dev: rename the CF + await goToCustomFormatGeneral(page, devId, formatName); + await updateCfName(page, renamedCfName); + + // Push upstream + await exportAndPush(page, devId, 'e2e: 2.44 cf rename upstream'); + + // Pull into local → conflict + await pullChanges(page, localId); + + // Verify conflict + await goToConflicts(page, localId); + await expectConflict(page, profileName); + + // Override — rename chain resolves old CF name → new name, + // so the local score is correctly applied to the renamed CF. + const rows = page.locator('table tbody tr', { hasText: profileName }); + const overrideResponsePromise = page.waitForResponse((r) => { + if (r.request().method() !== 'POST') return false; + const url = r.url(); + return url.includes('/conflicts?/override') || url.includes('/conflicts?%2Foverride'); + }); + await rows.first().getByRole('button', { name: 'Override' }).click(); + await overrideResponsePromise; + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + + // Verify: conflict resolved + await goToConflicts(page, localId); + await expectNoConflict(page, profileName); + + // Verify: renamed CF has LOCAL score (override preserved it) + await goToQualityProfileScoring(page, localId, profileName); + const final = await findScoringCellByFormat(page, renamedCfName); + expect(Number(await final.scoreInput.inputValue())).toBe(localValue); + }); + + test('b) align — upstream rename with original score', async ({ page }) => { + // Find first enabled scoring row on local + await goToQualityProfileScoring(page, localId, profileName); + const { formatName, scoreInput } = await findFirstEnabledScoringRow(page); + const original = Number(await scoreInput.inputValue()); + const localValue = original + 10; + const renamedCfName = `${formatName} E2E Renamed`; + + // Local: change score + await scoreInput.fill(String(localValue)); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Dev: rename the CF + await goToCustomFormatGeneral(page, devId, formatName); + await updateCfName(page, renamedCfName); + + // Push upstream + await exportAndPush(page, devId, 'e2e: 2.44 cf rename upstream'); + + // Pull into local → conflict + await pullChanges(page, localId); + + // Verify conflict + await goToConflicts(page, localId); + await expectConflict(page, profileName); + + // Align + await alignConflict(page, profileName); + + // Verify: upstream rename preserved, original score + await goToQualityProfileScoring(page, localId, profileName); + const final = await findScoringCellByFormat(page, renamedCfName); + expect(Number(await final.scoreInput.inputValue())).toBe(original); + }); }); diff --git a/src/tests/e2e/specs/2.45-qp-scoring-depends-on-cf-deleted.spec.ts b/src/tests/e2e/specs/2.45-qp-scoring-depends-on-cf-deleted.spec.ts index b6a95688..c0ef64ef 100644 --- a/src/tests/e2e/specs/2.45-qp-scoring-depends-on-cf-deleted.spec.ts +++ b/src/tests/e2e/specs/2.45-qp-scoring-depends-on-cf-deleted.spec.ts @@ -1,11 +1,225 @@ /** - * 2.45 Quality Profile — scoring depends on custom format deleted upstream + * 2.45 Quality Profile — scoring dependsOn CF deleted upstream * - * Scaffold only: implement full e2e flow from docs/todo/conflict-testing.md. + * Setup: Local changes a CF's score in a QP (score → original+10). + * Upstream deletes that same CF. + * Conflict: guard_mismatch — local scoring op references a CF name + * that no longer exists after the upstream delete. + * + * a) Override → conflict resolves, local score necessarily lost + * (dependency gone — CF no longer exists). + * Deleted CF absent from scoring table. + * b) Align → upstream delete preserved, CF absent from scoring */ -import { test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import { TEST_REPO_URL, TEST_PAT, TEST_GIT_NAME, TEST_GIT_EMAIL } from '../env'; +import { linkPcd } from '../helpers/linkPcd'; +import { unlinkPcdByName } from '../helpers/unlinkPcd'; +import { pullChanges, exportAndPush } from '../helpers/sync'; +import { + goToConflicts, + expectConflict, + expectNoConflict, + alignConflict +} from '../helpers/conflicts'; +import { + openFirstQualityProfileGeneral, + goToQualityProfileScoring, + goToCustomFormat +} from '../helpers/entity'; +import { getHead, resetToCommit } from '../helpers/reset'; -test.describe('2.45 QP scoring depends on custom format deleted upstream', () => { - test.todo('a) override'); - test.todo('b) align'); +const LOCAL_DB_NAME = 'E2E Local'; +const DEV_DB_NAME = 'E2E Dev'; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Find the first enabled scoring row and return its format name + cell locators. */ +async function findFirstEnabledScoringRow(page: Page): Promise<{ + formatName: string; + scoreInput: Locator; +}> { + const rows = page.locator('table tbody tr'); + await expect(rows.first()).toBeVisible({ timeout: 15_000 }); + + const count = await rows.count(); + for (let i = 0; i < count; i++) { + const row = rows.nth(i); + const nameCell = row.locator('td').first(); + const text = (await nameCell.innerText()).trim(); + if (!text) continue; + + const scoreCell = row.locator('td').nth(1); + const enabledCheckbox = scoreCell.locator('[role="checkbox"]').first(); + const scoreInput = scoreCell.locator('input[type="number"]').first(); + if (!(await enabledCheckbox.isVisible()) || !(await scoreInput.isVisible())) continue; + + const enabled = (await enabledCheckbox.getAttribute('aria-checked')) === 'true'; + if (!enabled) continue; + + return { formatName: text, scoreInput }; + } + + throw new Error('No enabled scoring row found'); +} + +/** Check that a CF name is absent from the scoring table. */ +async function expectScoringRowAbsent(page: Page, formatName: string): Promise { + const exact = new RegExp(`^${escapeRegex(formatName)}$`); + const rows = page + .locator('table tbody tr') + .filter({ has: page.locator('td').first().filter({ hasText: exact }) }); + await expect(rows).toHaveCount(0); +} + +/** Delete a custom format via the UI. */ +async function deleteCustomFormat( + page: Page, + databaseId: number, + name: string +): Promise { + await goToCustomFormat(page, databaseId, name); + await page.getByRole('button', { name: 'Delete' }).first().click(); + await page.getByRole('button', { name: 'Delete' }).last().click(); + await page.waitForURL(new RegExp(`/custom-formats/${databaseId}$`), { timeout: 15_000 }); + await page.waitForLoadState('networkidle'); +} + +test.describe('2.45 QP scoring dependsOn CF deleted upstream', () => { + test.describe.configure({ timeout: 120_000 }); + + let localId: number; + let devId: number; + let devHead: string; + let profileName: string; + + test.beforeEach(async ({ browser }) => { + const page = await browser.newPage(); + + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + + devId = await linkPcd(page, { + name: DEV_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL + }); + + devHead = getHead(devId); + + localId = await linkPcd(page, { + name: LOCAL_DB_NAME, + repoUrl: TEST_REPO_URL, + pat: TEST_PAT, + gitName: TEST_GIT_NAME, + gitEmail: TEST_GIT_EMAIL, + syncStrategy: 'Manual (no auto-sync)', + autoPull: false, + localOpsEnabled: true, + conflictStrategy: 'Ask every time' + }); + + profileName = await openFirstQualityProfileGeneral(page, localId); + await page.close(); + }); + + test.afterEach(async ({ browser }) => { + if (devId && devHead) { + try { + resetToCommit(devId, devHead, true); + } catch { + // Best-effort reset + } + } + + const page = await browser.newPage(); + await unlinkPcdByName(page, LOCAL_DB_NAME); + await unlinkPcdByName(page, DEV_DB_NAME); + await page.close(); + }); + + test('a) override — conflict resolves, score lost (CF deleted)', async ({ page }) => { + // Find first enabled scoring row on local + await goToQualityProfileScoring(page, localId, profileName); + const { formatName, scoreInput } = await findFirstEnabledScoringRow(page); + const original = Number(await scoreInput.inputValue()); + const localValue = original + 10; + + // Local: change score + await scoreInput.fill(String(localValue)); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Dev: delete the CF + await deleteCustomFormat(page, devId, formatName); + + // Push upstream + await exportAndPush(page, devId, 'e2e: 2.45 cf delete upstream'); + + // Pull into local → conflict + await pullChanges(page, localId); + + // Verify conflict + await goToConflicts(page, localId); + await expectConflict(page, profileName); + + // Override — CF is gone so score cannot be re-applied. + // Writer FK validation rejects the INSERT for the deleted CF. + const rows = page.locator('table tbody tr', { hasText: profileName }); + const overrideResponsePromise = page.waitForResponse((r) => { + if (r.request().method() !== 'POST') return false; + const url = r.url(); + return url.includes('/conflicts?/override') || url.includes('/conflicts?%2Foverride'); + }); + await rows.first().getByRole('button', { name: 'Override' }).click(); + await overrideResponsePromise; + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + + // Verify: conflict resolved + await goToConflicts(page, localId); + await expectNoConflict(page, profileName); + + // Verify: deleted CF is absent from scoring table + await goToQualityProfileScoring(page, localId, profileName); + await expectScoringRowAbsent(page, formatName); + }); + + test('b) align — CF absent from scoring', async ({ page }) => { + // Find first enabled scoring row on local + await goToQualityProfileScoring(page, localId, profileName); + const { formatName, scoreInput } = await findFirstEnabledScoringRow(page); + const original = Number(await scoreInput.inputValue()); + const localValue = original + 10; + + // Local: change score + await scoreInput.fill(String(localValue)); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Dev: delete the CF + await deleteCustomFormat(page, devId, formatName); + + // Push upstream + await exportAndPush(page, devId, 'e2e: 2.45 cf delete upstream'); + + // Pull into local → conflict + await pullChanges(page, localId); + + // Verify conflict + await goToConflicts(page, localId); + await expectConflict(page, profileName); + + // Align + await alignConflict(page, profileName); + + // Verify: deleted CF is absent from scoring table + await goToQualityProfileScoring(page, localId, profileName); + await expectScoringRowAbsent(page, formatName); + }); }); diff --git a/src/tests/e2e/specs/2.46-qp-conflict-strategy-align-auto-drop.spec.ts b/src/tests/e2e/specs/2.46-qp-conflict-strategy-align-auto-drop.spec.ts deleted file mode 100644 index 1c770b6d..00000000 --- a/src/tests/e2e/specs/2.46-qp-conflict-strategy-align-auto-drop.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * 2.46 Quality Profile — database conflict strategy align auto-drop behavior - * - * Scaffold only: implement full e2e flow from docs/todo/conflict-testing.md. - */ -import { test } from '@playwright/test'; - -test.describe('2.46 QP database conflict strategy align auto-drop behavior', () => { - test.todo('strategy behavior'); -});