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
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/
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"
}
}
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/CurlRequestTest.php b/tests/CurlRequestTest.php
new file mode 100644
index 0000000..180df2d
--- /dev/null
+++ b/tests/CurlRequestTest.php
@@ -0,0 +1,30 @@
+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);
+ }
+}
diff --git a/tests/ItemRenderTest.php b/tests/ItemRenderTest.php
new file mode 100644
index 0000000..b9813dd
--- /dev/null
+++ b/tests/ItemRenderTest.php
@@ -0,0 +1,125 @@
+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);
+ }
+}
diff --git a/tests/WorkflowConfigTest.php b/tests/WorkflowConfigTest.php
new file mode 100644
index 0000000..0a439da
--- /dev/null
+++ b/tests/WorkflowConfigTest.php
@@ -0,0 +1,43 @@
+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/WorkflowEnterpriseUrlTest.php b/tests/WorkflowEnterpriseUrlTest.php
new file mode 100644
index 0000000..cf37632
--- /dev/null
+++ b/tests/WorkflowEnterpriseUrlTest.php
@@ -0,0 +1,58 @@
+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')
+ );
+ }
+}
diff --git a/tests/WorkflowSchemaTest.php b/tests/WorkflowSchemaTest.php
new file mode 100644
index 0000000..ce274b5
--- /dev/null
+++ b/tests/WorkflowSchemaTest.php
@@ -0,0 +1,95 @@
+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');
+ }
+}
diff --git a/tests/WorkflowTestCase.php b/tests/WorkflowTestCase.php
new file mode 100644
index 0000000..5b3f76e
--- /dev/null
+++ b/tests/WorkflowTestCase.php
@@ -0,0 +1,29 @@
+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);
+ }
+}
diff --git a/tests/WorkflowTokenTest.php b/tests/WorkflowTokenTest.php
new file mode 100644
index 0000000..a594c9b
--- /dev/null
+++ b/tests/WorkflowTokenTest.php
@@ -0,0 +1,67 @@
+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());
+ }
+}
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);
+ }
+ }
+}