test(e2e): implement QP lifecycle and dependency tests 2.40–2.45

This commit is contained in:
Sam Chau
2026-02-11 01:37:00 +10:30
parent de616e31ab
commit 0d9bdff0fb
8 changed files with 1285 additions and 466 deletions

View File

@@ -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. | - [ ] |
## 410. 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.
---

View File

@@ -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<void> {
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);
});
});

View File

@@ -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<void> {
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<void> {
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<void> {
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);
});
});

View File

@@ -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<void> {
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<string[]> {
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<void> {
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<number> {
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]);
});
});

View File

@@ -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<void> {
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<void> {
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);
});
});

View File

@@ -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);
});
});

View File

@@ -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<void> {
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<void> {
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);
});
});

View File

@@ -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');
});