Skip to content

Discover plugins automatically #197

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8ff3fa6
Implement auto-discovery for plugins
illright Jan 19, 2025
41fb315
Merge branch 'next' into auto-plugin-discover
illright Feb 1, 2025
8a8aadb
Separate Vitest-dependent utils into a different entrypoint
illright Feb 2, 2025
5e9e18a
Make the changeset a minor one
illright Feb 2, 2025
dc06920
Merge branch 'separate-test-utils' into auto-plugin-discover
illright Feb 8, 2025
1d017a5
WIP: tests for plugin auto discovery
illright Feb 17, 2025
710355d
Move the toolkit's internal tests out of the source
illright May 10, 2025
ad7f49f
Finish the basic test for plugin discovery
illright May 10, 2025
ae54034
Add some more unit tests
illright May 10, 2025
4952eb1
Rename the integration test to fit the feature name being tested
illright May 10, 2025
8adac56
Add a test for suggesting and installing the FSD plugin
illright May 10, 2025
9ed9715
Tell TypeScript that `targetPath` cannot be undefined
illright May 11, 2025
e31c988
Double the integration test timeouts
illright May 11, 2025
e577534
Fix monorepo issues
illright May 11, 2025
a0abd9a
Use file URLs for Windows compatibility
illright May 11, 2025
89a5b39
Install the Steiger plugin through a tarball during testing
illright May 11, 2025
b161df6
Increase timeouts for the smoke test
illright May 11, 2025
a6db528
Increase timeouts yet again
illright May 11, 2025
731b581
Increase timeouts for the other test too
illright May 11, 2025
e644f73
Really increase timeouts this time
illright May 11, 2025
6952b0c
Clean up workspace node_modules in the test
illright May 13, 2025
6fa570a
Copy test files selectively
illright May 13, 2025
f61536f
debug
illright May 13, 2025
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
5 changes: 5 additions & 0 deletions .changeset/curly-years-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@steiger/toolkit': minor
---

Separate Vitest-dependent utilities into a different entrypoint to make Vitest a truly optional peer dependency
8 changes: 8 additions & 0 deletions examples/kitchen-sink-of-fsd-issues/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "kitchen-sink-of-fsd-issues",
"private": true,
"devDependencies": {
"@feature-sliced/steiger-plugin": "workspace:*",
"steiger": "workspace:*"
}
}
11 changes: 5 additions & 6 deletions integration-tests/tests/__snapshots__/smoke-stderr-posix.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
└ fsd/forbidden-imports: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/forbidden-imports

┌ src/entities
✘ Inconsistent pluralization of slice names. Prefer all plural names
✔ Auto-fixable
┌ src/entities/user
✘ Avoid having both "user" and "users" entities.
└ fsd/inconsistent-naming: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/inconsistent-naming

Expand Down Expand Up @@ -40,6 +39,6 @@
└ fsd/no-processes: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/no-processes

────────────────────────────────────────────────────────
Found 8 errors (1 can be fixed automatically with --fix)

────────────────────────────────────────────────
Found 8 errors (none can be fixed automatically)
11 changes: 5 additions & 6 deletions integration-tests/tests/__snapshots__/smoke-stderr-windows.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
└ fsd/forbidden-imports: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/forbidden-imports

┌ src\entities
× Inconsistent pluralization of slice names. Prefer all plural names
√ Auto-fixable
┌ src\entities\user
× Avoid having both "user" and "users" entities.
└ fsd/inconsistent-naming: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/inconsistent-naming

Expand Down Expand Up @@ -40,6 +39,6 @@
└ fsd/no-processes: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/no-processes

────────────────────────────────────────────────────────
Found 8 errors (1 can be fixed automatically with --fix)

────────────────────────────────────────────────
Found 8 errors (none can be fixed automatically)
156 changes: 156 additions & 0 deletions integration-tests/tests/discover-plugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import * as fs from 'node:fs/promises'
import os from 'node:os'
import { join } from 'node:path'
import type { ChildProcess } from 'node:child_process'

