Files
nexu-io__open-design/apps/web/tests/experience-survey-trigger.test.ts
open-design-crew[bot] 713caf0110 feat(web): ask the experience survey after a delivered artifact, and add an "other" answer (#7117)
* feat(web): add an "other" answer with a free-text field to the survey

The eight choices cover what users already tell us most often, but they cannot
cover what we have not heard yet — and the people whose problem is not on the
list are exactly the ones worth hearing from. Picking "Something else" opens a
text field instead of finishing the survey.

The cost is paid only by the people who need it: everyone whose answer is on
the list still finishes in one tap. This is the open-ended question we cut
earlier, put back behind a door rather than in everyone way.

Wire shape follows PostHog open-choice convention — the response recorded is
the text they typed. Picking "Something else" and typing nothing still reports
the choice itself, because "none of these fit" is an answer and dropping it
would turn those people into non-responders.

Skip disappears once the choice is made; it would only have been a second way
to submit.

* style(web): use a neutral focus border on the survey text field

The green ring competed with the brand fill on the selected chip directly
above it — two green things one under the other, neither reading as primary.
A neutral border marks focus without joining that fight, and the inset fill
still separates the field from the card.

* fix(web): keep the survey submit button on the footer right edge

Submit replaces Skip in the footer, but only Skip carried the margin-left:auto
that pushes it right — so the moment the text field opened, the primary action
collapsed back against the step counter and jumped from the right edge to the
left. Measured from the card edge, both now sit at the same 17px gap.

* feat(web): ask the experience survey after a delivered artifact, not an export

The post-export survey could only reach people who exported. Over 30 days
~13k users exported while ~37k produced an artifact, so roughly two thirds
of the people who got real work out of the product were never asked — and
the ones who tried it, got something, and left without exporting are the
ones worth hearing from most.

The card is now armed by a delivered design run (resolveDesignDeliveryOutcome
=== 'delivered'), from the second such delivery on. The first delivery
measures a first impression rather than an experience, and lands at the one
moment the user most wants to look at what they just got; waiting for the
second costs almost no reach because deliveries repeat (~13 per user per 30
days).

Because a delivery is usually mid-session rather than end-of-session, the
3s delay now bails out if the user starts writing their next prompt.
'beforeinput' rather than 'keydown' so IME composition counts as typing.
A dropped chance is taken by the next delivery, so the card lands after the
turn the user actually stopped on.

Export no longer arms anything; the three FileViewer call sites are gone.
Every survey event carries trigger: 'post_generation' so the two regimes
stay distinguishable in the events table.

* feat(web): arm the experience survey on the first delivered artifact

Drops the two-delivery threshold to one. Waiting for a second run bought a
more considered opinion at the cost of everyone who produces one artifact
and leaves — the users we understand least, and the ones this survey was
widened to reach in the first place.

Asking on the first delivery is affordable because the card is not an
interruption: the component still waits out the delay and still drops the
chance entirely if the user starts typing, so an early ask that lands badly
costs a dropped chance rather than a derailed session.

The delivery counter stays. It is the seam the policy is made of, and it is
what makes an unwritable store read as "not yet qualified" — a store that
cannot write cannot record a dismissal either, so without it the card would
come back after every run with no way to stop it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): retire the experience survey when it is shown, not when it is answered

One ask per user was the intent, but `retireSurvey()` only ran from `finish`
and `close`. Ignoring the card — the most common way to decline a prompt —
left the flag unset, so the card returned after the user's next artifact, and
the one after that. `exposedRef` did not cover this: it is a ref, so it resets
on the next page load.

Retiring on exposure makes the rule what it claims to be: a user sees this
card at most once, whatever they do with it. A chance dropped by the typing
bail-out is still retried, because a card the user never saw did not spend
their one ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(web): drop the typing bail-out from the experience survey

The card no longer cancels itself when the user starts typing during the
three-second delay. With one ask per user, a dropped chance is the whole ask
for anyone whose habit is to type straight into the next prompt — and that
habit belongs to the engaged users this survey most wants to hear from.

Removes the `beforeinput` listener and the IME reasoning that went with it.
The delay stays: it keeps the card from animating in on top of the artifact,
which was always its other job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): give the survey's free-text field an accessible name

