Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
22c89d4
fix(playground): place caret at end when editing a variable value
sundram-bruno Jul 30, 2026
e27164c
fix(playground): show the copy control in every variable card state
sundram-bruno Jul 30, 2026
cdba47f
fix(playground): drop the copy control when there is no value to copy
sundram-bruno Jul 30, 2026
e3b3c81
feat(ui): show a green tick on copy and block repeat clicks
sundram-bruno Jul 30, 2026
f813ec5
refactor(ui): reuse baseIconProps for the copy glyphs
sundram-bruno Jul 30, 2026
cd7e13a
feat(playground): make secrets fillable so a request can be sent with…
sundram-bruno Jul 30, 2026
dba0eaa
fix(playground): reference external secrets by bare name, not a $secr…
sundram-bruno Jul 30, 2026
5a63ab3
fix(playground): keep the secret changes out of the rendered docs
sundram-bruno Jul 30, 2026
d64138f
chore(playground): trim duplicated comments from the secret changes
sundram-bruno Jul 30, 2026
949451a
fix(playground): allow $secrets as a concrete variable scope
sundram-bruno Jul 30, 2026
880a39b
fix(playground): address review blockers on the secret changes
sundram-bruno Jul 31, 2026
0544813
fix(playground): mask secrets by making the field's content the mask
sundram-bruno Jul 31, 2026
a6e04e4
test(playground): cover the secret reaching the request, plus review …
sundram-bruno Jul 31, 2026
b25ced2
fix(playground): make Backspace and Delete work on a masked secret
sundram-bruno Jul 31, 2026
934bce9
test(playground): assert the copied tick is visible rather than counted
sundram-bruno Jul 31, 2026
8c66c43
fix(playground): stop the environments editor writing contradictory t…
sundram-bruno Jul 31, 2026
8bb03e3
refactor(playground): fold the two caret effects into one and rewrite…
sundram-bruno Jul 31, 2026
7a3d4ce
test(ui): give the copied checkmark its own test id
sundram-bruno Jul 31, 2026
62dd010
docs(playground): explain the external secret entry shape more plainly
sundram-bruno Jul 31, 2026
4c68bc9
refactor(ui): move copy state into a useCopy hook
sundram-bruno Jul 31, 2026
e9b8d67
refactor(ui): drop the comments from useCopy and make its reset rende…
sundram-bruno Jul 31, 2026
0b3a3b6
Merge upstream/main into feat/bru-4000-followup
sundram-bruno Jul 31, 2026
fbb125e
fix(playground): reorder the large response actions and tighten the i…
sundram-bruno Jul 31, 2026
d50f77e
fix(playground): always offer copy, and copy the real value out of a …
sundram-bruno Jul 31, 2026
4280a12
fix(ui): confirm a copy even when there is no value
sundram-bruno Jul 31, 2026
424e3b9
refactor(playground): drop dead code from the variable card
sundram-bruno Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export class VariableCardComponent extends BaseComponent {
readonly scopeBadge = this.card.getByTestId('variable-info-card-scope');
readonly value = this.card.getByTestId('variable-info-card-value');
readonly copyButton = this.card.getByTestId('variable-info-card-copy');
// Present only while the copy button is showing its confirmation, which clears
// itself after a second, so a test should assert it in one step.
readonly copiedTick = this.card.getByTestId('variable-info-card-copy-tick');
readonly revealToggle = this.card.getByTestId('variable-info-card-reveal');
readonly note = this.card.getByTestId('variable-info-card-note');
readonly warning = this.card.getByTestId('variable-info-card-warning');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,208 @@ test.describe('Playground variables: highlight, hover card and inline edit', ()
await playground.variable.value.click();
await expect(playground.variable.editField).toHaveCount(0);
});

test('clicking the value puts the caret at the end, not the start', async ({ playground }) => {
await playground.variable.hoverInputToken('host');
await playground.variable.value.click();

const caret = await playground.variable.editField.evaluate(
(el: HTMLTextAreaElement) => ({ start: el.selectionStart, end: el.selectionEnd, length: el.value.length })
);

expect(caret.length).toBeGreaterThan(0);
expect(caret.start).toBe(caret.length);
expect(caret.end).toBe(caret.length);
});