import { expect, test } from 'vitest'
import { createViteProject } from '../utils/create-vite-project.js'
import { exec } from 'tinyexec'
import { getSteigerBinPath } from '../utils/get-bin-path.js'
import { getRepoRootPath } from '../utils/get-repo-root-path.js'

const temporaryDirectory = await fs.realpath(os.tmpdir())
const repoRoot = getRepoRootPath()
const steiger = await getSteigerBinPath()

test('auto plugin discovery', { timeout: 60_000 }, async () => {
const project = join(temporaryDirectory, 'auto-discovery')
await createViteProject(project)

const plugin = join(temporaryDirectory, 'custom-steiger-plugin')
await createDummySteigerPlugin(plugin)

await exec('npm', ['install'], { nodeOptions: { cwd: plugin } })
await exec('npm', ['add', `steiger-plugin-dummy@file:${plugin}`], { nodeOptions: { cwd: project } })

function getDetectedPlugins(versionOutput: string) {
const [_steigerVersion, plugins] = versionOutput.trim().split('\n\n', 2)
return plugins.split('\n').map((line) => ({ name: line.split('\t')[0], version: line.split('\t')[1] }))
}

const resultWithOnlyDummy = await exec(steiger, ['-v'], { nodeOptions: { cwd: project, env: { NO_COLOR: '1' } } })
expect(resultWithOnlyDummy.stderr).toEqual('')
expect(getDetectedPlugins(resultWithOnlyDummy.stdout)).toEqual([
{ name: 'steiger-plugin-dummy', version: '1.0.0-alpha.0' },
])

await exec(
'npm',
['add', `@feature-sliced/steiger-plugin@file:${join(repoRoot, 'packages', 'steiger-plugin-fsd')}`],
{ nodeOptions: { cwd: project } },
)

const resultWithDummyAndFsd = await exec(steiger, ['-v'], { nodeOptions: { cwd: project, env: { NO_COLOR: '1' } } })
expect(resultWithDummyAndFsd.stderr).toEqual('')
expect(getDetectedPlugins(resultWithDummyAndFsd.stdout)).toEqual([
{ name: '@feature-sliced/steiger-plugin', version: expect.any(String) },
{ name: 'steiger-plugin-dummy', version: '1.0.0-alpha.0' },
])
})

test('suggestion to install the FSD plugin', { timeout: 4 * 60_000 }, async () => {
const project = join(temporaryDirectory, 'suggest-fsd-plugin')
await createViteProject(project)

const execResult = exec(steiger, ['./src'], {
nodeOptions: { stdio: 'pipe', cwd: project, env: { NO_COLOR: '1', npm_config_user_agent: undefined } },
})
const steigerProcess = execResult.process!

await expect(getNewProcessOutput(steigerProcess, { until: '○ No' })).resolves.toContain(
"Couldn't find any plugins in package.json. Are you trying to check this project's compliance to Feature-Sliced Design (https://feature-sliced.design)?",
)
console.log('got first batch of output')
steigerProcess.stdin?.write('y')

await expect(getNewProcessOutput(steigerProcess, { until: '○ No' })).resolves.toContain(
'Okay! Would you like to run `npm add -D @feature-sliced/steiger-plugin` in suggest-fsd-plugin (path: .) to install the FSD plugin?',
)
console.log('got second batch of output')
steigerProcess.stdin?.write('y')

await expect(getNewProcessOutput(steigerProcess, { until: 'All done!' })).resolves.toContain(
"All done! Now let's run the FSD checks.",
)
console.log('got third batch of output')

const packageJson = (await fs
.readFile(join(project, 'package.json'), { encoding: 'utf-8' })
.then(JSON.parse)) as Record<string, Record<string, string>>
expect(packageJson.devDependencies['@feature-sliced/steiger-plugin']).not.toBeUndefined()
await expect(getNewProcessOutput(execResult.process!, { stream: 'stderr' })).resolves.toContain('No problems found!')
await execResult
expect(execResult.exitCode).toEqual(0)
})

