Skip to content

Commit

Permalink
Merge pull request #30070 from storybookjs/tom/metadata-tweaks
Browse files Browse the repository at this point in the history
Telemetry: Add metadata distinguishing "apps" from "design systems"
  • Loading branch information
shilman authored Dec 16, 2024
2 parents faaf253 + 2a5a855 commit c3e1496
Show file tree
Hide file tree
Showing 16 changed files with 307 additions and 36 deletions.
1 change: 1 addition & 0 deletions code/core/src/__mocks__/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// empty file only matched on path
1 change: 1 addition & 0 deletions code/core/src/__mocks__/path/to/Screens/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// empty file only matched on path
2 changes: 1 addition & 1 deletion code/core/src/manager-api/modules/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export const defaultLayoutState: SubState = {
panelPosition: 'bottom',
showTabs: true,
},
selectedPanel: undefined,
selectedPanel: 'chromaui/addon-visual-tests/panel',
theme: create(),
};

Expand Down
2 changes: 1 addition & 1 deletion code/core/src/manager/components/panel/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const AddonPanel = React.memo<{
return (
<Tabs
absolute={absolute}
{...(selectedPanel ? { selected: selectedPanel } : {})}
{...(selectedPanel && panels[selectedPanel] ? { selected: selectedPanel } : {})}
menuName="Addons"
actions={actions}
showToolsWhenEmpty
Expand Down
71 changes: 71 additions & 0 deletions code/core/src/telemetry/exec-command-count-lines.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Transform } from 'node:stream';
import { PassThrough } from 'node:stream';

import { beforeEach, describe, expect, it, vitest } from 'vitest';

// eslint-disable-next-line depend/ban-dependencies
import { execaCommand as rawExecaCommand } from 'execa';

import { execCommandCountLines } from './exec-command-count-lines';

vitest.mock('execa');

const execaCommand = vitest.mocked(rawExecaCommand);
beforeEach(() => {
execaCommand.mockReset();
});

type ExecaStreamer = typeof Promise & {
stdout: Transform;
kill: () => void;
};

function createExecaStreamer() {
let resolver: () => void;
const promiseLike: ExecaStreamer = new Promise<void>((aResolver, aRejecter) => {
resolver = aResolver;
}) as any;

promiseLike.stdout = new PassThrough();
// @ts-expect-error technically it is invalid to use resolver "before" it is assigned (but not really)
promiseLike.kill = resolver;
return promiseLike;
}

describe('execCommandCountLines', () => {
it('counts lines, many', async () => {
const streamer = createExecaStreamer();
execaCommand.mockReturnValue(streamer as any);

const promise = execCommandCountLines('some command');

streamer.stdout.write('First line\n');
streamer.stdout.write('Second line\n');
streamer.kill();

expect(await promise).toEqual(2);
});

it('counts lines, one', async () => {
const streamer = createExecaStreamer();
execaCommand.mockReturnValue(streamer as any);

const promise = execCommandCountLines('some command');

streamer.stdout.write('First line\n');
streamer.kill();

expect(await promise).toEqual(1);
});

it('counts lines, none', async () => {
const streamer = createExecaStreamer();
execaCommand.mockReturnValue(streamer as any);

const promise = execCommandCountLines('some command');

streamer.kill();

expect(await promise).toEqual(0);
});
});
35 changes: 35 additions & 0 deletions code/core/src/telemetry/exec-command-count-lines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { createInterface } from 'node:readline';

// eslint-disable-next-line depend/ban-dependencies
import { execaCommand } from 'execa';

/**
* Execute a command in the local terminal and count the lines in the result
*
* @param command The command to execute.
* @param options Execa options
* @returns The number of lines the command returned
*/
export async function execCommandCountLines(
command: string,
options?: Parameters<typeof execaCommand>[1]
) {
const process = execaCommand(command, { shell: true, buffer: false, ...options });
if (!process.stdout) {
// eslint-disable-next-line local-rules/no-uncategorized-errors
throw new Error('Unexpected missing stdout');
}

let lineCount = 0;
const rl = createInterface(process.stdout);
rl.on('line', () => {
lineCount += 1;
});

// If the process errors, this will throw
await process;

rl.close();

return lineCount;
}
14 changes: 14 additions & 0 deletions code/core/src/telemetry/get-application-file-count.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { join } from 'node:path';

import { describe, expect, it } from 'vitest';

import { getApplicationFilesCountUncached } from './get-application-file-count';

const mocksDir = join(__dirname, '..', '__mocks__');

describe('getApplicationFilesCount', () => {
it('should find files with correct names', async () => {
const files = await getApplicationFilesCountUncached(mocksDir);
expect(files).toMatchInlineSnapshot(`2`);
});
});
32 changes: 32 additions & 0 deletions code/core/src/telemetry/get-application-file-count.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { sep } from 'node:path';

import { execCommandCountLines } from './exec-command-count-lines';
import { runTelemetryOperation } from './run-telemetry-operation';

// We are looking for files with the word "page" or "screen" somewhere in them with these exts
const nameMatches = ['page', 'screen'];
const extensions = ['js', 'jsx', 'ts', 'tsx'];

export const getApplicationFilesCountUncached = async (basePath: string) => {
const bothCasesNameMatches = nameMatches.flatMap((match) => [
match,
[match[0].toUpperCase(), ...match.slice(1)].join(''),
]);

const globs = bothCasesNameMatches.flatMap((match) =>
extensions.map((extension) => `"${basePath}${sep}*${match}*.${extension}"`)
);

try {
const command = `git ls-files -- ${globs.join(' ')}`;
return await execCommandCountLines(command);
} catch {
return undefined;
}
};

export const getApplicationFileCount = async (path: string) => {
return runTelemetryOperation('applicationFiles', async () =>
getApplicationFilesCountUncached(path)
);
};
29 changes: 29 additions & 0 deletions code/core/src/telemetry/get-has-router-package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, it } from 'vitest';