test('the copy control stays available while the value is being edited', async ({ playground }) => {
await playground.variable.hoverInputToken('host');
await expect(playground.variable.copyButton).toBeVisible();

await playground.variable.startEditing('https://edited.example.com');

await expect(playground.variable.editField).toBeVisible();
await expect(playground.variable.copyButton).toBeVisible();
});

test('an unset secret starts blank with a reveal control and takes a typed value', async ({ playground }) => {
await playground.variable.hoverInputToken('unsetSecret');

await expect(playground.variable.value).toHaveText('');
await expect(playground.variable.revealToggle).toBeVisible();

await playground.variable.editTo('typed-secret');

await expect(playground.variable.value).toHaveText('*'.repeat('typed-secret'.length));

await playground.variable.revealToggle.click();
await expect(playground.variable.value).toHaveText('typed-secret');
});

test('an external secret is editable and keeps its Secret scope', async ({ playground }) => {
await playground.variable.hoverInputToken('vaultKey');

await expect(playground.variable.scopeBadge).toHaveText('Secret');
await expect(playground.variable.value).toHaveText('');

await playground.variable.editTo('vault-value');

await expect(playground.variable.value).toHaveText('*'.repeat('vault-value'.length));
});

// The field contains the asterisks themselves rather than the secret, so the
// secret is never present in the page while it is hidden.
test('the edit field holds only mask characters while the secret is hidden', async ({ playground }) => {
await playground.variable.hoverInputToken('unsetSecret');
await playground.variable.startEditing('typed-secret');

const state = await playground.variable.editField.evaluate((el: HTMLTextAreaElement) => ({
value: el.value,
caret: el.selectionStart
}));

expect(state.value).toBe('*'.repeat('typed-secret'.length));
expect(state.caret).toBe(state.value.length);
});

// Use key presses, not fill(): fill() replaces the whole value at once and so
// never exercises deleting a single character through the mask.
test('Backspace and Delete remove characters from a masked secret', async ({ playground }) => {
await playground.variable.hoverInputToken('unsetSecret');
await playground.variable.startEditing('abcdef');

await playground.variable.editField.press('Backspace');
await expect(playground.variable.editField).toHaveValue('*'.repeat(5));

await playground.variable.editField.evaluate((el: HTMLTextAreaElement) => el.setSelectionRange(0, 0));
await playground.variable.editField.press('Delete');
await expect(playground.variable.editField).toHaveValue('*'.repeat(4));

await playground.variable.editField.press('Enter');
await playground.variable.hoverInputToken('unsetSecret');
await playground.variable.revealToggle.click();

await expect(playground.variable.value).toHaveText('bcde');
});

// The app confirms every copy, so a variable with nothing in it still ticks.
test('confirms a copy even when the secret has no value yet', async ({ playground, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);

await playground.variable.hoverInputToken('unsetSecret');
await expect(playground.variable.value).toHaveText('');
await expect(playground.variable.copyButton).toBeVisible();
await expect(playground.variable.copiedTick).toHaveCount(0);

await playground.variable.copyButton.click();

await expect(playground.variable.copiedTick).toBeVisible();
});

// The field holds asterisks, so a plain selection copy would put those on the
// clipboard instead of the secret.
test('copying a selection out of a masked secret yields the real value', async ({ page, playground, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);

await playground.variable.hoverInputToken('unsetSecret');
await playground.variable.startEditing('abcdef');

await playground.variable.editField.press('ControlOrMeta+a');
await playground.variable.editField.press('ControlOrMeta+c');

await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('abcdef');
});

test('cutting from a masked secret removes the real characters', async ({ page, playground, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);

await playground.variable.hoverInputToken('unsetSecret');
await playground.variable.startEditing('abcdef');

await playground.variable.editField.evaluate((el: HTMLTextAreaElement) => el.setSelectionRange(2, 4));
await playground.variable.editField.press('ControlOrMeta+x');

await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('cd');
await expect(playground.variable.editField).toHaveValue('*'.repeat(4));

await playground.variable.editField.press('Enter');
await playground.variable.hoverInputToken('unsetSecret');
await playground.variable.revealToggle.click();

await expect(playground.variable.value).toHaveText('abef');
});