async function createDummySteigerPlugin(location: string) {
await fs.rm(location, { recursive: true, force: true })
await fs.mkdir(location, { recursive: true })
const packageJsonContents = JSON.stringify(
{
name: 'steiger-plugin-dummy',
version: '1.0.0-alpha.0',
type: 'module',
exports: {
import: './index.mjs',
},
dependencies: {
'@steiger/toolkit': `file:${join(repoRoot, 'packages', 'toolkit')}`,
},
},
null,
2,
)
await fs.writeFile(join(location, 'package.json'), packageJsonContents)

const indexMjsContents = `
import { enableAllRules, createPlugin, createConfigs } from '@steiger/toolkit';

const plugin = createPlugin({
meta: {
name: 'steiger-plugin-dummy',
version: '1.0.0-alpha.0',
},
ruleDefinitions: [
{
name: 'dummy/rule1',
check(root) {
return { diagnostics: [{ message: 'Root detected', location: { path: root.path } }] };
},
},
],
});

const configs = createConfigs({
recommended: enableAllRules(plugin),
});

export default {
plugin,
configs,
};
`
await fs.writeFile(join(location, 'index.mjs'), indexMjsContents)
}

/**
* Read the stdout/stderr stream of the process until the specified string is found.
*
* If no string is specified, it will return the first chunk of output.
*/
function getNewProcessOutput(
process: ChildProcess,
{ until, stream = 'stdout' }: { until?: string; stream?: 'stdout' | 'stderr' } = {},
): Promise<string> {
return new Promise((resolve) => {
let output = ''
function onData(data: string) {
output += data
if (until === undefined || output.includes(until)) {
process[stream]?.off('data', onData)
resolve(output)
}
}
process[stream]?.on('data', onData)
})
}
25 changes: 21 additions & 4 deletions integration-tests/tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,35 @@ import { exec } from 'tinyexec'
import { expect, test } from 'vitest'

import { getSteigerBinPath } from '../utils/get-bin-path.js'
import { getSnapshotPath } from '../utils/get-snapshot-path.js'
import { getRepoRootPath } from '../utils/get-repo-root-path.js'

const temporaryDirectory = await fs.realpath(os.tmpdir())
const repoRoot = getRepoRootPath()
const steiger = await getSteigerBinPath()
const kitchenSinkExample = join(dirname(fileURLToPath(import.meta.url)), '../../examples/kitchen-sink-of-fsd-issues')
const pathPlatform = os.platform() === 'win32' ? 'windows' : 'posix'

