Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions apps/web/harness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Onboarding team-step harness

Renders `features/setup/flow/TeamStep` on its own — real component, real
Tailwind theme, no backend — so the screen can be driven and screenshotted
without a logged-in session.

The hooks the step needs (user email, contacts, onboarding state, team
queries/mutations, analytics) are swapped for fixtures in `mocks/` via Vite
aliases; everything else is the real code.

```bash
bunx vite -c harness/vite.config.ts # http://localhost:5199
bun harness/shoot.mjs ./shots # drive it + write screenshots
```

Query params:

- `?scenario=prefill` (default) — work-domain user with same-domain contacts,
so teammates arrive pre-added to the invite list
- `?scenario=plain` — personal-email user: no suggested domain, no prefill
- `?theme=dark` — Macro Dark instead of Macro Light

`shoot.mjs` also asserts behavior as it goes (which rows are pre-added, what
the remove buttons drop, what the mutation and analytics event receive) and
exits non-zero on a page error, so it doubles as a smoke test.

This directory sits outside `tsconfig.json`'s `include` and knip's `project`
globs on purpose — it is dev tooling, not shipped code.
3 changes: 3 additions & 0 deletions apps/web/harness/harness.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@import '../src/index.css';

@source './main.tsx';
20 changes: 20 additions & 0 deletions apps/web/harness/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en" data-theme-light="true">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Team step harness</title>
<style>
html,
body,
#root {
height: 100%;
margin: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
63 changes: 63 additions & 0 deletions apps/web/harness/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render } from 'solid-js/web';
import { NoiseBackground } from '../src/features/setup/flow/shared';
import { TeamStep } from '../src/features/setup/flow/TeamStep';
import { DEFAULT_THEMES } from '../src/features/theme/constants';
import './harness.css';

/** Paints one of the app's real theme presets onto :root. */
function applyTheme(id: string) {
const theme = DEFAULT_THEMES.find((t) => t.id === id);
if (!theme) throw new Error(`no theme ${id}`);
const root = document.documentElement;
for (const [key, v] of Object.entries(
theme.tokens as Record<string, { l: number; c: number; h: number }>
)) {
root.style.setProperty(`--${key}l`, `${v.l}`);
root.style.setProperty(`--${key}c`, `${v.c}`);
root.style.setProperty(`--${key}h`, `${v.h}deg`);
}
const tokens = theme.tokens as Record<string, { l: number }>;
root.dataset.themeLight = tokens.b0.l > tokens.c0.l ? 'true' : 'false';
}

