From 2ed44d4555fb764a8ffc6b49fb8b2a2bf74edbb2 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 9 Nov 2026 14:17:07 +0100 Subject: [PATCH 1/4] refactor(Diff): Render the real diff in DiffString under E2E DiffString rendered raw `old-value`/`new-value` divs when E2E was set, so Playwright never exercised the diff users actually see. Always render the real diff inside a `diff-string` wrapper, and have `compareVersion` assert against the diff's removed and added lines. --- .../e2e/helpers/e2e-helpers.playwright.ts | 39 +++++++++++++++---- frontend/web/components/diff/DiffString.tsx | 29 +++++++------- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/frontend/e2e/helpers/e2e-helpers.playwright.ts b/frontend/e2e/helpers/e2e-helpers.playwright.ts index 3f2d5e132c95..2d9d7f385d3e 100644 --- a/frontend/e2e/helpers/e2e-helpers.playwright.ts +++ b/frontend/e2e/helpers/e2e-helpers.playwright.ts @@ -817,17 +817,42 @@ export class E2EHelpers { // Use .first() to handle cases where multiple comparison modals might exist in DOM await expect(this.page.locator(byId('old-enabled')).first()).toHaveText(`${oldEnabled}`); await expect(this.page.locator(byId('new-enabled')).first()).toHaveText(`${newEnabled}`); - if (oldValue !== undefined) { - const expectedOldValue = oldValue === null ? '' : `${oldValue}`; - await expect(this.page.locator(byId('old-value')).first()).toHaveText(expectedOldValue); - } - if (newValue !== undefined) { - const expectedNewValue = newValue === null ? '' : `${newValue}`; - await expect(this.page.locator(byId('new-value')).first()).toHaveText(expectedNewValue); + if (oldValue !== undefined && newValue !== undefined) { + await this.assertDiffString( + this.page.locator(byId('diff-string')).first(), + oldValue, + newValue, + ); } await this.closeModal(); } + // Assert what a DiffString renders. Changed values are shown by + // react-diff-viewer as a removed (`-`) line and an added (`+`) line. + async assertDiffString(diff: Locator, oldValue: any, newValue: any) { + const toText = (value: any) => (value === null ? '' : `${value}`); + const oldText = toText(oldValue); + const newText = toText(newValue); + + if (oldText === newText) { + await expect(diff).toHaveText(oldText === '' ? 'No Value.' : oldText); + return; + } + + const removedLines = diff.locator('tr:has(td pre:text-is("-")) td:last-child'); + const addedLines = diff.locator('tr:has(td pre:text-is("+")) td:last-child'); + if (oldText === '') { + await expect(removedLines).toHaveCount(0); + } else { + await expect(removedLines).toHaveText([oldText]); + } + if (newText === '') { + await expect(addedLines).toHaveCount(0); + } else { + await expect(addedLines).toHaveText([newText]); + } + } + // Assert number of versions async assertNumberOfVersions(featureName: string, versions: number) { await this.goToFeatureVersions(featureName); diff --git a/frontend/web/components/diff/DiffString.tsx b/frontend/web/components/diff/DiffString.tsx index 0042b10d60d6..67bad040f712 100644 --- a/frontend/web/components/diff/DiffString.tsx +++ b/frontend/web/components/diff/DiffString.tsx @@ -20,20 +20,11 @@ const sanitiseDiffString = (value: FlagsmithValue) => { } return `${value}` } -const DiffString: FC = ({ - compareMethod = DiffMethod.WORDS_WITH_SPACE, - newValue, - oldValue, -}) => { - if (E2E) { - return ( - <> -
{oldValue}
-
{newValue}
- - ) - } - +const renderDiff = ( + oldValue: FlagsmithValue, + newValue: FlagsmithValue, + compareMethod: DiffMethod, +) => { if (oldValue === newValue) { if (oldValue === null || oldValue === '') { return
No Value.
@@ -74,4 +65,14 @@ const DiffString: FC = ({ ) } +const DiffString: FC = ({ + compareMethod = DiffMethod.WORDS_WITH_SPACE, + newValue, + oldValue, +}) => ( +
+ {renderDiff(oldValue, newValue, compareMethod)} +
+) + export default DiffString From 869068e4bdceae67823afc9fabce04e92e86d423 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 9 Nov 2026 14:17:07 +0100 Subject: [PATCH 2/4] test(Segments): Reproduce segment rules shown as [object Object] in audit log Contributes to #8867 --- frontend/e2e/tests/segment-test.pw.ts | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/frontend/e2e/tests/segment-test.pw.ts b/frontend/e2e/tests/segment-test.pw.ts index 810f7ea72d00..b8afc843014a 100644 --- a/frontend/e2e/tests/segment-test.pw.ts +++ b/frontend/e2e/tests/segment-test.pw.ts @@ -475,3 +475,45 @@ test('Segment test 4 - Create ANY rule type segment and verify match changes whe await deleteTrait('age_any') await deleteTrait('team') }) + +test('Segment test 5 - Audit log shows segment rule changes @enterprise', async ({ page }) => { + const { + click, + createSegment, + deleteSegment, + gotoProject, + gotoSegments, + login, + navigateToSegment, + setSegmentRule, + waitForElementVisible, + waitForToast, + } = createHelpers(page) + const segmentName = 'audit_log_segment' + + log('Login') + await login(E2E_USER, PASSWORD) + await gotoProject(E2E_SEGMENT_PROJECT_3) + await waitForElementVisible(byId('features-page')) + + log('Create and update segment') + await gotoSegments() + await createSegment(segmentName, [{ name: 'age', operator: 'EQUAL', value: 18 }]) + await navigateToSegment(segmentName) + await setSegmentRule(0, 0, 'age', 'EQUAL', 19) + await click(byId('update-segment')) + await waitForToast() + + log('Check audit log shows the segment rule change') + await click(byId('audit-log-link')) + await click(`text="Segment updated: ${segmentName}"`) + const rulesDataDiff = page + .locator('.list-item', { hasText: 'Rules data' }) + .locator(byId('diff-string')) + await expect(rulesDataDiff).toContainText('19') + await expect(rulesDataDiff).not.toContainText('[object Object]') + + log('Clean up segment') + await gotoSegments() + await deleteSegment(segmentName) +}) From b72d4d9e4728707abc1c5fc97b2cb8441dc33c3c Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 9 Nov 2026 14:26:30 +0100 Subject: [PATCH 3/4] fix(Audit Log): Show JSON field changes instead of [object Object] DiffString turned values into strings with a template literal, so JSON fields like segment `rules_data` became `[object Object]` on both sides of the diff, which then collapsed as unchanged. Format arrays and objects as indented JSON via `Utils.sanitiseDiffString`, which DiffString now uses instead of its own copy, and compare the formatted text. Contributes to #8867 --- frontend/common/types/responses.ts | 8 +++- frontend/common/utils/__tests__/utils.test.ts | 31 +++++++++++++++ frontend/common/utils/utils.tsx | 9 ++++- frontend/web/components/diff/DiffString.tsx | 38 +++++++------------ 4 files changed, 58 insertions(+), 28 deletions(-) diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index dde01266e3b8..f8dee3a87451 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -20,6 +20,10 @@ export interface GitHubPagedResponse extends PagedResponse { } export type FlagsmithValue = string | number | boolean | null +// A value shown in a diff. JSON fields, e.g. segment `rules_data`, arrive as +// arrays or objects. +export type DiffValue = FlagsmithValue | unknown[] | Record + export type EnvironmentMetrics = { id: number name: string @@ -565,8 +569,8 @@ export type AuditLogItem = { export type AuditLogDetail = AuditLogItem & { change_details: { field: string - old: FlagsmithValue - new: FlagsmithValue + old: DiffValue + new: DiffValue }[] } export type PaymentMethod = 'CHARGEBEE' | 'XERO' | 'AWS_MARKETPLACE' diff --git a/frontend/common/utils/__tests__/utils.test.ts b/frontend/common/utils/__tests__/utils.test.ts index ff9527edab7f..810d83d20db6 100644 --- a/frontend/common/utils/__tests__/utils.test.ts +++ b/frontend/common/utils/__tests__/utils.test.ts @@ -120,3 +120,34 @@ describe('getPlanPermission', () => { }, ) }) + +describe('sanitiseDiffString', () => { + it.each([ + [null, ''], + [undefined, ''], + ['', '""'], + ['small', 'small'], + [0, '0'], + [false, 'false'], + ])('formats %p as %p', (value, expected) => { + expect(Utils.sanitiseDiffString(value)).toBe(expected) + }) + + it('formats an array of objects as indented JSON', () => { + const rulesData = [ + { + conditions: [{ operator: 'EQUAL', property: 'age', value: '19' }], + rules: [], + type: 'ALL', + }, + ] + + expect(Utils.sanitiseDiffString(rulesData)).toBe( + JSON.stringify(rulesData, null, 2), + ) + }) + + it('formats an object as indented JSON', () => { + expect(Utils.sanitiseDiffString({ a: 1 })).toBe('{\n "a": 1\n}') + }) +}) diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 1609b6ddf716..bbab9e0cabe2 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -3,6 +3,7 @@ import ProjectStore from 'common/stores/project-store' import Project from 'common/project' import { ContentType, + DiffValue, FeatureState, FlagsmithValue, MultivariateFeatureStateValue, @@ -721,10 +722,16 @@ const Utils = Object.assign({}, BaseUtils, { ) }, - sanitiseDiffString: (value: FlagsmithValue) => { + sanitiseDiffString: (value: DiffValue | undefined): string => { if (value === undefined || value === null) { return '' } + if (value === '') { + return '""' + } + if (typeof value === 'object') { + return JSON.stringify(value, null, 2) + } return `${value}` }, diff --git a/frontend/web/components/diff/DiffString.tsx b/frontend/web/components/diff/DiffString.tsx index 67bad040f712..183be36ddbb5 100644 --- a/frontend/web/components/diff/DiffString.tsx +++ b/frontend/web/components/diff/DiffString.tsx @@ -2,30 +2,24 @@ import React, { FC } from 'react' import Diff, { DiffMethod } from 'react-diff-viewer-continued' import Prism from 'prismjs' import 'prismjs/components/prism-json' -import { FlagsmithValue } from 'common/types/responses' +import { DiffValue } from 'common/types/responses' +import Utils from 'common/utils/utils' import DiffRow from './DiffRow' type DiffType = { - oldValue: FlagsmithValue - newValue: FlagsmithValue + oldValue: DiffValue + newValue: DiffValue compareMethod?: DiffMethod } -const sanitiseDiffString = (value: FlagsmithValue) => { - if (value === undefined || value === null) { - return '' - } - if (value === '') { - return '""' - } - return `${value}` -} const renderDiff = ( - oldValue: FlagsmithValue, - newValue: FlagsmithValue, + oldValue: DiffValue, + newValue: DiffValue, compareMethod: DiffMethod, ) => { - if (oldValue === newValue) { + const oldText = Utils.sanitiseDiffString(oldValue) + const newText = Utils.sanitiseDiffString(newValue) + if (oldText === newText) { if (oldValue === null || oldValue === '') { return
No Value.
} @@ -34,11 +28,7 @@ const renderDiff = (
       
@@ -46,12 +36,10 @@ const renderDiff = (
   }
   return (
      (
         
Date: Mon, 9 Nov 2026 17:02:07 +0100
Subject: [PATCH 4/4] refactor(Diff): Select DiffString by role and label in
 E2E

Use a `group` role labelled "Value diff" instead of a `data-test`
attribute, as E2E is moving to accessible selectors.
---
 frontend/e2e/helpers/e2e-helpers.playwright.ts | 5 ++++-
 frontend/e2e/tests/segment-test.pw.ts          | 2 +-
 frontend/web/components/diff/DiffString.tsx    | 2 +-
 3 files changed, 6 insertions(+), 3 deletions(-)

diff --git a/frontend/e2e/helpers/e2e-helpers.playwright.ts b/frontend/e2e/helpers/e2e-helpers.playwright.ts
index 2d9d7f385d3e..24e276d4e163 100644
--- a/frontend/e2e/helpers/e2e-helpers.playwright.ts
+++ b/frontend/e2e/helpers/e2e-helpers.playwright.ts
@@ -819,7 +819,10 @@ export class E2EHelpers {
     await expect(this.page.locator(byId('new-enabled')).first()).toHaveText(`${newEnabled}`);
     if (oldValue !== undefined && newValue !== undefined) {
       await this.assertDiffString(
-        this.page.locator(byId('diff-string')).first(),
+        // The value diff can sit in an inactive tab, e.g. when only variations changed.
+        this.page
+          .getByRole('group', { includeHidden: true, name: 'Value diff' })
+          .first(),
         oldValue,
         newValue,
       );
diff --git a/frontend/e2e/tests/segment-test.pw.ts b/frontend/e2e/tests/segment-test.pw.ts
index b8afc843014a..cccb3555d525 100644
--- a/frontend/e2e/tests/segment-test.pw.ts
+++ b/frontend/e2e/tests/segment-test.pw.ts
@@ -509,7 +509,7 @@ test('Segment test 5 - Audit log shows segment rule changes @enterprise', async
   await click(`text="Segment updated: ${segmentName}"`)
   const rulesDataDiff = page
     .locator('.list-item', { hasText: 'Rules data' })
-    .locator(byId('diff-string'))
+    .getByRole('group', { name: 'Value diff' })
   await expect(rulesDataDiff).toContainText('19')
   await expect(rulesDataDiff).not.toContainText('[object Object]')
 
diff --git a/frontend/web/components/diff/DiffString.tsx b/frontend/web/components/diff/DiffString.tsx
index 183be36ddbb5..eefe3281768d 100644
--- a/frontend/web/components/diff/DiffString.tsx
+++ b/frontend/web/components/diff/DiffString.tsx
@@ -58,7 +58,7 @@ const DiffString: FC = ({
   newValue,
   oldValue,
 }) => (
-  
+
{renderDiff(oldValue, newValue, compareMethod)}
)