test('basic functionality in the kitchen sink example project', async () => {
test('basic functionality in the kitchen sink example project', { timeout: 30_000 }, async () => {
const project = join(temporaryDirectory, 'smoke')
await fs.rm(project, { recursive: true, force: true })
await fs.cp(kitchenSinkExample, project, { recursive: true })
await fs.mkdir(join(project, 'src'), { recursive: true })
await fs.cp(join(kitchenSinkExample, 'src'), join(project, 'src'), { recursive: true })
await fs.cp(join(kitchenSinkExample, 'tsconfig.app.json'), join(project, 'tsconfig.app.json'))
await fs.cp(join(kitchenSinkExample, 'tsconfig.json'), join(project, 'tsconfig.json'))

const steigerPluginPath = join(repoRoot, 'packages', 'steiger-plugin-fsd')
const { stdout: steigerPluginTarball } = await exec('npm', ['pack'], {
nodeOptions: { cwd: steigerPluginPath },
throwOnError: true,
})
await exec('npm', ['install', join(steigerPluginPath, steigerPluginTarball.trim())], {
nodeOptions: { cwd: project },
throwOnError: true,
})

const { stderr } = await exec('node', [steiger, 'src'], { nodeOptions: { cwd: project, env: { NO_COLOR: '1' } } })

await expect(stderr).toMatchFileSnapshot(join('__snapshots__', `smoke-stderr-${pathPlatform}.txt`))
await expect(stderr).toMatchFileSnapshot(getSnapshotPath('smoke-stderr'))

await fs.rm(join(steigerPluginPath, steigerPluginTarball.trim()))
})
31 changes: 31 additions & 0 deletions integration-tests/utils/create-vite-project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as fs from 'node:fs/promises'

import { exec } from 'tinyexec'

/** Run `npm create vite` in a given location using the Vanilla TS template (or a template of choice). */
export async function createViteProject(
location: string,
{ template = 'vanilla-ts' }: { template?: ViteTemplate } = {},
) {
await fs.rm(location, { recursive: true, force: true })
await fs.mkdir(location, { recursive: true })
return exec('npm', ['create', 'vite', '-y', '--', '.', '--template', template], { nodeOptions: { cwd: location } })
}

type ViteTemplate =
| 'vanilla'
| 'vanilla-ts'
| 'vue'
| 'vue-ts'
| 'react'
| 'react-ts'
| 'preact'
| 'preact-ts'
| 'lit'
| 'lit-ts'
| 'svelte'
| 'svelte-ts'
| 'solid'
| 'solid-ts'
| 'qwik'
| 'qwik-ts'
6 changes: 3 additions & 3 deletions integration-tests/utils/get-bin-path.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { promises as fs } from 'node:fs'
import * as process from 'node:process'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { getBinPath } from 'get-bin-path'
import { getRepoRootPath } from './get-repo-root-path.js'

/**
* Resolve the full path to the built JS file of Steiger.
*
* Rejects if the file doesn't exist.
*/
export async function getSteigerBinPath() {
const steiger = (await getBinPath({ cwd: join(dirname(fileURLToPath(import.meta.url)), '../../packages/steiger') }))!
const steiger = (await getBinPath({ cwd: join(getRepoRootPath(), './packages/steiger') }))!
try {
await fs.stat(steiger)
} catch {
Expand Down
9 changes: 9 additions & 0 deletions integration-tests/utils/get-repo-root-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

/** Return the absolute path to the root of this repository. */
export function getRepoRootPath() {
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRootPath = join(__dirname, '..', '..')
return repoRootPath
}
8 changes: 8 additions & 0 deletions integration-tests/utils/get-snapshot-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import os from 'node:os'
import { join } from 'node:path'

const pathPlatform = os.platform() === 'win32' ? 'windows' : 'posix'

export function getSnapshotPath(snapshotName: string) {
return join('__snapshots__', `${snapshotName}-${pathPlatform}.txt`)
}
3 changes: 2 additions & 1 deletion packages/pretty-reporter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
"typescript": "^5.7.3",
"vitest": "^3.0.2"
},
"dependencies": {
"figures": "^6.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { TSConfckParseResult } from 'tsconfck'
import { dirname, resolve } from 'node:path'
import { joinFromRoot } from '@steiger/toolkit'
import { joinFromRoot } from '@steiger/toolkit/test'

export type CollectRelatedTsConfigsPayload = {
tsconfig: TSConfckParseResult['tsconfig']
Expand Down
3 changes: 2 additions & 1 deletion packages/steiger-plugin-fsd/src/_lib/index-source-files.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getIndex, getLayers, getSegments, getSlices, isSliced, type LayerName } from '@feature-sliced/filesystem'
import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot, type File, type Folder } from '@steiger/toolkit'
import type { File, Folder } from '@steiger/toolkit'
import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test'

type SourceFile = {
file: File
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { join } from 'node:path'
import { expect, it } from 'vitest'

import ambiguousSliceNames from './index.js'
import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit'
import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test'

it('reports no errors on a project without slice names that match some segment name in Shared', () => {
const root = parseIntoFsdRoot(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, it } from 'vitest'

import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit'
import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test'
import excessiveSlicing from './index.js'

it('reports no errors on projects with moderate slicing', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, it, vi } from 'vitest'

import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit'
import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit/test'
import forbiddenImports from './index.js'

vi.mock('tsconfck', async (importOriginal) => {
Expand All @@ -23,7 +23,7 @@ vi.mock('tsconfck', async (importOriginal) => {

vi.mock('node:fs', async (importOriginal) => {
const originalFs = await importOriginal<typeof import('fs')>()
const { createFsMocks } = await import('@steiger/toolkit')
const { createFsMocks } = await import('@steiger/toolkit/test')

return createFsMocks(
{
Expand Down
Loading
Loading