Files
vercel/packages/fs-detectors/test/unit.framework-detector.test.ts
Andrew Gadzik b480e632a3 Add writeFile to DetectorFilesystem (#8493)
Adds a `writeFile` function to `DetectorFilesystem` that will be used to update the various file cache maps.

When detecting npm7+ monorepos, we identified a performance improvement where the service can inspect the `package-lock.json` file for workspaces, and reuse the package information for each workspace in framework-detection.

The pseudo code in `vercel/api` will look something like this

For a given lockfile
```json
{
  ...,
  "packages": {
    "": {
      "name": "npm-workspaces",
      "version": "1.0.0",
      "license": "ISC",
      "workspaces": {
        "packages": [
          "apps/*"
        ]
      }
    },
    "apps/admin": {
      "version": "0.1.0",
      "dependencies": {
        "next": "12.2.5",
        "react": "18.2.0",
        "react-dom": "18.2.0"
      },
      "devDependencies": {
        "eslint": "8.23.0",
        "eslint-config-next": "12.2.5"
      }
    },
    ...,
}
```

```ts
// for each projectPath we detect in package-lock.json
// switch the cwd of the fs to the project directory
const projectFs = fs.chdir(projectPath);
// gets the package info from the lockfile
const projectPackageInfo = lockFile.packages[projectPath];
// insert this content into fs cache
projectFs.writeFile('package.json', projectPackageInfo)
// call detectFramework, which should now have a cached "package.json" file 
const projectFramework = await detectFramework(projectFs);
```

### Related Issues

Related to [HIT-57](https://linear.app/vercel/issue/HIT-57/monorepo-detection-api-prevent-rate-limits)

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-08-31 18:51:03 +00:00

368 lines
10 KiB
TypeScript
Vendored

import path from 'path';
import frameworkList from '@vercel/frameworks';
import { detectFramework, DetectorFilesystem } from '../src';
import { Stat } from '../src/detectors/filesystem';
const posixPath = path.posix;
class VirtualFilesystem extends DetectorFilesystem {
private files: Map<string, Buffer>;
private cwd: string;
constructor(files: { [key: string]: string | Buffer }, cwd = '') {
super();
this.files = new Map();
this.cwd = cwd;
Object.entries(files).map(([key, value]) => {
const buffer = typeof value === 'string' ? Buffer.from(value) : value;
this.files.set(key, buffer);
});
}
private _normalizePath(rawPath: string): string {
return posixPath.normalize(rawPath);
}
async _hasPath(name: string): Promise<boolean> {
const basePath = this._normalizePath(posixPath.join(this.cwd, name));
for (const file of this.files.keys()) {
if (file.startsWith(basePath)) {
return true;
}
}
return false;
}
async _isFile(name: string): Promise<boolean> {
const basePath = this._normalizePath(posixPath.join(this.cwd, name));
return this.files.has(basePath);
}
async _readFile(name: string): Promise<Buffer> {
const basePath = this._normalizePath(posixPath.join(this.cwd, name));
const file = this.files.get(basePath);
if (file === undefined) {
throw new Error('File does not exist');
}
if (typeof file === 'string') {
return Buffer.from(file);
}
return file;
}
/**
* An example of how to implement readdir for a virtual filesystem.
*/
async _readdir(name = '/'): Promise<Stat[]> {
return (
[...this.files.keys()]
.map(filepath => {
const basePath = this._normalizePath(
posixPath.join(this.cwd, name === '/' ? '' : name)
);
const fileDirectoryName = posixPath.dirname(filepath);
if (fileDirectoryName === basePath) {
return {
name: posixPath.basename(filepath),
path: filepath.replace(
this.cwd === '' ? this.cwd : `${this.cwd}/`,
''
),
type: 'file',
};
}
if (
(basePath === '.' && fileDirectoryName !== '.') ||
fileDirectoryName.startsWith(basePath)
) {
let subDirectoryName = fileDirectoryName.replace(
basePath === '.' ? '' : `${basePath}/`,
''
);
if (subDirectoryName.includes('/')) {
subDirectoryName = subDirectoryName.split('/')[0];
}
return {
name: subDirectoryName,
path:
name === '/'
? subDirectoryName
: this._normalizePath(posixPath.join(name, subDirectoryName)),
type: 'dir',
};
}
return null;
})
// remove nulls
.filter((stat): stat is Stat => stat !== null)
// remove duplicates
.filter(
(stat, index, self) =>
index ===
self.findIndex(s => s.name === stat.name && s.path === stat.path)
)
);
}
/**
* An example of how to implement chdir for a virtual filesystem.
*/
_chdir(name: string): DetectorFilesystem {
const basePath = this._normalizePath(posixPath.join(this.cwd, name));
const files = Object.fromEntries(
[...this.files.keys()].map(key => [key, this.files.get(key) ?? ''])
);
return new VirtualFilesystem(files, basePath);
}
}
describe('DetectorFilesystem', () => {
it('should return the directory contents relative to the cwd', async () => {
const files = {
'package.json': '{}',
'packages/app1/package.json': '{}',
'packages/app2/package.json': '{}',
};
const fs = new VirtualFilesystem(files);
expect(await fs.readdir('/')).toEqual([
{ name: 'package.json', path: 'package.json', type: 'file' },
{ name: 'packages', path: 'packages', type: 'dir' },
]);
expect(await fs.readdir('packages')).toEqual([
{ name: 'app1', path: 'packages/app1', type: 'dir' },
{ name: 'app2', path: 'packages/app2', type: 'dir' },
]);
expect(await fs.readdir('./packages')).toEqual([
{ name: 'app1', path: 'packages/app1', type: 'dir' },
{ name: 'app2', path: 'packages/app2', type: 'dir' },
]);
expect(await fs.readdir('packages/app1')).toEqual([
{
name: 'package.json',
path: 'packages/app1/package.json',
type: 'file',
},
]);
});
it('should be able to write files', async () => {
const files = {};
const fs = new VirtualFilesystem(files);
fs.writeFile('file.txt', 'Hello World');
expect(await fs.readFile('file.txt')).toEqual(Buffer.from('Hello World'));
expect(await fs.hasPath('file.txt')).toBe(true);
expect(await fs.isFile('file.txt')).toBe(true);
});
it('should be able to change directories', async () => {
const nextPackageJson = JSON.stringify({
dependencies: {
next: '9.0.0',
},
});
const gatsbyPackageJson = JSON.stringify({
dependencies: {
gatsby: '1.0.0',
},
});
const files = {
'package.json': '{}',
'packages/app1/package.json': nextPackageJson,
'packages/app2/package.json': gatsbyPackageJson,
};
const fs = new VirtualFilesystem(files);
const packagesFs = fs.chdir('packages');
expect(await packagesFs.readdir('/')).toEqual([
{ name: 'app1', path: 'app1', type: 'dir' },
{ name: 'app2', path: 'app2', type: 'dir' },
]);
expect(await packagesFs.hasPath('app1')).toBe(true);
expect(await packagesFs.hasPath('app3')).toBe(false);
expect(await packagesFs.isFile('app1')).toBe(false);
expect(await packagesFs.isFile('app2')).toBe(false);
expect(await packagesFs.isFile('app1/package.json')).toBe(true);
expect(await packagesFs.isFile('app2/package.json')).toBe(true);
expect(
await (await packagesFs.readFile('app1/package.json')).toString()
).toEqual(nextPackageJson);
expect(
await (await packagesFs.readFile('app2/package.json')).toString()
).toEqual(gatsbyPackageJson);
expect(await detectFramework({ fs: packagesFs, frameworkList })).toBe(null);
const nextAppFs = packagesFs.chdir('app1');
expect(await nextAppFs.readdir('/')).toEqual([
{ name: 'package.json', path: 'package.json', type: 'file' },
]);
expect(await (await nextAppFs.readFile('package.json')).toString()).toEqual(
nextPackageJson
);
expect(await detectFramework({ fs: nextAppFs, frameworkList })).toBe(
'nextjs'
);
const gatsbyAppFs = packagesFs.chdir('./app2');
expect(await gatsbyAppFs.readdir('/')).toEqual([
{ name: 'package.json', path: 'package.json', type: 'file' },
]);
expect(
await (await gatsbyAppFs.readFile('package.json')).toString()
).toEqual(gatsbyPackageJson);
expect(await detectFramework({ fs: gatsbyAppFs, frameworkList })).toBe(
'gatsby'
);
});
describe('#detectFramework', () => {
it('Do not detect anything', async () => {
const fs = new VirtualFilesystem({
'README.md': '# hi',
'api/cheese.js': 'export default (req, res) => res.end("cheese");',
});
expect(await detectFramework({ fs, frameworkList })).toBe(null);
});
it('Detect Next.js', async () => {
const fs = new VirtualFilesystem({
'package.json': JSON.stringify({
dependencies: {
next: '9.0.0',
},
}),
});
expect(await detectFramework({ fs, frameworkList })).toBe('nextjs');
});
it('Detect frameworks based on ascending order in framework list', async () => {
const fs = new VirtualFilesystem({
'package.json': JSON.stringify({
dependencies: {
next: '9.0.0',
gatsby: '4.18.0',
},
}),
});
expect(await detectFramework({ fs, frameworkList })).toBe('nextjs');
});
it('Detect Nuxt.js', async () => {
const fs = new VirtualFilesystem({
'package.json': JSON.stringify({
dependencies: {
nuxt: '1.0.0',
},
}),
});
expect(await detectFramework({ fs, frameworkList })).toBe('nuxtjs');
});
it('Detect Gatsby', async () => {
const fs = new VirtualFilesystem({
'package.json': JSON.stringify({
dependencies: {
gatsby: '1.0.0',
},
}),
});
expect(await detectFramework({ fs, frameworkList })).toBe('gatsby');
});
it('Detect Hugo #1', async () => {
const fs = new VirtualFilesystem({
'config.yaml': 'baseURL: http://example.org/',
'content/post.md': '# hello world',
});
expect(await detectFramework({ fs, frameworkList })).toBe('hugo');
});
it('Detect Hugo #2', async () => {
const fs = new VirtualFilesystem({
'config.json': '{ "baseURL": "http://example.org/" }',
'content/post.md': '# hello world',
});
expect(await detectFramework({ fs, frameworkList })).toBe('hugo');
});
it('Detect Hugo #3', async () => {
const fs = new VirtualFilesystem({
'config.toml': 'baseURL = "http://example.org/"',
'content/post.md': '# hello world',
});
expect(await detectFramework({ fs, frameworkList })).toBe('hugo');
});
it('Detect Jekyll', async () => {
const fs = new VirtualFilesystem({
'_config.yml': 'config',
});
expect(await detectFramework({ fs, frameworkList })).toBe('jekyll');
});
it('Detect Middleman', async () => {
const fs = new VirtualFilesystem({
'config.rb': 'config',
});
expect(await detectFramework({ fs, frameworkList })).toBe('middleman');
});
it('Detect Scully', async () => {
const fs = new VirtualFilesystem({
'package.json': JSON.stringify({
dependencies: {
'@angular/cli': 'latest',
'@scullyio/init': 'latest',
},
}),
});
expect(await detectFramework({ fs, frameworkList })).toBe('scully');
});
it('Detect Zola', async () => {
const fs = new VirtualFilesystem({
'config.toml': 'base_url = "/"',
});
expect(await detectFramework({ fs, frameworkList })).toBe('zola');
});
});
});