From 1ad0c5e63994f4ac2373da0fdc6c40e57190d928 Mon Sep 17 00:00:00 2001 From: David Okun Date: Sat, 11 Apr 2026 14:05:04 -0500 Subject: [PATCH 01/11] chore: ignore vendor, phpunit cache, composer.phar Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index afc0e2b..740022d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ /.php-cs-fixer.cache /composer.lock +/composer.phar /vendor/ +/.phpunit.cache/ +/tests/tmp/ + /yarn.lock /node_modules/ From 42e998e82d41429df5088cd8c1d2727b6bd0f636 Mon Sep 17 00:00:00 2001 From: David Okun Date: Sat, 11 Apr 2026 14:12:04 -0500 Subject: [PATCH 02/11] test: add phpunit 11 to dev dependencies Co-Authored-By: Claude Opus 4.6 (1M context) --- composer.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index bdb1425..98df486 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,9 @@ { "require-dev": { - "friendsofphp/php-cs-fixer": "^3.48" + "friendsofphp/php-cs-fixer": "^3.48", + "phpunit/phpunit": "^11.0" + }, + "scripts": { + "test": "phpunit" } } From fa75ed4b18b9439179e525a17e634ebc83406e83 Mon Sep 17 00:00:00 2001 From: David Okun Date: Sat, 11 Apr 2026 14:31:05 -0500 Subject: [PATCH 03/11] test: add config round-trip characterization tests --- tests/WorkflowConfigTest.php | 45 ++++++++++++++++++++++++++++++++++++ tests/WorkflowTestCase.php | 31 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/WorkflowConfigTest.php create mode 100644 tests/WorkflowTestCase.php diff --git a/tests/WorkflowConfigTest.php b/tests/WorkflowConfigTest.php new file mode 100644 index 0000000..5f59491 --- /dev/null +++ b/tests/WorkflowConfigTest.php @@ -0,0 +1,45 @@ +assertSame('hello', Workflow::getConfig('greeting')); + } + + public function testGetConfigReturnsDefaultWhenMissing(): void + { + Workflow::init(); + + $this->assertNull(Workflow::getConfig('missing')); + $this->assertSame('fallback', Workflow::getConfig('missing', 'fallback')); + } + + public function testSetConfigOverwritesExistingValue(): void + { + Workflow::init(); + + Workflow::setConfig('k', 'v1'); + Workflow::setConfig('k', 'v2'); + + $this->assertSame('v2', Workflow::getConfig('k')); + } + + public function testRemoveConfigDeletesKey(): void + { + Workflow::init(); + + Workflow::setConfig('k', 'v'); + Workflow::removeConfig('k'); + + $this->assertNull(Workflow::getConfig('k')); + } +} diff --git a/tests/WorkflowTestCase.php b/tests/WorkflowTestCase.php new file mode 100644 index 0000000..91dc1a6 --- /dev/null +++ b/tests/WorkflowTestCase.php @@ -0,0 +1,31 @@ +dataDir = agw_test_tmp_dir(); + putenv('alfred_workflow_data='.$this->dataDir); + agw_test_reset_workflow(); + } + + protected function tearDown(): void + { + agw_test_reset_workflow(); + putenv('alfred_workflow_data'); + agw_test_rmrf($this->dataDir); + } +} From afca3752c20c536a33c0e541e14e725bd9103621 Mon Sep 17 00:00:00 2001 From: David Okun Date: Sat, 11 Apr 2026 14:31:21 -0500 Subject: [PATCH 04/11] test: pin github vs enterprise token isolation --- tests/WorkflowTokenTest.php | 69 +++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/WorkflowTokenTest.php diff --git a/tests/WorkflowTokenTest.php b/tests/WorkflowTokenTest.php new file mode 100644 index 0000000..8f95604 --- /dev/null +++ b/tests/WorkflowTokenTest.php @@ -0,0 +1,69 @@ +assertSame('gh-token', Workflow::getAccessToken()); + $this->assertSame('gh-token', Workflow::getConfig('access_token')); + $this->assertNull(Workflow::getConfig('enterprise_access_token')); + } + + public function testEnterpriseTokenStoredUnderEnterpriseKey(): void + { + Workflow::init(true); + + Workflow::setAccessToken('ghe-token'); + + $this->assertSame('ghe-token', Workflow::getAccessToken()); + $this->assertSame('ghe-token', Workflow::getConfig('enterprise_access_token')); + $this->assertNull(Workflow::getConfig('access_token')); + } + + public function testGithubAndEnterpriseTokensDoNotCollide(): void + { + Workflow::init(); + Workflow::setAccessToken('gh-token'); + + agw_test_reset_workflow(); + Workflow::init(true); + Workflow::setAccessToken('ghe-token'); + + $this->assertSame('ghe-token', Workflow::getAccessToken()); + + agw_test_reset_workflow(); + Workflow::init(); + $this->assertSame('gh-token', Workflow::getAccessToken()); + } + + public function testRemoveAccessTokenOnlyAffectsActiveSlot(): void + { + Workflow::init(); + Workflow::setAccessToken('gh-token'); + + agw_test_reset_workflow(); + Workflow::init(true); + Workflow::setAccessToken('ghe-token'); + Workflow::removeAccessToken(); + + $this->assertNull(Workflow::getAccessToken()); + + agw_test_reset_workflow(); + Workflow::init(); + $this->assertSame('gh-token', Workflow::getAccessToken()); + } +} From 5a240bfda168d5676bb27eec8a5a3543f38f36c4 Mon Sep 17 00:00:00 2001 From: David Okun Date: Sat, 11 Apr 2026 14:31:41 -0500 Subject: [PATCH 05/11] test: pin sqlite schema created by Workflow::init --- tests/WorkflowSchemaTest.php | 97 ++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/WorkflowSchemaTest.php diff --git a/tests/WorkflowSchemaTest.php b/tests/WorkflowSchemaTest.php new file mode 100644 index 0000000..aa31efe --- /dev/null +++ b/tests/WorkflowSchemaTest.php @@ -0,0 +1,97 @@ +tableColumns('config'); + + $this->assertSame(['key', 'value'], array_column($columns, 'name')); + $this->assertSame(1, $this->columnByName($columns, 'key')['pk']); + $this->assertSame(1, $this->columnByName($columns, 'key')['notnull']); + } + + public function testInitCreatesRequestCacheTableWithExpectedColumns(): void + { + Workflow::init(); + + $columns = $this->tableColumns('request_cache'); + + $this->assertSame( + ['url', 'timestamp', 'etag', 'content', 'refresh', 'parent'], + array_column($columns, 'name') + ); + $this->assertSame(1, $this->columnByName($columns, 'url')['pk']); + } + + public function testInitCreatesParentUrlIndex(): void + { + Workflow::init(); + + $pdo = $this->db(); + $row = $pdo->query("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'parent_url'")->fetch(PDO::FETCH_ASSOC); + + $this->assertIsArray($row); + $this->assertStringContainsString('request_cache', $row['sql']); + $this->assertStringContainsString('parent', $row['sql']); + } + + public function testInitIsIdempotentAcrossReopen(): void + { + Workflow::init(); + Workflow::setConfig('persisted', 'yes'); + + agw_test_reset_workflow(); + Workflow::init(); + + $this->assertSame('yes', Workflow::getConfig('persisted')); + + $columns = $this->tableColumns('config'); + $this->assertSame(['key', 'value'], array_column($columns, 'name')); + } + + private function db(): PDO + { + return new PDO('sqlite:'.$this->dataDir.'/db.sqlite'); + } + + /** @return array */ + private function tableColumns(string $table): array + { + $stmt = $this->db()->query('PRAGMA table_info('.$table.')'); + $columns = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $columns[] = [ + 'name' => $row['name'], + 'pk' => (int) $row['pk'], + 'notnull' => (int) $row['notnull'], + ]; + } + + return $columns; + } + + /** @param array $columns */ + private function columnByName(array $columns, string $name): array + { + foreach ($columns as $column) { + if ($column['name'] === $name) { + return $column; + } + } + + $this->fail('Column '.$name.' not found'); + } +} From 857044f509ad27a5163cf3981928a07866427134 Mon Sep 17 00:00:00 2001 From: David Okun Date: Sat, 11 Apr 2026 14:31:57 -0500 Subject: [PATCH 06/11] test: pin enterprise URL derivation and API path building --- tests/WorkflowEnterpriseUrlTest.php | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/WorkflowEnterpriseUrlTest.php diff --git a/tests/WorkflowEnterpriseUrlTest.php b/tests/WorkflowEnterpriseUrlTest.php new file mode 100644 index 0000000..ae3d784 --- /dev/null +++ b/tests/WorkflowEnterpriseUrlTest.php @@ -0,0 +1,60 @@ +assertSame('https://github.com', Workflow::getBaseUrl()); + $this->assertSame('https://api.github.com', Workflow::getApiUrl()); + $this->assertSame('https://gist.github.com', Workflow::getGistUrl()); + } + + public function testEnterpriseUrlsDerivedFromConfig(): void + { + Workflow::init(); + Workflow::setConfig('enterprise_url', 'https://ghe.example.com'); + + agw_test_reset_workflow(); + Workflow::init(true); + + $this->assertSame('https://ghe.example.com', Workflow::getBaseUrl()); + $this->assertSame('https://ghe.example.com/api/v3', Workflow::getApiUrl()); + $this->assertSame('https://ghe.example.com/gist', Workflow::getGistUrl()); + } + + public function testEnterpriseUrlsAreNullWhenConfigMissing(): void + { + Workflow::init(true); + + $this->assertNull(Workflow::getBaseUrl()); + $this->assertNull(Workflow::getApiUrl()); + $this->assertNull(Workflow::getGistUrl()); + } + + public function testGetApiUrlAppendsPathAndPerPage(): void + { + Workflow::init(); + + $this->assertSame( + 'https://api.github.com/user/repos?per_page=100', + Workflow::getApiUrl('/user/repos') + ); + $this->assertSame( + 'https://api.github.com/search/repositories?q=foo&per_page=100', + Workflow::getApiUrl('/search/repositories?q=foo') + ); + } +} From 08bb6c9b598fdc391b4971640c84e8a935f29606 Mon Sep 17 00:00:00 2001 From: David Okun Date: Sat, 11 Apr 2026 14:32:10 -0500 Subject: [PATCH 07/11] test: pin CurlRequest per-request token carrier --- tests/CurlRequestTest.php | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/CurlRequestTest.php diff --git a/tests/CurlRequestTest.php b/tests/CurlRequestTest.php new file mode 100644 index 0000000..c6ae3fa --- /dev/null +++ b/tests/CurlRequestTest.php @@ -0,0 +1,32 @@ +assertSame('https://api.github.com/user', $request->url); + $this->assertSame('etag-1', $request->etag); + $this->assertSame('tok', $request->token); + $this->assertSame($callback, $request->callback); + } + + public function testTokenMayBeNullForUnauthenticatedRequests(): void + { + $request = new CurlRequest('https://api.github.com/', null, null, static function () {}); + + $this->assertNull($request->token); + $this->assertNull($request->etag); + } +} From df76e5f73fe4c08ff22f4e12ec52d172067a211a Mon Sep 17 00:00:00 2001 From: David Okun Date: Sat, 11 Apr 2026 14:32:59 -0500 Subject: [PATCH 08/11] test: pin Item::toXml rendering, escaping, and prefix rules --- tests/ItemRenderTest.php | 127 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/ItemRenderTest.php diff --git a/tests/ItemRenderTest.php b/tests/ItemRenderTest.php new file mode 100644 index 0000000..697d7d3 --- /dev/null +++ b/tests/ItemRenderTest.php @@ -0,0 +1,127 @@ +title('repo'); + $xml = Item::toXml([$item], false, false, 'https://github.com'); + + $expectedUid = md5('repo'); + $expected = ''."\n". + 'icon.pngrepo'."\n"; + + $this->assertSame($expected, $xml); + } + + public function testAbsolutePathArgIsPrefixedWithBaseUrl(): void + { + $item = Item::create()->title('repo')->arg('/owner/repo'); + $xml = Item::toXml([$item], false, false, 'https://github.com'); + + $this->assertStringContainsString('arg="https://github.com/owner/repo"', $xml); + } + + public function testEnterpriseFlagPrefixesNonUrlArg(): void + { + $item = Item::create()->title('search')->arg('foo bar'); + $xml = Item::toXml([$item], true, false, 'https://ghe.example.com'); + + $this->assertStringContainsString('arg="e foo bar"', $xml); + } + + public function testNonEnterpriseDoesNotPrefixArg(): void + { + $item = Item::create()->title('search')->arg('foo bar'); + $xml = Item::toXml([$item], false, false, 'https://github.com'); + + $this->assertStringContainsString('arg="foo bar"', $xml); + } + + public function testAbsoluteUrlArgIsUsedVerbatim(): void + { + $item = Item::create()->title('site')->arg('https://example.com/thing'); + $xml = Item::toXml([$item], false, false, 'https://github.com'); + + $this->assertStringContainsString('arg="https://example.com/thing"', $xml); + } + + public function testInvalidItemAppendsEllipsisAndValidNo(): void + { + $item = Item::create()->title('partial')->valid(false); + $xml = Item::toXml([$item], false, false, 'https://github.com'); + + $this->assertStringContainsString('valid="no"', $xml); + $this->assertStringContainsString('partial…', $xml); + } + + public function testInvalidItemUsesCustomSuffix(): void + { + $item = Item::create()->title('type')->valid(false, ' more'); + $xml = Item::toXml([$item], false, false, 'https://github.com'); + + $this->assertStringContainsString('type more', $xml); + } + + public function testSubtitleAndTitleAreHtmlEscaped(): void + { + $item = Item::create()->title('bold')->subtitle('a & b'); + $xml = Item::toXml([$item], false, false, 'https://github.com'); + + $this->assertStringContainsString('<b>bold</b>', $xml); + $this->assertStringContainsString('a & b', $xml); + } + + public function testHotkeyFlagStripsLeadingAutocompleteSpace(): void + { + $item = Item::create()->title('repo'); + $xml = Item::toXml([$item], false, true, 'https://github.com'); + + $this->assertStringContainsString('autocomplete="repo"', $xml); + } + + public function testPrefixIsIncludedInDisplayTitleButNotAutocompleteByDefault(): void + { + $item = Item::create()->prefix('gh ')->title('repo'); + $xml = Item::toXml([$item], false, false, 'https://github.com'); + + $this->assertStringContainsString('gh repo', $xml); + $this->assertStringContainsString('autocomplete=" repo"', $xml); + } + + public function testPrefixIncludedInAutocompleteWhenNotOnlyTitle(): void + { + $item = Item::create()->prefix('gh ', false)->title('repo'); + $xml = Item::toXml([$item], false, false, 'https://github.com'); + + $this->assertStringContainsString('autocomplete=" gh repo"', $xml); + } + + public function testMultipleItemsAreEmittedInOrder(): void + { + $items = [ + Item::create()->title('first'), + Item::create()->title('second'), + Item::create()->title('third'), + ]; + $xml = Item::toXml($items, false, false, 'https://github.com'); + + $firstPos = strpos($xml, 'first'); + $secondPos = strpos($xml, 'second'); + $thirdPos = strpos($xml, 'third'); + + $this->assertNotFalse($firstPos); + $this->assertLessThan($secondPos, $firstPos); + $this->assertLessThan($thirdPos, $secondPos); + } +} From e89aca5d55319c4a2df721bd847bc2392e8d59c3 Mon Sep 17 00:00:00 2001 From: David Okun Date: Sat, 11 Apr 2026 14:33:11 -0500 Subject: [PATCH 09/11] ci: run phpunit on php 8.2-8.4 matrix --- .github/workflows/test.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..196277e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: tests + +on: + push: + branches: + - main + pull_request: + +jobs: + phpunit: + name: PHPUnit (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo, pdo_sqlite, sqlite3, curl, simplexml + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Run PHPUnit + run: vendor/bin/phpunit From 25d6859f40a7aa8e3e3205829bbda5559eef8e96 Mon Sep 17 00:00:00 2001 From: David Okun Date: Sat, 11 Apr 2026 14:42:43 -0500 Subject: [PATCH 10/11] chore: add phpunit config and test bootstrap --- phpunit.xml.dist | 15 ++++++++++ tests/bootstrap.php | 72 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 phpunit.xml.dist create mode 100644 tests/bootstrap.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..632c115 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,15 @@ + + + + + tests + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..2ffd930 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,72 @@ + null, + 'fileDb' => null, + 'db' => null, + 'statements' => [], + 'enterprise' => null, + 'baseUrl' => 'https://github.com', + 'apiUrl' => 'https://api.github.com', + 'gistUrl' => 'https://gist.github.com', + 'query' => null, + 'hotkey' => null, + 'items' => [], + 'refreshUrls' => [], + 'debug' => false, + ]; + foreach ($resets as $name => $value) { + if ($ref->hasProperty($name)) { + $prop = $ref->getProperty($name); + $prop->setValue(null, $value); + } + } +} From cafb0df6d193b849ccaa9e3b9e022bd67769027a Mon Sep 17 00:00:00 2001 From: David Okun Date: Sat, 11 Apr 2026 14:42:44 -0500 Subject: [PATCH 11/11] style: apply php-cs-fixer to tests --- tests/CurlRequestTest.php | 2 -- tests/ItemRenderTest.php | 2 -- tests/WorkflowConfigTest.php | 2 -- tests/WorkflowEnterpriseUrlTest.php | 2 -- tests/WorkflowSchemaTest.php | 2 -- tests/WorkflowTestCase.php | 2 -- tests/WorkflowTokenTest.php | 2 -- 7 files changed, 14 deletions(-) diff --git a/tests/CurlRequestTest.php b/tests/CurlRequestTest.php index c6ae3fa..180df2d 100644 --- a/tests/CurlRequestTest.php +++ b/tests/CurlRequestTest.php @@ -1,7 +1,5 @@