Skip to content

Commit c9e753e

Browse files
committed
fix(deploy): update tracking provider attribution and restore Playwright worker fixture ownership
1 parent 9c0391b commit c9e753e

4 files changed

Lines changed: 183 additions & 29 deletions

File tree

apps/web/src/components/FileViewer.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9763,6 +9763,22 @@ function HtmlViewer({
97639763
};
97649764
}
97659765

9766+
function trackingProviderFromDeployProviderId(providerId: WebDeployProviderId): TrackingDeployProvider {
9767+
switch (providerId) {
9768+
case CLOUDFLARE_PAGES_PROVIDER_ID:
9769+
return 'cloudflare_pages';
9770+
case NETLIFY_PROVIDER_ID:
9771+
return 'netlify';
9772+
case RENDER_PROVIDER_ID:
9773+
return 'render';
9774+
case RAILWAY_PROVIDER_ID:
9775+
return 'railway';
9776+
case DEFAULT_DEPLOY_PROVIDER_ID:
9777+
default:
9778+
return 'vercel';
9779+
}
9780+
}
9781+
97669782
async function deployToSelectedProvider() {
97679783
setDeploying(true);
97689784
setDeployPhase('deploying');
@@ -9773,8 +9789,7 @@ function HtmlViewer({
97739789
// accepts the publish, failed on any hard error / missing config. This is
97749790
// distinct from the share-popover "opened" signal (artifact_export_result).
97759791
const deployStarted = performance.now();
9776-
const providerForTracking: TrackingDeployProvider =
9777-
deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? 'cloudflare_pages' : 'vercel';
9792+
const providerForTracking: TrackingDeployProvider = trackingProviderFromDeployProviderId(deployProviderId);
97789793
const firstConfigure = !deployConfig?.configured;
97799794
let savedNewToken = false;
97809795
const fireDeployResult = (
@@ -9798,8 +9813,10 @@ function HtmlViewer({
97989813
};
97999814
try {
98009815
const typedToken = deployToken.trim();
9801-
const hasNewToken = typedToken && typedToken !== deployConfig?.tokenMask;
9802-
savedNewToken = Boolean(hasNewToken);
9816+
const hasNewToken = Boolean(typedToken && typedToken !== deployConfig?.tokenMask);
9817+
const typedGithubToken = renderGithubToken.trim();
9818+
const hasNewGithubToken = Boolean(typedGithubToken && typedGithubToken !== deployConfig?.githubTokenMask);
9819+
savedNewToken = hasNewToken || hasNewGithubToken;
98039820

98049821
// Save the latest credentials unconditionally so they are always used for this deploy!
98059822
const nextConfig = await saveDeployConfig({ isDeploying: true });
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// @vitest-environment jsdom
2+
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
4+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5+
6+
import { FileViewer } from '../../src/components/FileViewer';
7+
import type { ProjectFile } from '../../src/types';
8+
9+
const { analyticsTrackMock } = vi.hoisted(() => ({
10+
analyticsTrackMock: vi.fn(),
11+
}));
12+
13+
vi.mock('../../src/analytics/provider', async () => {
14+
const actual = await vi.importActual<typeof import('../../src/analytics/provider')>(
15+
'../../src/analytics/provider',
16+
);
17+
return {
18+
...actual,
19+
useAnalytics: () => ({
20+
...actual.useAnalytics(),
21+
track: analyticsTrackMock,
22+
}),
23+
};
24+
});
25+
26+
afterEach(() => {
27+
cleanup();
28+
vi.restoreAllMocks();
29+
vi.unstubAllGlobals();
30+
analyticsTrackMock.mockReset();
31+
});
32+
33+
function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
34+
return {
35+
name: 'asset.png',
36+
path: 'asset.png',
37+
type: 'file',
38+
size: 1024,
39+
mtime: 1710000000,
40+
kind: 'image',
41+
mime: 'image/png',
42+
...overrides,
43+
};
44+
}
45+
46+
function deployableHtmlFile(): ProjectFile {
47+
return baseFile({
48+
name: 'index.html',
49+
path: 'index.html',
50+
mime: 'text/html',
51+
kind: 'html',
52+
artifactManifest: {
53+
version: 1,
54+
kind: 'html',
55+
title: 'Page',
56+
entry: 'index.html',
57+
renderer: 'html',
58+
exports: ['html'],
59+
},
60+
});
61+
}
62+
63+
function mockDeployFetch() {
64+
return vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
65+
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
66+
const method = init?.method || (input instanceof Request ? input.method : 'GET');
67+
68+
if (url === '/api/projects/project-1/deployments') {
69+
return new Response(JSON.stringify({ deployments: [] }), { status: 200 });
70+
}
71+
if (url.startsWith('/api/deploy/config') && method === 'PUT') {
72+
const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
73+
return new Response(JSON.stringify({
74+
providerId: body.providerId ?? 'netlify',
75+
configured: true,
76+
tokenMask: 'saved-token',
77+
githubTokenMask: 'saved-github-token',
78+
}), { status: 200 });
79+
}
80+
if (url.startsWith('/api/deploy/config') && method === 'GET') {
81+
const parsedUrl = new URL(url, 'http://localhost');
82+
const providerId = parsedUrl.searchParams.get('providerId') ?? 'cloudflare-pages';
83+
return new Response(JSON.stringify({
84+
providerId,
85+
configured: false,
86+
tokenMask: '',
87+
githubTokenMask: '',
88+
}), { status: 200 });
89+
}
90+
if (url === '/api/projects/project-1/deploy' && method === 'POST') {
91+
const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
92+
return new Response(JSON.stringify({
93+
id: 'deploy-1',
94+
projectId: 'project-1',
95+
fileName: 'index.html',
96+
providerId: body.providerId ?? 'netlify',
97+
url: 'https://demo.netlify.app',
98+
deploymentId: 'dep-1',
99+
deploymentCount: 1,
100+
target: 'production',
101+
status: 'ready',
102+
createdAt: 1,
103+
updatedAt: 2,
104+
}), { status: 200 });
105+
}
106+
return new Response(JSON.stringify({}), { status: 404 });
107+
});
108+
}
109+
110+
describe('FileViewer deploy analytics attribution', () => {
111+
it('tracks artifact_deploy_result with provider: "netlify" and saved_new_token: true when GitHub PAT is entered', async () => {
112+
vi.stubGlobal('fetch', mockDeployFetch());
113+
114+
render(
115+
<FileViewer projectId="project-1" projectKind="prototype" file={deployableHtmlFile()}
116+
liveHtml="<html><body><h1>Hello</h1></body></html>"
117+
/>,
118+
);
119+
120+
fireEvent.click(screen.getByRole('button', { name: /share/i }));
121+
fireEvent.click(await screen.findByRole('menuitem', { name: /Deploy to Cloudflare Pages/i }));
122+
123+
const providerSelect = await screen.findByRole('combobox', { name: /Provider/i });
124+
fireEvent.change(providerSelect, { target: { value: 'netlify' } });
125+
126+
await waitFor(() => {
127+
expect((providerSelect as HTMLSelectElement).value).toBe('netlify');
128+
});
129+
130+
const deployTokenInput = document.getElementById('deploy-token');
131+
expect(deployTokenInput).not.toBeNull();
132+
fireEvent.change(deployTokenInput!, { target: { value: 'nnt_123456' } });
133+
134+
const githubPatInput = document.getElementById('github-pat-token');
135+
expect(githubPatInput).not.toBeNull();
136+
fireEvent.change(githubPatInput!, { target: { value: 'ghp_secretpat123' } });
137+
138+
const deployButtons = screen.getAllByRole('button', { name: /^Deploy$/i });
139+
fireEvent.click(deployButtons[deployButtons.length - 1]!);
140+
141+
await waitFor(() => {
142+
const trackCalls = analyticsTrackMock.mock.calls.filter(
143+
(call: [string, Record<string, unknown>]) => call[0] === 'artifact_deploy_result',
144+
);
145+
expect(trackCalls.length).toBeGreaterThan(0);
146+
const [, props] = trackCalls[0];
147+
expect(props).toMatchObject({
148+
page_name: 'artifact',
149+
area: 'deploy_modal',
150+
provider: 'netlify',
151+
result: 'success',
152+
saved_new_token: true,
153+
});
154+
});
155+
});
156+
});

e2e/playwright.config.ts

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
11
import { defineConfig, devices } from '@playwright/test';
22

3-
const daemonPort = Number(process.env.OD_PORT) || 17_456;
4-
const webPort = Number(process.env.OD_WEB_PORT) || 17_573;
5-
const baseURL = `http://127.0.0.1:${webPort}`;
6-
const namespace = process.env.OD_E2E_NAMESPACE || `playwright-${process.pid}`;
7-
const dataDir = process.env.OD_E2E_DATA_DIR || `e2e/ui/.od-data/${namespace}`;
8-
9-
function shellQuote(value: string): string {
10-
return `'${value.replaceAll("'", "'\\''")}'`;
11-
}
12-
133
export default defineConfig({
144
testDir: './ui',
155
outputDir: './ui/reports/test-results',
@@ -18,11 +8,6 @@ export default defineConfig({
188
expect: {
199
timeout: 10_000,
2010
},
21-
// The webServer owns one daemon and one OD_DATA_DIR for the entire UI suite.
22-
// Keep backend-mutating UI tests serialized until the harness can boot an
23-
// isolated daemon/data directory per worker.
24-
fullyParallel: false,
25-
workers: 1,
2611
reporter: process.env.CI
2712
? [
2813
['github'],
@@ -38,18 +23,9 @@ export default defineConfig({
3823
['junit', { outputFile: './ui/reports/junit.xml' }],
3924
],
4025
use: {
41-
baseURL,
4226
trace: 'on-first-retry',
4327
screenshot: 'only-on-failure',
4428
},
45-
webServer: {
46-
command: process.platform === 'win32'
47-
? `set "OD_DATA_DIR=${dataDir}" && pnpm --dir .. tools-dev run web --namespace ${namespace} --daemon-port ${daemonPort} --web-port ${webPort}`
48-
: `OD_DATA_DIR=${shellQuote(dataDir)} pnpm --dir .. tools-dev run web --namespace ${shellQuote(namespace)} --daemon-port ${daemonPort} --web-port ${webPort}`,
49-
url: baseURL,
50-
reuseExistingServer: false,
51-
timeout: 120_000,
52-
},
5329
projects: [
5430
{
5531
name: 'chromium',

packages/contracts/src/analytics/events/result-events.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,12 @@ export interface SketchExportResultProps {
517517
project_id: string;
518518
}
519519

520-
export type TrackingDeployProvider = 'vercel' | 'cloudflare_pages';
520+
export type TrackingDeployProvider =
521+
| 'vercel'
522+
| 'cloudflare_pages'
523+
| 'netlify'
524+
| 'render'
525+
| 'railway';
521526

522527
// Fired from the deploy modal when a real publish attempt resolves — NOT when
523528
// the modal merely opens (that path is `artifact_export_result` with

0 commit comments

Comments
 (0)