/**
* Renders the onboarding team step on its own, with the flow's card chrome
* copied from OnboardingFlow, so it can be screenshotted without a backend.
* `?scenario=plain` swaps in a personal-email user; `?theme=dark` flips it.
*/
function Harness() {
const params = new URLSearchParams(window.location.search);
applyTheme(params.get('theme') === 'dark' ? 'Macro Dark' : 'Macro Light');

return (
<div class="relative size-full overflow-hidden bg-surface font-sans text-ink">
<NoiseBackground />
<div class="relative z-10 size-full overflow-y-auto overscroll-contain">
<div class="flex min-h-full items-center justify-center px-6 py-12">
<div class="w-full sm:max-w-lg">
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-1.5">
<h1 class="text-2xl font-semibold tracking-tight text-ink">
Macro is meant for teams
</h1>
<p class="max-w-md text-sm leading-relaxed text-ink-muted">
Macro is built to be used with others. Invite your team to
share docs, channels, and context from day one.
</p>
</div>
<div class="flex flex-col gap-8">
<TeamStep
onContinue={() => console.log('[continue]')}
onSkip={() => console.log('[skip]')}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

const root = document.getElementById('root');
if (root) render(() => <Harness />, root);
4 changes: 4 additions & 0 deletions apps/web/harness/mocks/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const useAnalytics = () => ({
track: (event: string, props?: unknown) =>
console.log('[analytics]', event, props),
});
21 changes: 21 additions & 0 deletions apps/web/harness/mocks/contacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { hasWorkDomain } from '../scenario';

const WORK_CONTACTS = [
'nikhil@macro.com',
'priya@macro.com',
'tom@macro.com',
'sarah@macro.com',
'diego@macro.com',
'ade@macro.com',
'ellen@macro.com',
];

// Personal-email users still have contacts — just none on a team domain.
const PERSONAL_CONTACTS = ['mom@aol.com', 'nikhil@macro.com'];
Comment on lines +13 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep personal fixtures off the team domain.

PERSONAL_CONTACTS contains nikhil@macro.com, although the comment says that personal-email users have no team-domain contacts. Replace this address with a non-macro.com fixture, or update the scenario contract if the cross-domain contact is intentional.

Proposed fixture correction
-const PERSONAL_CONTACTS = ['mom@aol.com', 'nikhil@macro.com'];
+const PERSONAL_CONTACTS = ['mom@aol.com', 'friend@example.com'];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Personal-email users still have contacts — just none on a team domain.
const PERSONAL_CONTACTS = ['mom@aol.com', 'nikhil@macro.com'];
// Personal-email users still have contacts — just none on a team domain.
const PERSONAL_CONTACTS = ['mom@aol.com', 'friend@example.com'];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/harness/mocks/contacts.ts` around lines 13 - 14, Update
PERSONAL_CONTACTS so every fixture uses a non-macro.com domain, replacing
nikhil@macro.com with an appropriate personal address while preserving the
scenario contract described by the adjacent comment.


export const useContacts = () => () =>
(hasWorkDomain() ? WORK_CONTACTS : PERSONAL_CONTACTS).map((email) => ({
id: email,
email,
name: email.split('@')[0],
}));
6 changes: 6 additions & 0 deletions apps/web/harness/mocks/invitations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const useUserInvitesQuery = () => ({ data: { invites: [] } });

export const useJoinTeamMutation = () => ({
isPending: false,
mutate: (v: unknown) => console.log('[join-team]', v),
});
Comment on lines +3 to +6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the onSuccess contract in the mutation mock.

The consumer in apps/web/src/features/setup/flow/TeamStep.tsx passes an onSuccess callback at Lines 112-116. This mock discards that callback, so the harness never records joined_invite after mutate. If success handling owns continuation, the join flow also cannot advance.

Accept the options and invoke onSuccess after a successful mutate.

Proposed mock correction
-export const useJoinTeamMutation = () => ({
+export const useJoinTeamMutation = (
+  options?: { onSuccess?: () => void }
+) => ({
   isPending: false,
-  mutate: (v: unknown) => console.log('[join-team]', v),
+  mutate: (v: unknown) => {
+    console.log('[join-team]', v);
+    options?.onSuccess?.();
+  },
});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const useJoinTeamMutation = () => ({
isPending: false,
mutate: (v: unknown) => console.log('[join-team]', v),
});
export const useJoinTeamMutation = (
options?: { onSuccess?: () => void }
) => ({
isPending: false,
mutate: (v: unknown) => {
console.log('[join-team]', v);
options?.onSuccess?.();
},
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/harness/mocks/invitations.ts` around lines 3 - 6, Update the
useJoinTeamMutation mock to accept mutation options containing onSuccess, then
invoke that callback after mutate completes successfully while preserving the
existing logging and isPending behavior.

7 changes: 7 additions & 0 deletions apps/web/harness/mocks/onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { hasWorkDomain } from '../scenario';

export const useOnboardingQuery = () => ({
data: {
suggested_team_domain: hasWorkDomain() ? 'macro.com' : null,
},
});
8 changes: 8 additions & 0 deletions apps/web/harness/mocks/teams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const useUserTeamsQuery = () => ({ data: [] as { name: string }[] });

export const useCreateTeamWithInvitesMutation = () => ({
isPending: false,
mutateAsync: async (body: unknown) => {
console.log('[create-team]', JSON.stringify(body));
},
});
5 changes: 5 additions & 0 deletions apps/web/harness/mocks/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { hasWorkDomain } from '../scenario';

/** Mirrors the real hook: a hook returning an Accessor<string | undefined>. */
export const useEmail = () => () =>
hasWorkDomain() ? 'jacob@macro.com' : 'jacob@gmail.com';
6 changes: 6 additions & 0 deletions apps/web/harness/scenario.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Which fixture the page renders, read from `?scenario=`. */
export const scenario = () =>
new URLSearchParams(window.location.search).get('scenario') ?? 'prefill';

/** A work-domain user with same-domain contacts vs. a personal-email user. */
export const hasWorkDomain = () => scenario() !== 'plain';
108 changes: 108 additions & 0 deletions apps/web/harness/shoot.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { mkdirSync } from 'node:fs';
import { chromium } from 'playwright';

const OUT = process.argv[2] ?? '/tmp/shots';
const BASE = 'http://127.0.0.1:5199';
mkdirSync(OUT, { recursive: true });

const browser = await chromium.launch({
executablePath: '/opt/pw-browsers/chromium',
});
const page = await browser.newPage({
viewport: { width: 900, height: 1000 },
deviceScaleFactor: 2,
});
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
page.on('console', (m) => {
if (m.type() === 'error') errors.push(m.text());
else console.log(` console: ${m.text()}`);
});

const shot = (name) => page.screenshot({ path: `${OUT}/${name}.png` });

const open = async (query) => {
await page.goto(`${BASE}/${query}`, { waitUntil: 'networkidle' });
await page.waitForSelector('#team-name');
await page.waitForTimeout(400);
};

const inviteValues = () =>
page.$$eval('input[id^="invite-"]', (els) => els.map((e) => e.value));

// 1. The new default: same-domain teammates already in the list.
await open('?scenario=prefill');
console.log('prefilled rows:', await inviteValues());
console.log('cta:', await page.textContent('button:has-text("Create team")'));
await shot('01-prefilled-light');
console.log(
'remove buttons:',
await page.$$eval('button[aria-label^="Don\'t invite"]', (e) => e.length),
'of',
(await inviteValues()).length,
'rows'
);
console.log('page scrollable:', await page.evaluate(() => document.body.scrollHeight > window.innerHeight));

// 2. Hover state on a remove button.
await page.hover('button[aria-label="Don\'t invite tom@macro.com"]');
await page.waitForTimeout(200);
await shot('02-remove-hover');

// 3. Two teammates removed — the X actually drops the right rows.
await page.click('button[aria-label="Don\'t invite tom@macro.com"]');
await page.click('button[aria-label="Don\'t invite ade@macro.com"]');
await page.waitForTimeout(200);
console.log('after removing 2:', await inviteValues());
console.log('cta:', await page.textContent('button:has-text("Create team")'));
await shot('03-after-removing-two');

// 4. Typing into the trailing empty row still works, and the row grows an X.
const slots = await page.$$('input[id^="invite-"]');
await slots[slots.length - 1].fill('newhire@macro.com');
await page.waitForTimeout(200);
console.log('after typing:', await inviteValues());
await shot('04-typed-into-empty-row');

// 5. What actually gets submitted (logged by the mocked mutation).
const submitted = [];
page.on('console', (m) => {
if (m.text().startsWith('[create-team]')) submitted.push(m.text());
if (m.text().startsWith('[analytics]')) submitted.push(m.text());
});
await page.click('button:has-text("Create team")');
await page.waitForTimeout(500);
console.log('submitted:', submitted.join('\n '));
Comment on lines +30 to +75

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the expected invite and submission values.

The script only logs inviteValues() and submitted console messages. A wrong prefill, removal result, or analytics payload can pass this smoke test. Add node:assert/strict checks against the fixture values after each interaction. This also makes the README assertion claim accurate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/harness/shoot.mjs` around lines 30 - 75, The smoke test in the
top-level interaction flow must assert expected fixture values instead of only
logging them. Import node:assert/strict and add assertions after prefill, after
removing Tom and Ade, after typing newhire@macro.com, and after submission to
validate inviteValues(), CTA state, and the mocked create-team and analytics
payloads against the fixture values.


// 6. Dark theme.
await open('?scenario=prefill&theme=dark');
await shot('05-prefilled-dark');

// 7. Personal-email user: no domain, no prefill, plain two-slot form.
await open('?scenario=plain');
console.log('plain rows:', await inviteValues());
console.log(
'remove buttons on empty form:',
await page.$$eval('button[aria-label^="Remove"]', (e) => e.length),
'/ dont-invite buttons:',
await page.$$eval('button[aria-label^="Don\'t invite"]', (e) => e.length)
);
await shot('06-personal-email-plain');

// 8. Plain form, one address typed by hand — X appears only on that row.
await page.fill('#invite-0', 'someone@elsewhere.com');
await page.waitForTimeout(200);
console.log(
'after typing one:',
await page.$$eval('button[aria-label^="Don\'t invite"]', (e) =>
e.map((b) => b.getAttribute('aria-label'))
)
);
await shot('07-plain-typed-one');

await browser.close();
if (errors.length) {
console.log('\nPAGE ERRORS:\n' + errors.join('\n'));
process.exit(1);
}
console.log('\nno page errors');
38 changes: 38 additions & 0 deletions apps/web/harness/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { fileURLToPath } from 'node:url';
import tailwind from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
import solidSvg from 'vite-plugin-solid-svg';
import tsconfigPaths from 'vite-tsconfig-paths';

const r = (p: string) => fileURLToPath(new URL(p, import.meta.url));

const mock = (find: RegExp, file: string) => ({
find,
replacement: r(`./mocks/${file}`),
});

export default defineConfig({
root: r('.'),
plugins: [
tailwind(),
tsconfigPaths({ root: r('..') }),
solid(),
solidSvg({ defaultAsComponent: true }),
],
resolve: {
dedupe: ['solid-js'],
alias: [
mock(/^@app\/lib\/analytics\/analytics-context$/, 'analytics.ts'),
mock(/^@core\/context\/user$/, 'user.ts'),
mock(/^@queries\/contacts\/contacts$/, 'contacts.ts'),
mock(/^@queries\/onboarding$/, 'onboarding.ts'),
mock(/^@queries\/team\/invitations$/, 'invitations.ts'),
mock(/^@queries\/team\/teams$/, 'teams.ts'),
],
},
server: {
port: 5199,
fs: { allow: [r('../../..')] },
},
});
2 changes: 1 addition & 1 deletion apps/web/src/features/setup/flow/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ function buildSteps(
...connectorSteps,
{
key: 'team',
title: 'Set up your team',
title: 'Macro is meant for teams',
subtitle:
'Macro is built to be used with others. Invite your team to share docs, channels, and context from day one.',
render: (controls) => (
Expand Down
Loading
Loading