diff --git a/CLAUDE.md b/CLAUDE.md index 6313a0b..bb938fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,13 +1,13 @@ # Next Commerce Theme Kit -CLI tool (`ntk`) for building and maintaining storefront themes on the Next Commerce platform. Supports Sass processing via `libsass`. +CLI tool (`ntk`) for building and maintaining storefront themes on the Next Commerce platform. Supports Sass processing via `libsass` and Tailwind CSS compilation. ## Project Structure ``` ntk/ __main__.py # Entry point - command.py # All CLI commands (watch, push, pull, checkout, init, list, sass) + command.py # All CLI commands (watch, push, pull, checkout, init, list, sass, tailwind) conf.py # Config loading and constants decorator.py # @parser_config decorator for command validation gateway.py # API client for Next Commerce store @@ -17,6 +17,7 @@ tests/ test_gateway.py test_config.py test_installer.py + test_tailwind.py ``` ## Development Setup @@ -71,6 +72,27 @@ pytest --cov=ntk --cov-report xml Requires Python >= 3.10. Tested against 3.10, 3.11, 3.12, 3.13, 3.14 via tox and GitHub Actions. +## Tailwind CSS Integration + +ntk supports Tailwind CSS themes alongside traditional Sass themes: + +- **Auto-detection:** `ntk watch` and `ntk tailwind` auto-detect Tailwind v4 (`@import "tailwindcss"` in `css/input.css`) or v3 (`tailwind.config.js`) +- **Binary resolution:** Checks `./tailwindcss` (local binary), `tailwindcss` (PATH), `npx tailwindcss` in order +- **sass-compat:** If `scripts/sass-compat.py` exists, runs it automatically after Tailwind compilation (strips `@property`, converts oklch to hex, replaces `color-mix()`) +- **Config:** Optional `tailwind:` section in `config.yml`: + ```yaml + development: + tailwind: + input: css/input.css # default + output: assets/main.css # default + binary: ./tailwindcss # auto-detected + sass_compat: scripts/sass-compat.py # auto-detected + ``` +- **Commands:** + - `ntk tailwind` — compile once + push CSS to store + - `ntk tailwind --minify` — compile minified + push + - `ntk watch` — auto-starts Tailwind watcher alongside file watcher, runs sass-compat on CSS output changes + ## Important Conventions - Use `asyncio.run()` for async entry points — not `get_event_loop()` (broke in Python 3.12+) diff --git a/ntk/command.py b/ntk/command.py index 991f388..8d84d5b 100644 --- a/ntk/command.py +++ b/ntk/command.py @@ -2,6 +2,9 @@ import glob import logging import os +import shlex +import subprocess +import sys import time import sass @@ -151,6 +154,109 @@ def _compile_sass(self): logging.error(f'[{self.config.env}] Sass processing failed, see error below.') logging.error(f'[{self.config.env}] {error}') + def _compile_tailwind(self, minify=False): + """Compile Tailwind CSS using the detected or configured binary.""" + if not self.config.detect_tailwind(): + logging.warning(f'[{self.config.env}] Tailwind CSS not detected. ' + 'Ensure css/input.css exists with @import "tailwindcss" ' + 'and a tailwindcss binary is available.') + return False + + binary = self.config.tailwind_binary + input_file = self.config.tailwind_input + output_file = self.config.tailwind_output + + cmd = f'{binary} -i {input_file} -o {output_file}' + if minify: + cmd += ' --minify' + + logging.info(f'[{self.config.env}] Compiling Tailwind CSS: {input_file} -> {output_file}') + + try: + result = subprocess.run( + shlex.split(cmd), + capture_output=True, + text=True, + timeout=60 + ) + if result.returncode != 0: + logging.error(f'[{self.config.env}] Tailwind compilation failed:') + if result.stderr: + for line in result.stderr.strip().splitlines(): + logging.error(f'[{self.config.env}] {line}') + return False + + logging.info(f'[{self.config.env}] Tailwind CSS compiled successfully.') + + except FileNotFoundError: + logging.error(f'[{self.config.env}] Tailwind CLI not found at "{binary}". ' + 'Download from https://github.com/tailwindlabs/tailwindcss/releases ' + 'or install via npm.') + return False + except subprocess.TimeoutExpired: + logging.error(f'[{self.config.env}] Tailwind compilation timed out after 60 seconds.') + return False + + # Run sass-compat.py post-processor if present + if self.config.tailwind_sass_compat and os.path.exists(self.config.tailwind_sass_compat): + logging.info(f'[{self.config.env}] Running sass-compat post-processor on {output_file}') + try: + compat_result = subprocess.run( + [sys.executable, self.config.tailwind_sass_compat, output_file], + capture_output=True, + text=True, + timeout=30 + ) + if compat_result.returncode != 0: + logging.error(f'[{self.config.env}] sass-compat.py failed:') + if compat_result.stderr: + for line in compat_result.stderr.strip().splitlines(): + logging.error(f'[{self.config.env}] {line}') + return False + logging.info(f'[{self.config.env}] sass-compat post-processing complete.') + except FileNotFoundError: + logging.warning(f'[{self.config.env}] Python not found for sass-compat.py. Skipping.') + except subprocess.TimeoutExpired: + logging.error(f'[{self.config.env}] sass-compat.py timed out after 30 seconds.') + return False + + return True + + def _start_tailwind_watch(self): + """Start Tailwind CLI in watch mode as a background subprocess.""" + if not self.config.detect_tailwind(): + return None + + binary = self.config.tailwind_binary + input_file = self.config.tailwind_input + output_file = self.config.tailwind_output + + cmd = f'{binary} -i {input_file} -o {output_file} --watch' + + logging.info(f'[{self.config.env}] Starting Tailwind watcher: {input_file} -> {output_file}') + + try: + process = subprocess.Popen( + shlex.split(cmd), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + return process + except FileNotFoundError: + logging.error(f'[{self.config.env}] Tailwind CLI not found at "{binary}". ' + 'Tailwind watch disabled.') + return None + + def _stop_tailwind_watch(self, process): + """Stop the Tailwind watch subprocess.""" + if process and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + logging.info(f'[{self.config.env}] Tailwind watcher stopped.') + @parser_config(theme_id_required=False) def init(self, parser): if parser.name: @@ -196,11 +302,40 @@ def watch(self, parser): logging.info(f'[{self.config.env}] Current theme id {self.config.theme_id}') logging.info(f'[{self.config.env}] Preview theme URL {self.config.store}?preview_theme={self.config.theme_id}') logging.info(f'[{self.config.env}] Watching for file changes in {current_pathfile}') + + # Start Tailwind watcher if detected + tailwind_process = None + tailwind_output = None + if self.config.detect_tailwind(): + tailwind_output = os.path.abspath(self.config.tailwind_output) + # Do initial compilation before starting watch + self._compile_tailwind() + tailwind_process = self._start_tailwind_watch() + if tailwind_process: + logging.info(f'[{self.config.env}] Tailwind watcher running (PID {tailwind_process.pid})') + logging.info(f'[{self.config.env}] Press Ctrl + C to stop') async def main(): - async for changes in awatch('.'): - self._handle_files_change(changes) + try: + async for changes in awatch('.'): + # If Tailwind output file changed (from Tailwind watcher), + # run sass-compat before pushing + if tailwind_output: + tw_changes = [(t, p) for t, p in changes if os.path.abspath(p) == tailwind_output] + if tw_changes and self.config.tailwind_sass_compat: + logging.info(f'[{self.config.env}] Tailwind output changed, running sass-compat...') + try: + subprocess.run( + [sys.executable, self.config.tailwind_sass_compat, self.config.tailwind_output], + capture_output=True, text=True, timeout=30 + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as e: + logging.warning(f'[{self.config.env}] sass-compat failed: {e}') + + self._handle_files_change(changes) + finally: + self._stop_tailwind_watch(tailwind_process) asyncio.run(main()) @@ -208,3 +343,13 @@ async def main(): def compile_sass(self, parser): logging.info(f'[{self.config.env}] Sass output style {self.config.sass_output_style}.') self._compile_sass() + + @parser_config() + def compile_tailwind(self, parser): + minify = getattr(parser, 'minify', False) + if self._compile_tailwind(minify=minify): + # Push the compiled CSS to the store + output_file = self.config.tailwind_output + if output_file and os.path.exists(output_file): + logging.info(f'[{self.config.env}] Pushing {output_file} to store...') + self._push_templates([output_file]) diff --git a/ntk/conf.py b/ntk/conf.py index ae874f4..927ba9d 100644 --- a/ntk/conf.py +++ b/ntk/conf.py @@ -18,6 +18,14 @@ SASS_DESTINATION = 'assets' SASS_OUTPUT_STYLES = ['nested', 'expanded', 'compact', 'compressed'] +# Tailwind CSS defaults +TAILWIND_INPUT = 'css/input.css' +TAILWIND_OUTPUT = 'assets/main.css' +TAILWIND_BINARY_NAMES = ['./tailwindcss', 'tailwindcss', 'npx tailwindcss'] +TAILWIND_V4_MARKER = '@import "tailwindcss"' +TAILWIND_V3_CONFIG = 'tailwind.config.js' +TAILWIND_SASS_COMPAT = 'scripts/sass-compat.py' + GLOB_PATTERN = [ "assets/**/*.html", "assets/**/*.json", @@ -58,6 +66,12 @@ class Config(object): theme_id = None sass_output_style = None + # Tailwind config + tailwind_input = None + tailwind_output = None + tailwind_binary = None + tailwind_sass_compat = None + env = 'development' apikey_required = True @@ -118,9 +132,58 @@ def read_config(self, update=True): self.theme_id = configs[self.env].get('theme_id') if configs[self.env].get('sass'): self.sass_output_style = configs[self.env]['sass'].get('output_style') + if configs[self.env].get('tailwind') is not None: + tw = configs[self.env]['tailwind'] + self.tailwind_input = tw.get('input', TAILWIND_INPUT) + self.tailwind_output = tw.get('output', TAILWIND_OUTPUT) + self.tailwind_binary = tw.get('binary') + self.tailwind_sass_compat = tw.get('sass_compat') return configs + def detect_tailwind(self): + """Auto-detect Tailwind CSS setup if not explicitly configured.""" + if self.tailwind_input and self.tailwind_binary: + return True # Already configured + + # Check for Tailwind v4 (CSS-based config with @import "tailwindcss") + input_file = self.tailwind_input or TAILWIND_INPUT + if os.path.exists(input_file): + try: + with open(input_file, 'r', encoding='utf-8') as f: + content = f.read(1024) # Only need the first kilobyte + if TAILWIND_V4_MARKER in content: + self.tailwind_input = input_file + self.tailwind_output = self.tailwind_output or TAILWIND_OUTPUT + except (OSError, UnicodeDecodeError): + pass + + # Check for Tailwind v3 (JS config file) + if not self.tailwind_input and os.path.exists(TAILWIND_V3_CONFIG): + self.tailwind_input = TAILWIND_INPUT if os.path.exists(TAILWIND_INPUT) else None + self.tailwind_output = self.tailwind_output or TAILWIND_OUTPUT + + if not self.tailwind_input: + return False + + # Find binary if not configured + if not self.tailwind_binary: + import shutil + for binary in TAILWIND_BINARY_NAMES: + if binary.startswith('./'): + if os.path.isfile(binary) and os.access(binary, os.X_OK): + self.tailwind_binary = binary + break + elif shutil.which(binary): + self.tailwind_binary = binary + break + + # Detect sass-compat.py if not configured + if not self.tailwind_sass_compat and os.path.exists(TAILWIND_SASS_COMPAT): + self.tailwind_sass_compat = TAILWIND_SASS_COMPAT + + return self.tailwind_binary is not None + def write_config(self): configs = self.read_config(update=False) diff --git a/ntk/ntk_parser.py b/ntk/ntk_parser.py index 70e93fa..c75b0fd 100644 --- a/ntk/ntk_parser.py +++ b/ntk/ntk_parser.py @@ -38,6 +38,7 @@ def create_parser(self): push Push all theme files from your current direcotry to the store watch Watch for changes in your current directory and push updates to the store sass Process Sass files to CSS files in assets directory + tailwind Compile Tailwind CSS and push to store ''' + option_commands, usage=argparse.SUPPRESS, epilog='Use "ntk [command] --help" for more information about a command.', @@ -139,4 +140,20 @@ def create_parser(self): formatter_class=argparse.RawTextHelpFormatter) parser_watch.set_defaults(func=self.command.compile_sass) self._add_config_arguments(parser_watch) + + # create the parser for the "tailwind" command + parser_tailwind = subparsers.add_parser( + 'tailwind', + help='Compile Tailwind CSS and push to store', + usage=argparse.SUPPRESS, + description=''' +Usage: + ntk tailwind [options] +''' + option_commands, + formatter_class=argparse.RawTextHelpFormatter) + parser_tailwind.set_defaults(func=self.command.compile_tailwind) + parser_tailwind.add_argument( + '--minify', action='store_true', dest='minify', default=False, + help='Minify the Tailwind CSS output') + self._add_config_arguments(parser_tailwind) return parser diff --git a/tests/test_tailwind.py b/tests/test_tailwind.py new file mode 100644 index 0000000..a4f6eab --- /dev/null +++ b/tests/test_tailwind.py @@ -0,0 +1,377 @@ +import os +import unittest +from unittest.mock import call, MagicMock, mock_open, patch + +from ntk import conf +from ntk.conf import Config + + +class TestTailwindDetection(unittest.TestCase): + """Tests for Tailwind CSS auto-detection in Config.""" + + @patch("os.path.exists", autospec=True) + @patch("yaml.load", autospec=True) + def setUp(self, mock_load_yaml, mock_patch_exists): + mock_patch_exists.return_value = True + mock_load_yaml.return_value = { + 'development': { + 'apikey': 'abc123', + 'store': 'https://test.com', + 'theme_id': 1 + } + } + with patch('builtins.open', mock_open(read_data='yaml data')): + self.config = Config() + + def test_detect_tailwind_returns_false_when_no_input_file(self): + """Should return False when css/input.css doesn't exist.""" + with patch('os.path.exists', return_value=False): + result = self.config.detect_tailwind() + self.assertFalse(result) + + def test_detect_tailwind_v4_with_marker(self): + """Should detect Tailwind v4 when input.css contains @import 'tailwindcss'.""" + def exists_side_effect(path): + if path == conf.TAILWIND_INPUT: + return True + if path == './tailwindcss': + return True + return False + + with patch('os.path.exists', side_effect=exists_side_effect), \ + patch('os.path.isfile', return_value=True), \ + patch('os.access', return_value=True), \ + patch('builtins.open', mock_open(read_data='@import "tailwindcss";\n@theme {}')): + result = self.config.detect_tailwind() + + self.assertTrue(result) + self.assertEqual(self.config.tailwind_input, conf.TAILWIND_INPUT) + self.assertEqual(self.config.tailwind_output, conf.TAILWIND_OUTPUT) + self.assertEqual(self.config.tailwind_binary, './tailwindcss') + + def test_detect_tailwind_returns_false_without_binary(self): + """Should return False when input.css exists but no binary found.""" + def exists_side_effect(path): + if path == conf.TAILWIND_INPUT: + return True + return False + + with patch('os.path.exists', side_effect=exists_side_effect), \ + patch('os.path.isfile', return_value=False), \ + patch('shutil.which', return_value=None), \ + patch('builtins.open', mock_open(read_data='@import "tailwindcss";')): + result = self.config.detect_tailwind() + + self.assertFalse(result) + + def test_detect_tailwind_finds_system_binary(self): + """Should find tailwindcss on PATH via shutil.which.""" + def exists_side_effect(path): + if path == conf.TAILWIND_INPUT: + return True + if path.startswith('./'): + return False + return False + + with patch('os.path.exists', side_effect=exists_side_effect), \ + patch('os.path.isfile', return_value=False), \ + patch('shutil.which', side_effect=lambda b: '/usr/local/bin/tailwindcss' if b == 'tailwindcss' else None), \ + patch('builtins.open', mock_open(read_data='@import "tailwindcss";')): + result = self.config.detect_tailwind() + + self.assertTrue(result) + self.assertEqual(self.config.tailwind_binary, 'tailwindcss') + + def test_detect_tailwind_skips_when_already_configured(self): + """Should return True immediately when binary and input are already set.""" + self.config.tailwind_input = 'css/input.css' + self.config.tailwind_binary = './tailwindcss' + result = self.config.detect_tailwind() + self.assertTrue(result) + + def test_detect_sass_compat(self): + """Should detect scripts/sass-compat.py when present.""" + def exists_side_effect(path): + if path == conf.TAILWIND_INPUT: + return True + if path == './tailwindcss': + return True + if path == conf.TAILWIND_SASS_COMPAT: + return True + return False + + with patch('os.path.exists', side_effect=exists_side_effect), \ + patch('os.path.isfile', return_value=True), \ + patch('os.access', return_value=True), \ + patch('builtins.open', mock_open(read_data='@import "tailwindcss";')): + self.config.detect_tailwind() + + self.assertEqual(self.config.tailwind_sass_compat, conf.TAILWIND_SASS_COMPAT) + + def test_read_config_with_tailwind_section(self): + """Should read tailwind config from config.yml.""" + yaml_data = { + 'development': { + 'apikey': 'abc123', + 'store': 'https://test.com', + 'theme_id': 1, + 'tailwind': { + 'input': 'src/input.css', + 'output': 'dist/main.css', + 'binary': '/usr/local/bin/tailwindcss', + 'sass_compat': 'tools/compat.py' + } + } + } + with patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open(read_data='yaml')), \ + patch('yaml.load', return_value=yaml_data): + config = Config() + + self.assertEqual(config.tailwind_input, 'src/input.css') + self.assertEqual(config.tailwind_output, 'dist/main.css') + self.assertEqual(config.tailwind_binary, '/usr/local/bin/tailwindcss') + self.assertEqual(config.tailwind_sass_compat, 'tools/compat.py') + + def test_read_config_tailwind_defaults(self): + """Should use defaults when tailwind section has partial config.""" + yaml_data = { + 'development': { + 'apikey': 'abc123', + 'store': 'https://test.com', + 'theme_id': 1, + 'tailwind': {} + } + } + with patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open(read_data='yaml')), \ + patch('yaml.load', return_value=yaml_data): + config = Config() + + self.assertEqual(config.tailwind_input, conf.TAILWIND_INPUT) + self.assertEqual(config.tailwind_output, conf.TAILWIND_OUTPUT) + + +class TestTailwindCompile(unittest.TestCase): + """Tests for Tailwind CSS compilation in Command.""" + + @patch("os.path.exists", autospec=True) + @patch("yaml.load", autospec=True) + @patch('ntk.command.Gateway', autospec=True) + def setUp(self, mock_gateway, mock_load_yaml, mock_patch_exists): + mock_patch_exists.return_value = True + mock_load_yaml.return_value = { + 'development': { + 'apikey': 'abc123', + 'store': 'https://test.com', + 'theme_id': 1 + } + } + config = { + 'env': 'development', + 'apikey': 'abc123', + 'theme_id': 1, + 'store': 'https://test.com', + } + with patch('builtins.open', mock_open(read_data='yaml data')): + from ntk.command import Command + self.parser = MagicMock(**config) + self.command = Command() + self.mock_gateway = mock_gateway + + @patch('ntk.command.subprocess.run') + def test_compile_tailwind_success(self, mock_run): + """Should compile successfully and return True.""" + self.command.config.tailwind_input = 'css/input.css' + self.command.config.tailwind_output = 'assets/main.css' + self.command.config.tailwind_binary = './tailwindcss' + self.command.config.tailwind_sass_compat = None + + mock_run.return_value = MagicMock(returncode=0, stderr='', stdout='') + + with patch.object(self.command.config, 'detect_tailwind', return_value=True): + result = self.command._compile_tailwind() + + self.assertTrue(result) + mock_run.assert_called_once() + call_args = mock_run.call_args + self.assertIn('./tailwindcss', call_args[0][0]) + + @patch('ntk.command.subprocess.run') + def test_compile_tailwind_with_minify(self, mock_run): + """Should pass --minify flag when requested.""" + self.command.config.tailwind_input = 'css/input.css' + self.command.config.tailwind_output = 'assets/main.css' + self.command.config.tailwind_binary = './tailwindcss' + self.command.config.tailwind_sass_compat = None + + mock_run.return_value = MagicMock(returncode=0, stderr='', stdout='') + + with patch.object(self.command.config, 'detect_tailwind', return_value=True): + result = self.command._compile_tailwind(minify=True) + + self.assertTrue(result) + cmd_args = mock_run.call_args[0][0] + self.assertIn('--minify', cmd_args) + + @patch('ntk.command.subprocess.run') + def test_compile_tailwind_failure(self, mock_run): + """Should return False when compilation fails.""" + self.command.config.tailwind_input = 'css/input.css' + self.command.config.tailwind_output = 'assets/main.css' + self.command.config.tailwind_binary = './tailwindcss' + self.command.config.tailwind_sass_compat = None + + mock_run.return_value = MagicMock(returncode=1, stderr='Error: invalid CSS', stdout='') + + with patch.object(self.command.config, 'detect_tailwind', return_value=True): + result = self.command._compile_tailwind() + + self.assertFalse(result) + + def test_compile_tailwind_no_detection(self): + """Should return False when Tailwind is not detected.""" + with patch.object(self.command.config, 'detect_tailwind', return_value=False): + result = self.command._compile_tailwind() + + self.assertFalse(result) + + @patch('ntk.command.subprocess.run') + def test_compile_tailwind_binary_not_found(self, mock_run): + """Should return False and log error when binary is missing.""" + self.command.config.tailwind_input = 'css/input.css' + self.command.config.tailwind_output = 'assets/main.css' + self.command.config.tailwind_binary = './missing-tailwindcss' + self.command.config.tailwind_sass_compat = None + + mock_run.side_effect = FileNotFoundError('No such file') + + with patch.object(self.command.config, 'detect_tailwind', return_value=True): + result = self.command._compile_tailwind() + + self.assertFalse(result) + + @patch('ntk.command.subprocess.run') + def test_compile_tailwind_timeout(self, mock_run): + """Should return False when compilation times out.""" + import subprocess + self.command.config.tailwind_input = 'css/input.css' + self.command.config.tailwind_output = 'assets/main.css' + self.command.config.tailwind_binary = './tailwindcss' + self.command.config.tailwind_sass_compat = None + + mock_run.side_effect = subprocess.TimeoutExpired(cmd='./tailwindcss', timeout=60) + + with patch.object(self.command.config, 'detect_tailwind', return_value=True): + result = self.command._compile_tailwind() + + self.assertFalse(result) + + @patch('ntk.command.subprocess.run') + def test_compile_tailwind_runs_sass_compat(self, mock_run): + """Should run sass-compat.py after successful compilation.""" + self.command.config.tailwind_input = 'css/input.css' + self.command.config.tailwind_output = 'assets/main.css' + self.command.config.tailwind_binary = './tailwindcss' + self.command.config.tailwind_sass_compat = 'scripts/sass-compat.py' + + mock_run.return_value = MagicMock(returncode=0, stderr='', stdout='') + + with patch.object(self.command.config, 'detect_tailwind', return_value=True), \ + patch('os.path.exists', return_value=True): + result = self.command._compile_tailwind() + + self.assertTrue(result) + # Should be called twice: once for tailwind, once for sass-compat + self.assertEqual(mock_run.call_count, 2) + + @patch('ntk.command.subprocess.run') + def test_compile_tailwind_sass_compat_failure(self, mock_run): + """Should return False when sass-compat.py fails.""" + self.command.config.tailwind_input = 'css/input.css' + self.command.config.tailwind_output = 'assets/main.css' + self.command.config.tailwind_binary = './tailwindcss' + self.command.config.tailwind_sass_compat = 'scripts/sass-compat.py' + + # First call (tailwind) succeeds, second call (sass-compat) fails + mock_run.side_effect = [ + MagicMock(returncode=0, stderr='', stdout=''), + MagicMock(returncode=1, stderr='sass-compat error', stdout=''), + ] + + with patch.object(self.command.config, 'detect_tailwind', return_value=True), \ + patch('os.path.exists', return_value=True): + result = self.command._compile_tailwind() + + self.assertFalse(result) + + +class TestTailwindWatch(unittest.TestCase): + """Tests for Tailwind watch subprocess management.""" + + @patch("os.path.exists", autospec=True) + @patch("yaml.load", autospec=True) + @patch('ntk.command.Gateway', autospec=True) + def setUp(self, mock_gateway, mock_load_yaml, mock_patch_exists): + mock_patch_exists.return_value = True + mock_load_yaml.return_value = { + 'development': { + 'apikey': 'abc123', + 'store': 'https://test.com', + 'theme_id': 1 + } + } + with patch('builtins.open', mock_open(read_data='yaml data')): + from ntk.command import Command + self.command = Command() + + @patch('ntk.command.subprocess.Popen') + def test_start_tailwind_watch(self, mock_popen): + """Should start Tailwind CLI in watch mode.""" + self.command.config.tailwind_input = 'css/input.css' + self.command.config.tailwind_output = 'assets/main.css' + self.command.config.tailwind_binary = './tailwindcss' + + mock_process = MagicMock() + mock_popen.return_value = mock_process + + with patch.object(self.command.config, 'detect_tailwind', return_value=True): + result = self.command._start_tailwind_watch() + + self.assertEqual(result, mock_process) + call_args = mock_popen.call_args[0][0] + self.assertIn('--watch', call_args) + + def test_start_tailwind_watch_no_detection(self): + """Should return None when Tailwind is not detected.""" + with patch.object(self.command.config, 'detect_tailwind', return_value=False): + result = self.command._start_tailwind_watch() + + self.assertIsNone(result) + + def test_stop_tailwind_watch(self): + """Should terminate the Tailwind watch process.""" + mock_process = MagicMock() + mock_process.poll.return_value = None # Process still running + + self.command._stop_tailwind_watch(mock_process) + + mock_process.terminate.assert_called_once() + mock_process.wait.assert_called_once_with(timeout=5) + + def test_stop_tailwind_watch_already_stopped(self): + """Should not terminate if process already stopped.""" + mock_process = MagicMock() + mock_process.poll.return_value = 0 # Already stopped + + self.command._stop_tailwind_watch(mock_process) + + mock_process.terminate.assert_not_called() + + def test_stop_tailwind_watch_none(self): + """Should handle None process gracefully.""" + self.command._stop_tailwind_watch(None) # Should not raise + + +if __name__ == '__main__': + unittest.main()