test('typing into the middle of a masked secret edits the real value', async ({ playground }) => {
await playground.variable.hoverInputToken('unsetSecret');
await playground.variable.startEditing('abcdef');

await playground.variable.editField.evaluate((el: HTMLTextAreaElement) => el.setSelectionRange(3, 3));
await playground.variable.editField.pressSequentially('XY');
await playground.variable.editField.press('Enter');

await playground.variable.hoverInputToken('unsetSecret');
await playground.variable.revealToggle.click();

await expect(playground.variable.value).toHaveText('abcXYdef');
});

test('a typed secret stays masked in the card until revealed', async ({ playground }) => {
await playground.variable.hoverInputToken('unsetSecret');
await playground.variable.editTo('typed-secret');

await expect(playground.variable.card).not.toContainText('typed-secret');

await playground.variable.revealToggle.click();

await expect(playground.variable.card).toContainText('typed-secret');

await playground.variable.revealToggle.click();

await expect(playground.variable.card).not.toContainText('typed-secret');
await expect(playground.variable.value).toHaveText('*'.repeat('typed-secret'.length));
});

// A secret nobody filled in has no value to send, so the request keeps the
// `{{name}}` reference instead of quietly sending an empty value. Environment
// secrets and external secrets behave the same way here.
test('leaves an unfilled secret unresolved in the request', async ({ page, responsePane }) => {
const sent: string[] = [];
await page.route('**/customers/**', (route) => {
sent.push(route.request().url());
return route.fulfill({ status: 200, headers: { 'content-type': 'application/json' }, body: '{}' });
});

await responsePane.send();

await expect.poll(() => sent.length).toBeGreaterThan(0);
expect(sent[0]).toContain('s={{unsetSecret}}');
expect(sent[0]).toContain('k={{vaultKey}}');
});

