mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-02-23 01:35:24 -05:00
test(e2e): implement QP lifecycle and dependency tests 2.40–2.45
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user