import { getHasRouterPackage } from './get-has-router-package';

it('returns true if there is a routing package in package.json', () => {
expect(
getHasRouterPackage({
dependencies: {
react: '^18',
'react-dom': '^18',
'react-router': '^6',
},
})
).toBe(true);
});

it('returns false if there is a routing package in package.json dependencies', () => {
expect(
getHasRouterPackage({
dependencies: {
react: '^18',
'react-dom': '^18',
},
devDependencies: {
'react-router': '^6',
},
})
).toBe(false);
});
37 changes: 37 additions & 0 deletions code/core/src/telemetry/get-has-router-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { PackageJson } from '../types';

const routerPackages = new Set([
'react-router',
'react-router-dom',
'remix',
'@tanstack/react-router',
'expo-router',
'@reach/router',
'react-easy-router',
'@remix-run/router',
'wouter',
'wouter-preact',
'preact-router',
'vue-router',
'unplugin-vue-router',
'@angular/router',
'@solidjs/router',

// metaframeworks that imply routing
'next',
'react-scripts',
'gatsby',
'nuxt',
'@sveltejs/kit',
]);

/**
* @param packageJson The package JSON of the project
* @returns Boolean Does this project use a routing package?
*/
export function getHasRouterPackage(packageJson: PackageJson) {
// NOTE: we just check real dependencies; if it is in dev dependencies, it may just be an example
return Object.keys(packageJson?.dependencies ?? {}).some((depName) =>
routerPackages.has(depName)
);
}
43 changes: 12 additions & 31 deletions code/core/src/telemetry/get-portable-stories-usage.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,18 @@
// eslint-disable-next-line depend/ban-dependencies
import { execaCommand } from 'execa';

import { createFileSystemCache, resolvePathInStorybookCache } from '../common';

const cache = createFileSystemCache({
basePath: resolvePathInStorybookCache('portable-stories'),
ns: 'storybook',
ttl: 24 * 60 * 60 * 1000, // 24h
});
import { execCommandCountLines } from './exec-command-count-lines';
import { runTelemetryOperation } from './run-telemetry-operation';

export const getPortableStoriesFileCountUncached = async (path?: string) => {
const command = `git grep -l composeStor` + (path ? ` -- ${path}` : '');
const { stdout } = await execaCommand(command, {
cwd: process.cwd(),
shell: true,
});

return stdout.split('\n').filter(Boolean).length;
try {
const command = `git grep -l composeStor` + (path ? ` -- ${path}` : '');
return await execCommandCountLines(command);
} catch (err: any) {
// exit code 1 if no matches are found
return err.exitCode === 1 ? 0 : undefined;
}
};

const CACHE_KEY = 'portableStories';
export const getPortableStoriesFileCount = async (path?: string) => {
let cached = await cache.get(CACHE_KEY);
if (!cached) {
try {
const count = await getPortableStoriesFileCountUncached();
cached = { count };
await cache.set(CACHE_KEY, cached);
} catch (err: any) {
// exit code 1 if no matches are found
const count = err.exitCode === 1 ? 0 : null;
cached = { count };
}
}
return cached.count;
return runTelemetryOperation('portableStories', async () =>
getPortableStoriesFileCountUncached(path)
);
};
25 changes: 25 additions & 0 deletions code/core/src/telemetry/run-telemetry-operation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createFileSystemCache, resolvePathInStorybookCache } from '../common';

const cache = createFileSystemCache({
basePath: resolvePathInStorybookCache('telemetry'),
ns: 'storybook',
ttl: 24 * 60 * 60 * 1000, // 24h
});

/**
* Run an (expensive) operation, caching the result in a FS cache for 24 hours.
*
* NOTE: if the operation returns `undefined` the value will not be cached. Use this to indicate
* that the operation failed.
*/
export const runTelemetryOperation = async <T>(cacheKey: string, operation: () => Promise<T>) => {
let cached = await cache.get<T>(cacheKey);
if (cached === undefined) {
cached = await operation();
// Undefined indicates an error, setting isn't really valuable.
if (cached !== undefined) {
await cache.set(cacheKey, cached);
}
}
return cached;
};
Loading

0 comments on commit c3e1496

Please sign in to comment.