test('sends a typed secret with the request', async ({ page, playground, responsePane }) => {
const sent: string[] = [];
await page.route('**/customers/**', (route) => {
sent.push(route.request().url());
return route.fulfill({ status: 200, headers: { 'content-type': 'application/json' }, body: '{}' });
});

await playground.variable.hoverInputToken('unsetSecret');
await playground.variable.editTo('typed-secret');

await expect(playground.variable.value).toHaveText('*'.repeat('typed-secret'.length));

// A card is already open from the edit above, so the hover helper's wait for
// a visible card returns immediately. Wait for the name to change before
// editing, or the edit lands on the previous variable.
await playground.variable.hoverInputToken('vaultKey');
await expect(playground.variable.name).toHaveText('vaultKey');
await playground.variable.editTo('vault-value');
await expect(playground.variable.value).toHaveText('*'.repeat('vault-value'.length));

await responsePane.send();

await expect.poll(() => sent.length).toBeGreaterThan(0);
expect(sent[0]).toContain('s=typed-secret');
expect(sent[0]).toContain('k=vault-value');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ test.describe('Variable hover card', () => {
await expect(variableCard.copyButton).toHaveCount(0);
});

// Only the playground can fill an external secret in, so the docs leave it unresolved.
test('leaves an external secret undefined', async ({ requestPage }) => {
const { variableCard } = requestPage;
await variableCard.hoverToken('vaultKey');

await expect(variableCard.card).toBeVisible();
await expect(variableCard.scopeBadge).toHaveText('Undefined');
await expect(variableCard.note).toHaveText('Variable is not defined');
});

test('shows an (empty) placeholder with no copy for a defined variable that has no value', async ({ requestPage }) => {
const { variableCard } = requestPage;
await variableCard.hoverToken('emptyValue');
Expand Down Expand Up @@ -133,6 +143,21 @@ test.describe('Variable hover card', () => {
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('2024-01');
});

test('turns the copy glyph into a green tick while copied, then reverts', async ({ page, requestPage }) => {
const { variableCard } = requestPage;
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);

await variableCard.pinToken('apiVersion');
await expect(variableCard.card).toBeVisible();

await expect(variableCard.copiedTick).toHaveCount(0);

await variableCard.copyButton.click();

await expect(variableCard.copiedTick).toBeVisible();
await expect(variableCard.copiedTick).toHaveCount(0, { timeout: 3000 });
});

test('is read-only — no editor inside the card', async ({ requestPage }) => {
const { variableCard } = requestPage;
await variableCard.pinToken('host');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,6 @@ export const LargeResponseWarning: React.FC<LargeResponseWarningProps> = ({ resp
</span>
<span>View</span>
</button>
<button
type="button"
className="large-response-copy"
onClick={copyResponse}
aria-label={copied ? 'Copied response' : 'Copy response'}
data-testid="large-response-copy"
>
<span className="button-icon">
{copied ? <IconCheck size={13} strokeWidth={1} /> : <IconCopy size={13} strokeWidth={1} />}
</span>
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
<button
type="button"
className="large-response-download"
Expand All @@ -75,6 +63,18 @@ export const LargeResponseWarning: React.FC<LargeResponseWarningProps> = ({ resp
</span>
<span>Download</span>
</button>
<button
type="button"
className="large-response-copy"
onClick={copyResponse}
aria-label={copied ? 'Copied response' : 'Copy response'}
data-testid="large-response-copy"
>
<span className="button-icon">
{copied ? <IconCheck size={13} strokeWidth={1} /> : <IconCopy size={13} strokeWidth={1} />}
</span>
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
</StyledWrapper>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export const StyledWrapper = styled.div`
text-align: center;

.warning-icon {
margin-bottom: 1rem;
color: var(--oc-status-warning-text);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { variableTypeColumn } from '../Common/VariableTypeControl/variableTypeCo
import { GlobeIcon } from '../../../../../assets/icons';
import { useAppDispatch } from '../../../../../store/hooks';
import { cx } from '../../../../../utils/cx';
import { envVariableToRow, envRowToVariable } from '../../../../../utils/environments';
import { isSecretVariable } from '../../../../../utils/variableResolution';
import { envVariableToRow, envRowToVariable, mergeExternalSecretRows } from '../../../../../utils/environments';
import { isSecretVariable, isExternalSecretActive, type ExternalSecretEntry } from '../../../../../utils/variableResolution';
import { updateCollectionEnvironments } from '@slices/playground';

const ENV_TABS = [
Expand Down Expand Up @@ -66,12 +66,15 @@ const EnvironmentsView: React.FC<EnvironmentsViewProps> = ({ collection, compact
const secretProviderType = selectedEnvironment?.externalSecrets?.type as SecretProviderType | undefined;
const secretPointerField = (secretProviderType && SECRET_POINTER_FIELD[secretProviderType]) || 'secretName';

const externalRows: KeyValueRow[] = (selectedEnvironment?.externalSecrets?.variables ?? []).map(
(variable: { name?: string; disabled?: boolean }, index: number) => ({
// Same predicate the resolver uses, so the checkbox and the request agree on
// which entries are live.
const externalEntries = (selectedEnvironment?.externalSecrets?.variables ?? []) as ExternalSecretEntry[];
const externalRows: KeyValueRow[] = externalEntries.map(
(variable, index: number) => ({
id: `ext-${index}`,
name: variable.name ?? '',
value: (variable as Record<string, string | undefined>)[secretPointerField] ?? '',
enabled: variable.disabled !== true
enabled: isExternalSecretActive(variable)
})
);

Expand Down Expand Up @@ -101,11 +104,7 @@ const EnvironmentsView: React.FC<EnvironmentsViewProps> = ({ collection, compact
applyToSelectedEnv({
externalSecrets: {
...(selectedEnvironment?.externalSecrets ?? {}),
variables: rows.map((row) => ({
name: row.name,
[secretPointerField]: row.value,
disabled: !row.enabled
}))
variables: mergeExternalSecretRows(selectedEnvironment?.externalSecrets?.variables, rows, secretPointerField)
}
});

Expand Down
Loading
Loading