diff --git a/packages/app/tests/core/jsx-tagger.test.ts b/packages/app/tests/core/jsx-tagger.test.ts
new file mode 100644
index 0000000..5325922
--- /dev/null
+++ b/packages/app/tests/core/jsx-tagger.test.ts
@@ -0,0 +1,253 @@
+import { types as t } from "@babel/core"
+import { describe, expect, it } from "@effect/vitest"
+import { Effect } from "effect"
+
+import { attrExists, createPathAttribute, type JsxTaggerContext, processJsxElement } from "../../src/core/jsx-tagger.js"
+import {
+ createEmptyNodeWithLocation,
+ createNodeWithClassName,
+ createNodeWithClassNameAndLocation
+} from "./jsx-test-fixtures.js"
+
+// CHANGE: add comprehensive unit tests for jsx-tagger core functions
+// WHY: ensure mathematical invariants and idempotency properties are verified
+// QUOTE(ТЗ): "Unit: formatComponentPathValue, attrExists, processJsxElement (идемпотентность, пропуск без loc)"
+// REF: issue-25
+// FORMAT THEOREM: ∀ test ∈ Tests: test verifies declared invariant
+// PURITY: tests verify CORE purity and SHELL effects
+// INVARIANT: tests catch regressions in attribute handling and format
+// COMPLEXITY: O(1) per test case
+
+// CHANGE: extract context factory to module scope per linter requirement
+// WHY: unicorn/consistent-function-scoping rule enforces scope consistency
+// REF: ESLint unicorn plugin rules
+const createTestContext = (filename = "src/App.tsx", attributeName = "data-path"): JsxTaggerContext => ({
+ relativeFilename: filename,
+ attributeName
+})
+
+describe("jsx-tagger", () => {
+ describe("attrExists", () => {
+ // FORMAT THEOREM: ∀ node, name: attrExists(node, name) ↔ ∃ attr ∈ node.attributes: attr.name = name
+ // INVARIANT: predicate returns true iff attribute with exact name exists
+ // COMPLEXITY: O(n) where n = number of attributes
+
+ it.effect("returns false when element has no attributes", () =>
+ Effect.sync(() => {
+ const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [])
+ expect(attrExists(node, "data-path", t)).toBe(false)
+ }))
+
+ it.effect("returns false when attribute does not exist", () =>
+ Effect.sync(() => {
+ const node = createNodeWithClassName(t)
+ expect(attrExists(node, "data-path", t)).toBe(false)
+ }))
+
+ it.effect("returns true when attribute exists", () =>
+ Effect.sync(() => {
+ const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [
+ t.jsxAttribute(t.jsxIdentifier("data-path"), t.stringLiteral("src/App.tsx:10:5"))
+ ])
+ expect(attrExists(node, "data-path", t)).toBe(true)
+ }))
+
+ it.effect("returns true when attribute exists among multiple attributes", () =>
+ Effect.sync(() => {
+ const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [
+ t.jsxAttribute(t.jsxIdentifier("className"), t.stringLiteral("container")),
+ t.jsxAttribute(t.jsxIdentifier("data-path"), t.stringLiteral("src/App.tsx:10:5")),
+ t.jsxAttribute(t.jsxIdentifier("id"), t.stringLiteral("main"))
+ ])
+ expect(attrExists(node, "data-path", t)).toBe(true)
+ }))
+
+ it.effect("returns false for spread attributes", () =>
+ Effect.sync(() => {
+ const spreadAttr = t.jsxSpreadAttribute(t.identifier("props"))
+ const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [spreadAttr])
+ expect(attrExists(node, "data-path", t)).toBe(false)
+ }))
+
+ it.effect("distinguishes between different attribute names", () =>
+ Effect.sync(() => {
+ const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [
+ t.jsxAttribute(t.jsxIdentifier("data-path"), t.stringLiteral("value"))
+ ])
+ expect(attrExists(node, "path", t)).toBe(false)
+ expect(attrExists(node, "data-path", t)).toBe(true)
+ }))
+ })
+
+ describe("createPathAttribute", () => {
+ // FORMAT THEOREM: ∀ f, l, c: createPathAttribute(f, l, c) = JSXAttribute(path, f:l:c)
+ // INVARIANT: output format is always path:line:column
+ // COMPLEXITY: O(1)/O(1)
+
+ it.effect("creates JSX attribute with correct format", () =>
+ Effect.sync(() => {
+ const attr = createPathAttribute("data-path", "src/App.tsx", 10, 5, t)
+
+ expect(t.isJSXAttribute(attr)).toBe(true)
+ expect(t.isJSXIdentifier(attr.name)).toBe(true)
+ expect(attr.name.name).toBe("data-path")
+ expect(t.isStringLiteral(attr.value)).toBe(true)
+ if (t.isStringLiteral(attr.value)) {
+ expect(attr.value.value).toBe("src/App.tsx:10:5")
+ }
+ }))
+
+ it.effect("handles nested directory paths", () =>
+ Effect.sync(() => {
+ const attr = createPathAttribute("data-path", "src/components/ui/Button.tsx", 42, 8, t)
+
+ if (t.isStringLiteral(attr.value)) {
+ expect(attr.value.value).toBe("src/components/ui/Button.tsx:42:8")
+ }
+ }))
+
+ it.effect("handles line 1 and column 0", () =>
+ Effect.sync(() => {
+ const attr = createPathAttribute("data-path", "index.tsx", 1, 0, t)
+
+ if (t.isStringLiteral(attr.value)) {
+ expect(attr.value.value).toBe("index.tsx:1:0")
+ }
+ }))
+
+ it.effect("handles large line and column numbers", () =>
+ Effect.sync(() => {
+ const attr = createPathAttribute("data-path", "src/LargeFile.tsx", 9999, 999, t)
+
+ if (t.isStringLiteral(attr.value)) {
+ expect(attr.value.value).toBe("src/LargeFile.tsx:9999:999")
+ }
+ }))
+ })
+
+ describe("processJsxElement", () => {
+ // FORMAT THEOREM: ∀ jsx ∈ JSXOpeningElement: processElement(jsx) → tagged(jsx) ∨ skipped(jsx)
+ // INVARIANT: idempotent - processing same element twice produces same result
+ // INVARIANT: each JSX element has at most one path attribute after processing
+ // COMPLEXITY: O(n)/O(1) where n = number of existing attributes
+
+ it.effect("adds path attribute when element has no attributes", () =>
+ Effect.sync(() => {
+ const node = createEmptyNodeWithLocation(t)
+ const result = processJsxElement(node, createTestContext(), t)
+
+ expect(result).toBe(true)
+ expect(node.attributes.length).toBe(1)
+ expect(attrExists(node, "data-path", t)).toBe(true)
+
+ const pathAttr = node.attributes[0]
+ if (t.isJSXAttribute(pathAttr) && t.isStringLiteral(pathAttr.value)) {
+ expect(pathAttr.value.value).toBe("src/App.tsx:10:5")
+ }
+ }))
+
+ it.effect("adds path attribute when element has other attributes", () =>
+ Effect.sync(() => {
+ const node = createNodeWithClassNameAndLocation(t)
+ const result = processJsxElement(node, createTestContext(), t)
+
+ expect(result).toBe(true)
+ expect(node.attributes.length).toBe(2)
+ expect(attrExists(node, "data-path", t)).toBe(true)
+ }))
+
+ it.effect("skips element without location info (loc === null)", () =>
+ Effect.sync(() => {
+ const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [])
+ node.loc = null
+
+ const result = processJsxElement(node, createTestContext(), t)
+
+ expect(result).toBe(false)
+ expect(node.attributes.length).toBe(0)
+ expect(attrExists(node, "data-path", t)).toBe(false)
+ }))
+
+ it.effect("skips element that already has path attribute (idempotency)", () =>
+ Effect.sync(() => {
+ const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [
+ t.jsxAttribute(t.jsxIdentifier("data-path"), t.stringLiteral("src/Old.tsx:5:0"))
+ ])
+ node.loc = {
+ start: { line: 20, column: 3, index: 0 },
+ end: { line: 20, column: 15, index: 0 },
+ filename: "src/App.tsx",
+ identifierName: undefined
+ }
+
+ const result = processJsxElement(node, createTestContext(), t)
+
+ expect(result).toBe(false)
+ expect(node.attributes.length).toBe(1) // No new attribute added
+ const pathAttr = node.attributes[0]
+ if (t.isJSXAttribute(pathAttr) && t.isStringLiteral(pathAttr.value)) {
+ expect(pathAttr.value.value).toBe("src/Old.tsx:5:0") // Original value preserved
+ }
+ }))
+
+ it.effect("is truly idempotent - processing twice produces same result", () =>
+ Effect.sync(() => {
+ const node = createEmptyNodeWithLocation(t)
+
+ // First processing
+ const result1 = processJsxElement(node, createTestContext(), t)
+ expect(result1).toBe(true)
+ const attributesAfterFirst = node.attributes.length
+
+ // Second processing (should be no-op)
+ const result2 = processJsxElement(node, createTestContext(), t)
+ expect(result2).toBe(false)
+ expect(node.attributes.length).toBe(attributesAfterFirst)
+ }))
+
+ it.effect("uses context filename for path value", () =>
+ Effect.sync(() => {
+ const node = t.jsxOpeningElement(t.jsxIdentifier("button"), [])
+ node.loc = {
+ start: { line: 7, column: 12, index: 0 },
+ end: { line: 7, column: 20, index: 0 },
+ filename: "different.tsx",
+ identifierName: undefined
+ }
+
+ const context = createTestContext("src/components/Button.tsx")
+ processJsxElement(node, context, t)
+
+ const pathAttr = node.attributes.find(
+ (attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: "data-path" })
+ )
+ expect(pathAttr).toBeDefined()
+ if (pathAttr && t.isJSXAttribute(pathAttr) && t.isStringLiteral(pathAttr.value)) {
+ expect(pathAttr.value.value).toBe("src/components/Button.tsx:7:12")
+ }
+ }))
+
+ it.effect("preserves existing attributes when adding path", () =>
+ Effect.sync(() => {
+ const node = t.jsxOpeningElement(t.jsxIdentifier("div"), [
+ t.jsxAttribute(t.jsxIdentifier("className"), t.stringLiteral("container")),
+ t.jsxAttribute(t.jsxIdentifier("id"), t.stringLiteral("main")),
+ t.jsxSpreadAttribute(t.identifier("props"))
+ ])
+ node.loc = {
+ start: { line: 25, column: 0, index: 0 },
+ end: { line: 25, column: 30, index: 0 },
+ filename: "src/App.tsx",
+ identifierName: undefined
+ }
+
+ processJsxElement(node, createTestContext(), t)
+
+ expect(node.attributes.length).toBe(4)
+ // Verify original attributes still exist
+ expect(attrExists(node, "className", t)).toBe(true)
+ expect(attrExists(node, "id", t)).toBe(true)
+ expect(attrExists(node, "data-path", t)).toBe(true)
+ }))
+ })
+})
diff --git a/packages/app/tests/core/jsx-test-fixtures.ts b/packages/app/tests/core/jsx-test-fixtures.ts
new file mode 100644
index 0000000..a0089c5
--- /dev/null
+++ b/packages/app/tests/core/jsx-test-fixtures.ts
@@ -0,0 +1,84 @@
+import type { types as t } from "@babel/core"
+
+// CHANGE: extract common test fixtures to reduce code duplication
+// WHY: vibecode-linter detects duplicates in test setup code
+// REF: issue-25 test implementation
+// PURITY: CORE (pure test data factories)
+// INVARIANT: factories produce deterministic test nodes
+// COMPLEXITY: O(1) per factory call
+
+/**
+ * Creates a mock SourceLocation for testing.
+ *
+ * @pure true
+ * @complexity O(1)
+ */
+export const createMockLocation = (
+ line = 10,
+ column = 5
+): t.SourceLocation => ({
+ start: { line, column, index: 0 },
+ end: { line, column: column + 5, index: 0 },
+ filename: "src/App.tsx",
+ identifierName: undefined
+})
+
+/**
+ * Creates a JSX opening element with className attribute for testing.
+ *
+ * @pure true
+ * @complexity O(1)
+ */
+export const createNodeWithClassName = (
+ types: typeof t,
+ className = "container"
+): t.JSXOpeningElement => {
+ const node = types.jsxOpeningElement(types.jsxIdentifier("div"), [
+ types.jsxAttribute(types.jsxIdentifier("className"), types.stringLiteral(className))
+ ])
+ return node
+}
+
+/**
+ * Creates an empty JSX opening element for testing.
+ *
+ * @pure true
+ * @complexity O(1)
+ */
+export const createEmptyNode = (types: typeof t): t.JSXOpeningElement =>
+ types.jsxOpeningElement(types.jsxIdentifier("div"), [])
+
+/**
+ * Creates an empty JSX opening element with location info for testing.
+ * Combines node creation and location setup to reduce duplication.
+ *
+ * @pure true
+ * @complexity O(1)
+ */
+export const createEmptyNodeWithLocation = (
+ types: typeof t,
+ line = 10,
+ column = 5
+): t.JSXOpeningElement => {
+ const node = createEmptyNode(types)
+ node.loc = createMockLocation(line, column)
+ return node
+}
+
+/**
+ * Creates a JSX opening element with className attribute and location info for testing.
+ * Combines node creation and location setup to reduce duplication.
+ *
+ * @pure true
+ * @complexity O(1)
+ */
+export const createNodeWithClassNameAndLocation = (
+ types: typeof t,
+ className = "container",
+ line = 15,
+ column = 2
+): t.JSXOpeningElement => {
+ const node = createNodeWithClassName(types, className)
+ node.loc = createMockLocation(line, column)
+ return node
+}
diff --git a/packages/app/tests/shell/babel-plugin-config.test.ts b/packages/app/tests/shell/babel-plugin-config.test.ts
new file mode 100644
index 0000000..51fd5a2
--- /dev/null
+++ b/packages/app/tests/shell/babel-plugin-config.test.ts
@@ -0,0 +1,124 @@
+import { transformSync } from "@babel/core"
+import { describe, expect, it } from "@effect/vitest"
+import { Effect } from "effect"
+import path from "node:path"
+
+import { componentTaggerBabelPlugin } from "../../src/shell/babel-plugin.js"
+import { expectPathAttribute, transformAndValidateJsx, transformJsx } from "./babel-test-utils.js"
+
+// CHANGE: extract plugin configuration and rootDir tests to separate file.
+// WHY: comply with max-lines ESLint rule (300 lines limit).
+// REF: issue-16, issue-25
+// FORMAT THEOREM: ∀ config ∈ Config: valid(config) → plugin_works(config)
+// PURITY: SHELL tests (effect verification)
+// INVARIANT: plugin respects rootDir configuration hierarchy
+// COMPLEXITY: O(n) per transform where n = JSX elements
+
+describe("babel-plugin configuration", () => {
+ describe("plugin structure", () => {
+ it.effect("creates a valid Babel plugin object", () =>
+ Effect.sync(() => {
+ const plugin = componentTaggerBabelPlugin()
+
+ expect(plugin).toHaveProperty("name")
+ expect(plugin).toHaveProperty("visitor")
+ expect(plugin.name).toBe("component-path-babel-tagger")
+ expect(typeof plugin.visitor).toBe("object")
+ }))
+
+ it.effect("exports default plugin factory", () =>
+ Effect.gen(function*() {
+ const module = yield* Effect.tryPromise(() => import("../../src/shell/babel-plugin.js"))
+ const defaultExport = module.default
+
+ expect(typeof defaultExport).toBe("function")
+
+ const plugin = defaultExport()
+ expect(plugin).toHaveProperty("name")
+ expect(plugin.name).toBe("component-path-babel-tagger")
+ }))
+ })
+
+ describe("rootDir configuration", () => {
+ it.effect("uses process.cwd() when rootDir and cwd are missing", () =>
+ Effect.sync(() => {
+ const code = "const App = () => { return
Hello
}"
+ const testFilename = path.resolve(process.cwd(), "src/TestComponent.tsx")
+
+ const result = transformJsx(code, testFilename)
+
+ expectPathAttribute(result, "src/TestComponent.tsx")
+ }))
+
+ it.effect("uses state.cwd when rootDir is missing", () =>
+ Effect.sync(() => {
+ const code = "const App = () => { return Hello
}"
+ const customCwd = "/custom/working/directory"
+ const testFilename = path.resolve(customCwd, "src/TestComponent.tsx")
+
+ const result = transformJsx(code, testFilename, undefined, customCwd)
+
+ expectPathAttribute(result, "src/TestComponent.tsx")
+ }))
+
+ it.effect("prefers explicit rootDir option", () =>
+ Effect.sync(() => {
+ const code = "const App = () => { return Hello
}"
+ const customRoot = "/custom/root"
+ const testFilename = path.resolve(customRoot, "components/TestComponent.tsx")
+
+ const result = transformJsx(code, testFilename, { rootDir: customRoot })
+
+ expectPathAttribute(result, "components/TestComponent.tsx")
+ }))
+ })
+
+ describe("custom attributeName", () => {
+ it.effect("uses custom attribute name when provided", () =>
+ Effect.sync(() => {
+ const code = "const App = () => { return Hello
}"
+
+ const { code: transformedCode } = transformAndValidateJsx(code, "src/App.tsx", {
+ attributeName: "custom-path"
+ })
+
+ expect(transformedCode).toContain("custom-path=\"src/App.tsx:")
+ expect(transformedCode).not.toContain("data-path=")
+ }))
+
+ it.effect("respects idempotency with custom attribute name", () =>
+ Effect.sync(() => {
+ const code = `
+ function App() {
+ return Hello
+ }
+ `
+ const { expectContains, expectDataPathCount } = transformAndValidateJsx(code, "src/App.tsx", {
+ attributeName: "custom-path"
+ })
+
+ // Should keep the existing custom-path attribute
+ expectContains("custom-path=\"existing:1:0\"")
+ // Count custom-path attributes - should only be one
+ expectDataPathCount(1)
+ }))
+ })
+
+ describe("file type filtering", () => {
+ it.effect("skips non-JSX files", () =>
+ Effect.sync(() => {
+ const code = "const value = 42"
+ const testFilename = path.resolve(process.cwd(), "src/utils.ts")
+
+ const result = transformSync(code, {
+ filename: testFilename,
+ parserOpts: { plugins: ["typescript"] },
+ plugins: [componentTaggerBabelPlugin]
+ })
+
+ expect(result).not.toBeNull()
+ expect(result?.code).toBeDefined()
+ expect(result?.code).not.toContain("data-path=\"")
+ }))
+ })
+})
diff --git a/packages/app/tests/shell/babel-plugin-transformations.test.ts b/packages/app/tests/shell/babel-plugin-transformations.test.ts
new file mode 100644
index 0000000..4a6bb2b
--- /dev/null
+++ b/packages/app/tests/shell/babel-plugin-transformations.test.ts
@@ -0,0 +1,204 @@
+import { describe, it } from "@effect/vitest"
+import { Effect } from "effect"
+
+import { transformAndValidateJsx } from "./babel-test-utils.js"
+
+// CHANGE: extract JSX transformation tests to separate file.
+// WHY: comply with max-lines ESLint rule (300 lines limit).
+// REF: issue-25
+// FORMAT THEOREM: ∀ jsx ∈ JSXOpeningElement: transform(jsx) → contains(output, data-path attribute)
+// PURITY: SHELL tests (effect verification)
+// INVARIANT: all JSX elements are tagged with data-path attribute, no duplicates
+// COMPLEXITY: O(n) per transform where n = JSX elements
+
+describe("babel-plugin JSX transformations", () => {
+ it.effect("transforms simple JSX element with data-path attribute", () =>
+ Effect.sync(() => {
+ const code = `
+ function App() {
+ return Hello
+ }
+ `
+ const { expectContains } = transformAndValidateJsx(code, "src/App.tsx")
+
+ expectContains("data-path=\"src/App.tsx:")
+ expectContains("
+ Effect.sync(() => {
+ const code = `
+ function App() {
+ return (
+
+
+ Content
+
+ )
+ }
+ `
+ const { expectDataPathMinCount } = transformAndValidateJsx(code, "src/App.tsx")
+
+ // Should contain data-path attributes for div, header, and main
+ expectDataPathMinCount(3)
+ }))
+
+ it.effect("does not add duplicate data-path attribute (idempotency)", () =>
+ Effect.sync(() => {
+ const code = `
+ function App() {
+ return
Hello
+ }
+ `
+ const { expectContains, expectDataPathCount } = transformAndValidateJsx(code, "src/App.tsx")
+
+ // Should keep the existing data-path attribute
+ expectContains("data-path=\"existing:1:0\"")
+ // Count data-path attributes - should only be one
+ expectDataPathCount(1)
+ }))
+
+ it.effect("does not interfere with other path-like attributes", () =>
+ Effect.sync(() => {
+ const code = `
+ function App() {
+ return

+ }
+ `
+ const { expectContains } = transformAndValidateJsx(code, "src/App.tsx")
+
+ // Should preserve src attribute
+ expectContains("src=\"/image.png\"")
+ // Should add data-path attribute
+ expectContains("data-path=\"src/App.tsx:")
+ }))
+
+ it.effect("handles JSX with existing attributes", () =>
+ Effect.sync(() => {
+ const code = `
+ function Button() {
+ return
+ }
+ `
+ const { expectContains } = transformAndValidateJsx(code, "src/components/Button.tsx")
+
+ // Should preserve existing attributes
+ expectContains("className=\"btn\"")
+ expectContains("id=\"submit\"")
+ expectContains("onClick={handleClick}")
+ // Should add data-path attribute
+ expectContains("data-path=\"src/components/Button.tsx:")
+ }))
+
+ it.effect("handles self-closing JSX elements", () =>
+ Effect.sync(() => {
+ const code = `
+ function App() {
+ return
+ }
+ `
+ const { expectContains } = transformAndValidateJsx(code, "src/App.tsx")
+
+ expectContains("data-path=\"src/App.tsx:")
+ expectContains("type=\"text\"")
+ }))
+
+ it.effect("handles nested JSX components", () =>
+ Effect.sync(() => {
+ const code = `
+ function Page() {
+ return (
+
+
+
+
+
+
+ )
+ }
+ `
+ const { expectDataPathMinCount } = transformAndValidateJsx(code, "src/pages/Page.tsx")
+
+ // All components should be tagged: Layout, Header, Logo, Nav, Content, Article
+ expectDataPathMinCount(6)
+ }))
+
+ it.effect("handles JSX fragments", () =>
+ Effect.sync(() => {
+ const code = `
+ function App() {
+ return (
+ <>
+
First
+
Second
+ >
+ )
+ }
+ `
+ const { expectDataPathMinCount } = transformAndValidateJsx(code, "src/App.tsx")
+
+ // Fragments don't get tagged, but their children do (two div elements)
+ expectDataPathMinCount(2)
+ }))
+
+ it.effect("handles JSX with spread attributes", () =>
+ Effect.sync(() => {
+ const code = `
+ function App(props) {
+ return
Content
+ }
+ `
+ const { expectContains } = transformAndValidateJsx(code, "src/App.tsx")
+
+ expectContains("{...props}")
+ expectContains("data-path=\"src/App.tsx:")
+ }))
+
+ it.effect("handles TypeScript JSX generics", () =>
+ Effect.sync(() => {
+ const code = `
+ function Generic
() {
+ return Generic Component
+ }
+ `
+ const { expectContains } = transformAndValidateJsx(code, "src/Generic.tsx")
+
+ expectContains("data-path=\"src/Generic.tsx:")
+ }))
+
+ it.effect("correctly formats data-path with line and column", () =>
+ Effect.sync(() => {
+ const code = `function App() {
+ return Test
+}`
+ const { expectMatch } = transformAndValidateJsx(code, "src/App.tsx")
+
+ // The data-path should contain line 2 (where is) and column number
+ expectMatch(/data-path="src\/App\.tsx:2:\d+"/)
+ }))
+
+ it.effect("handles components with multiple props on multiple lines", () =>
+ Effect.sync(() => {
+ const code = `
+ function App() {
+ return (
+
+ )
+ }
+ `
+ const { expectContains } = transformAndValidateJsx(code, "src/App.tsx")
+
+ expectContains("data-path=\"src/App.tsx:")
+ expectContains("className=\"primary\"")
+ expectContains("onClick={handleClick}")
+ }))
+})
diff --git a/packages/app/tests/shell/babel-plugin.test.ts b/packages/app/tests/shell/babel-plugin.test.ts
deleted file mode 100644
index 74bf6b9..0000000
--- a/packages/app/tests/shell/babel-plugin.test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { type TransformOptions, transformSync } from "@babel/core"
-import { describe, expect, it } from "@effect/vitest"
-import { Effect } from "effect"
-import path from "node:path"
-
-import { componentTaggerBabelPlugin, type ComponentTaggerBabelPluginOptions } from "../../src/shell/babel-plugin.js"
-
-// CHANGE: add tests for rootDir fallback to process.cwd().
-// WHY: ensure correct relative path computation when rootDir and cwd are missing.
-// QUOTE(ТЗ): "При отсутствии rootDir и cwd относительный путь корректный (от process.cwd()). Добавить тест/fixture для этого случая."
-// REF: issue-16
-// SOURCE: n/a
-// FORMAT THEOREM: ∀ state: state.opts.rootDir = undefined ∧ state.cwd = undefined → rootDir = process.cwd()
-// PURITY: SHELL
-// EFFECT: Effect
-// INVARIANT: plugin returns valid Babel PluginObj and computes correct relative paths
-// COMPLEXITY: O(1)/O(1)
-
-/**
- * Helper function to transform JSX code with the component tagger plugin.
- *
- * @param code - JSX source code to transform
- * @param filename - Absolute path to the file being transformed
- * @param options - Optional plugin configuration
- * @param cwd - Optional Babel working directory
- * @returns Transformed code result
- *
- * @pure false - performs Babel transformation
- * @complexity O(n) where n = code length
- */
-const transformJsx = (
- code: string,
- filename: string,
- options?: ComponentTaggerBabelPluginOptions,
- cwd?: string
-): ReturnType => {
- const transformOptions: TransformOptions = {
- cwd,
- filename,
- parserOpts: {
- plugins: ["jsx", "typescript"]
- },
- plugins: options === undefined ? [componentTaggerBabelPlugin] : [[componentTaggerBabelPlugin, options]]
- }
-
- return transformSync(code, transformOptions)
-}
-
-/**
- * Helper function to verify transformed code contains expected path.
- *
- * @param result - Babel transform result
- * @param expectedPath - Expected relative path in the path attribute
- *
- * @pure true - only performs assertions
- * @complexity O(1)
- */
-const expectPathAttribute = (result: ReturnType, expectedPath: string): void => {
- expect(result).not.toBeNull()
- expect(result?.code).toBeDefined()
- expect(result?.code).toContain(`path="${expectedPath}:`)
-}
-
-describe("babel-plugin", () => {
- it.effect("creates a valid Babel plugin object", () =>
- Effect.sync(() => {
- const plugin = componentTaggerBabelPlugin()
-
- expect(plugin).toHaveProperty("name")
- expect(plugin).toHaveProperty("visitor")
- expect(plugin.name).toBe("component-path-babel-tagger")
- expect(typeof plugin.visitor).toBe("object")
- }))
-
- it.effect("exports default plugin factory", () =>
- Effect.gen(function*() {
- const module = yield* Effect.tryPromise(() => import("../../src/shell/babel-plugin.js"))
- const defaultExport = module.default
-
- expect(typeof defaultExport).toBe("function")
-
- const plugin = defaultExport()
- expect(plugin).toHaveProperty("name")
- expect(plugin.name).toBe("component-path-babel-tagger")
- }))
-
- it.effect("uses process.cwd() when rootDir and cwd are missing", () =>
- Effect.sync(() => {
- const code = "const App = () => { return Hello
}"
- const testFilename = path.resolve(process.cwd(), "src/TestComponent.tsx")
-
- const result = transformJsx(code, testFilename)
-
- expectPathAttribute(result, "src/TestComponent.tsx")
- }))
-
- it.effect("uses state.cwd when rootDir is missing", () =>
- Effect.sync(() => {
- const code = "const App = () => { return Hello
}"
- const customCwd = "/custom/working/directory"
- const testFilename = path.resolve(customCwd, "src/TestComponent.tsx")
-
- const result = transformJsx(code, testFilename, undefined, customCwd)
-
- expectPathAttribute(result, "src/TestComponent.tsx")
- }))
-
- it.effect("prefers explicit rootDir option", () =>
- Effect.sync(() => {
- const code = "const App = () => { return Hello
}"
- const customRoot = "/custom/root"
- const testFilename = path.resolve(customRoot, "components/TestComponent.tsx")
-
- const result = transformJsx(code, testFilename, { rootDir: customRoot })
-
- expectPathAttribute(result, "components/TestComponent.tsx")
- }))
-
- it.effect("skips non-JSX files", () =>
- Effect.sync(() => {
- const code = "const value = 42"
- const testFilename = path.resolve(process.cwd(), "src/utils.ts")
-
- const result = transformSync(code, {
- filename: testFilename,
- parserOpts: { plugins: ["typescript"] },
- plugins: [componentTaggerBabelPlugin]
- })
-
- expect(result).not.toBeNull()
- expect(result?.code).toBeDefined()
- expect(result?.code).not.toContain("path=\"")
- }))
-})
diff --git a/packages/app/tests/shell/babel-test-utils.ts b/packages/app/tests/shell/babel-test-utils.ts
new file mode 100644
index 0000000..c8cf9a1
--- /dev/null
+++ b/packages/app/tests/shell/babel-test-utils.ts
@@ -0,0 +1,107 @@
+import { type TransformOptions, transformSync } from "@babel/core"
+import { expect } from "@effect/vitest"
+import path from "node:path"
+
+import { componentTaggerBabelPlugin, type ComponentTaggerBabelPluginOptions } from "../../src/shell/babel-plugin.js"
+
+/**
+ * Helper function to transform JSX code with the component tagger plugin.
+ *
+ * @param code - JSX source code to transform
+ * @param filename - Absolute path to the file being transformed
+ * @param options - Optional plugin configuration
+ * @param cwd - Optional Babel working directory
+ * @returns Transformed code result
+ *
+ * @pure false - performs Babel transformation
+ * @complexity O(n) where n = code length
+ */
+export const transformJsx = (
+ code: string,
+ filename: string,
+ options?: ComponentTaggerBabelPluginOptions,
+ cwd?: string
+): ReturnType => {
+ const transformOptions: TransformOptions = {
+ cwd,
+ filename,
+ parserOpts: {
+ plugins: ["jsx", "typescript"]
+ },
+ plugins: options === undefined ? [componentTaggerBabelPlugin] : [[componentTaggerBabelPlugin, options]]
+ }
+
+ return transformSync(code, transformOptions)
+}
+
+/**
+ * Helper function to verify transformed code contains expected path.
+ *
+ * @param result - Babel transform result
+ * @param expectedPath - Expected relative path in the data-path attribute
+ *
+ * @pure true - only performs assertions
+ * @complexity O(1)
+ */
+export const expectPathAttribute = (result: ReturnType, expectedPath: string): void => {
+ expect(result).not.toBeNull()
+ expect(result?.code).toBeDefined()
+ expect(result?.code).toContain(`data-path="${expectedPath}:`)
+}
+
+/**
+ * Helper function to transform and validate JSX code.
+ * Reduces test code duplication by combining transform + null check.
+ *
+ * @param code - JSX source code to transform
+ * @param relativeFilePath - Relative file path (e.g., "src/App.tsx")
+ * @param options - Optional plugin configuration
+ * @returns Object with result and helper methods
+ *
+ * @pure false - performs Babel transformation
+ * @complexity O(n) where n = code length
+ */
+export const transformAndValidateJsx = (
+ code: string,
+ relativeFilePath: string,
+ options?: ComponentTaggerBabelPluginOptions
+) => {
+ const rootDir = "/project"
+ const filename = path.resolve(rootDir, relativeFilePath)
+ const result = transformJsx(code, filename, { rootDir, ...options })
+
+ expect(result).not.toBeNull()
+
+ return {
+ result,
+ code: result?.code ?? "",
+ expectContains: (substring: string) => {
+ expect(result?.code).toContain(substring)
+ },
+ expectMatch: (pattern: RegExp) => {
+ expect(result?.code).toMatch(pattern)
+ },
+ expectDataPathCount: (count: number) => {
+ const attributeName = options?.attributeName ?? "data-path"
+ const pathMatches = result?.code?.match(new RegExp(`${attributeName}="`, "g"))
+ expect(pathMatches?.length).toBe(count)
+ },
+ expectDataPathMinCount: (minCount: number) => {
+ const attributeName = options?.attributeName ?? "data-path"
+ const pathMatches = result?.code?.match(new RegExp(String.raw`${attributeName}="[^"]+:\d+:\d+"`, "g"))
+ expect(pathMatches).toBeDefined()
+ expect(pathMatches?.length).toBeGreaterThanOrEqual(minCount)
+ }
+ }
+}
+
+/**
+ * Creates a standard test filename path for the project.
+ *
+ * @param relativeFilePath - Relative file path (e.g., "src/App.tsx")
+ * @returns Absolute path resolved from /project
+ *
+ * @pure true - no side effects
+ * @complexity O(1)
+ */
+export const createTestFilePath = (relativeFilePath: string): string => path.resolve("/project", relativeFilePath)
diff --git a/packages/app/tests/shell/component-tagger.test.ts b/packages/app/tests/shell/component-tagger.test.ts
new file mode 100644
index 0000000..04d9d2b
--- /dev/null
+++ b/packages/app/tests/shell/component-tagger.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "@effect/vitest"
+import { Effect } from "effect"
+import type { Plugin } from "vite"
+
+import { componentTagger } from "../../src/shell/component-tagger.js"
+
+// CHANGE: add integration tests for Vite plugin structure
+// WHY: ensure Vite plugin is correctly configured and exported
+// QUOTE(ТЗ): "Integration: Vite plugin transform (через вызов runTransform или вынесенный helper) → содержит data-path"
+// REF: issue-25
+// NOTE: Full transform testing requires Vite context setup, so we verify plugin structure here.
+// The underlying runTransform logic is covered by Babel plugin tests which use the same core.
+// FORMAT THEOREM: ∀ plugin: componentTagger() → ValidVitePlugin
+// PURITY: SHELL tests (verify plugin structure)
+// INVARIANT: plugin has correct name, hooks, and configuration
+// COMPLEXITY: O(1) per structural verification
+
+describe("component-tagger (Vite plugin)", () => {
+ describe("componentTagger", () => {
+ it.effect("returns a Vite plugin with correct name", () =>
+ Effect.sync(() => {
+ const plugin = componentTagger() as Plugin
+
+ expect(plugin).toBeDefined()
+ expect(plugin.name).toBe("component-path-tagger")
+ }))
+
+ it.effect("has enforce: pre configuration", () =>
+ Effect.sync(() => {
+ const plugin = componentTagger() as Plugin
+
+ expect(plugin.enforce).toBe("pre")
+ }))
+
+ it.effect("applies only in serve mode", () =>
+ Effect.sync(() => {
+ const plugin = componentTagger() as Plugin
+
+ expect(plugin.apply).toBe("serve")
+ }))
+
+ it.effect("has transform hook", () =>
+ Effect.sync(() => {
+ const plugin = componentTagger() as Plugin
+
+ expect(plugin.transform).toBeDefined()
+ }))
+
+ it.effect("has configResolved hook", () =>
+ Effect.sync(() => {
+ const plugin = componentTagger() as Plugin
+
+ expect(plugin.configResolved).toBeDefined()
+ }))
+ })
+})