Review finding from #7074, folded in with that branch. The textarea is
auto-focused the instant "Something else" is picked, so its accessible name
is the only thing telling a screen-reader user what the field is for — and it
had none. A placeholder is not a label and disappears on the first keystroke.

Reuses the placeholder string rather than adding a twentieth translation of
the same sentence, so the visible and announced names cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: ensure PR CI

---------

Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
Co-authored-by: Eli <qiongyu1999@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: open-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com>
2026-08-19 10:35:34 +00:00

137 lines
4.4 KiB
TypeScript

// The survey's whole value is that it is asked once, after real work. Both
// halves are enforced here rather than in the component: the component can
// only render what the trigger arms, so a leak past `retired` or a count that
// silently stops advancing is invisible until the card either follows a user
// around forever or never shows up at all.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SURVEY_MIN_DELIVERIES,
deliveredCount,
isSurveyRetired,
notifyArtifactDelivered,
onArtifactDelivered,
retireSurvey,
} from '../src/components/experience-survey-trigger';
// Minimal in-memory localStorage stub. Vitest runs in a node env, so we
// provide just enough of the Storage interface for the module's code paths.
function createStorageStub() {
const store = new Map<string, string>();
return {
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => { store.set(key, value); },
removeItem: (key: string) => { store.delete(key); },
clear: () => { store.clear(); },
key: (i: number) => Array.from(store.keys())[i] ?? null,
get length() { return store.size; },
};
}
function useStorage(storage: unknown) {
(globalThis as unknown as { window: unknown }).window = { localStorage: storage };
}
/** Subscribes a spy for the duration of one test. */
function listen() {
const listener = vi.fn();
const unsubscribe = onArtifactDelivered(listener);
unsubscribers.push(unsubscribe);
return listener;
}
let unsubscribers: Array<() => void> = [];
beforeEach(() => {
useStorage(createStorageStub());
});
afterEach(() => {
for (const unsubscribe of unsubscribers) unsubscribe();
unsubscribers = [];
delete (globalThis as unknown as { window?: unknown }).window;
});
describe('experience survey delivery trigger', () => {
it('arms on the very first delivery', () => {
const listener = listen();
notifyArtifactDelivered();
expect(listener).toHaveBeenCalledTimes(1);
expect(deliveredCount()).toBe(SURVEY_MIN_DELIVERIES);
});
it('keeps arming on later deliveries so a lost arm is not the last chance', () => {
const listener = listen();
notifyArtifactDelivered();
notifyArtifactDelivered();
notifyArtifactDelivered();
// Arming is not showing: an arm can be lost to a navigation mid-delay, so
// every delivery has to offer the component another one.
expect(listener).toHaveBeenCalledTimes(3);
});
it('counts deliveries across reloads', () => {
const storage = createStorageStub();
useStorage(storage);
notifyArtifactDelivered();
// Same store, new page load. The count is what makes an unwritable store
// read as "not yet qualified", so it has to survive the reload even though
// today's threshold is met on the first delivery.
useStorage(storage);
notifyArtifactDelivered();
expect(deliveredCount()).toBe(2);
});
it('never arms again once retired, and stops counting', () => {
const listener = listen();
retireSurvey();
notifyArtifactDelivered();
notifyArtifactDelivered();
notifyArtifactDelivered();
expect(listener).not.toHaveBeenCalled();
expect(isSurveyRetired()).toBe(true);
expect(deliveredCount()).toBe(0);
});
it('never arms when the store is unwritable', () => {
// Fail-closed, and the reason the count still exists: a store that cannot
// write cannot record a dismissal either, so a card shown here would come
// back after every run with no way for the user to stop it.
useStorage({
getItem: () => null,
setItem: () => { throw new Error('QuotaExceededError'); },
removeItem: () => {},
clear: () => {},
key: () => null,
length: 0,
});
const listener = listen();
for (let i = 0; i < 5; i += 1) notifyArtifactDelivered();
expect(listener).not.toHaveBeenCalled();
});
it('restarts the count from zero when the stored value is corrupted', () => {
const storage = createStorageStub();
storage.setItem('open-design:experience-survey:v1:deliveries', 'not-a-number');
useStorage(storage);
const listener = listen();
notifyArtifactDelivered();
// Garbage in the store must not poison the count into NaN, which would
// compare false against the threshold forever.
expect(listener).toHaveBeenCalledTimes(1);
expect(deliveredCount()).toBe(1);
});
});