Skip to content

Commit 3a3079d

Browse files
committed
test(exapp_integration): add Playwright file action menu regression for #848
Signed-off-by: Oleksander Piskun <oleksandr2088@icloud.com>
1 parent 912fcae commit 3a3079d

5 files changed

Lines changed: 330 additions & 4 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: MIT
3+
name: Tests - ExApp Integration
4+
5+
on:
6+
pull_request:
7+
branches: [main]
8+
push:
9+
branches: [main]
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: read
14+
15+
concurrency:
16+
group: tests-exapp-integration-${{ github.head_ref || github.run_id }}
17+
cancel-in-progress: true
18+
19+
env:
20+
NEXTCLOUD_URL: "http://127.0.0.1:8080"
21+
NEXTCLOUD_USER: "admin"
22+
NEXTCLOUD_PASS: "admin"
23+
APP_ID: "test_appapi"
24+
APP_VERSION: "1.0.0"
25+
APP_PORT: 9009
26+
APP_HOST: "127.0.0.1"
27+
APP_SECRET: "tC6vkwPhcppjMykD1r0n9NlI95uJMBYjs5blpIcA1PAdoPDmc5qoAjaBAkyocZ6E"
28+
29+
jobs:
30+
exapp-integration:
31+
runs-on: ubuntu-22.04
32+
name: ExApp integration (Playwright)
33+
34+
services:
35+
postgres:
36+
image: ghcr.io/nextcloud/continuous-integration-postgres-14:latest # zizmor: ignore[unpinned-images]
37+
ports:
38+
- 4444:5432/tcp
39+
env:
40+
POSTGRES_USER: root
41+
POSTGRES_PASSWORD: rootpassword
42+
POSTGRES_DB: nextcloud
43+
options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5
44+
45+
steps:
46+
- name: Set app env
47+
run: echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV
48+
49+
- name: Checkout server
50+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
51+
with:
52+
persist-credentials: false
53+
submodules: true
54+
repository: nextcloud/server
55+
ref: master
56+
57+
- name: Checkout AppAPI
58+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
59+
with:
60+
persist-credentials: false
61+
path: apps/${{ env.APP_NAME }}
62+
63+
- name: Set up php
64+
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2
65+
with:
66+
php-version: '8.3'
67+
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, pgsql, pdo_pgsql
68+
coverage: none
69+
ini-file: development
70+
env:
71+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
72+
73+
- name: Set up Python
74+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
75+
with:
76+
python-version: '3.11'
77+
78+
- name: Read package.json node and npm engines version
79+
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
80+
id: versions
81+
with:
82+
path: apps/${{ env.APP_NAME }}
83+
fallbackNode: '^24'
84+
fallbackNpm: '^11.3'
85+
86+
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
87+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
88+
with:
89+
node-version: ${{ steps.versions.outputs.nodeVersion }}
90+
91+
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
92+
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
93+
94+
- name: Install AppAPI composer deps
95+
working-directory: apps/${{ env.APP_NAME }}
96+
run: composer i
97+
98+
- name: Build AppAPI frontend bundle
99+
working-directory: apps/${{ env.APP_NAME }}
100+
run: |
101+
npm ci
102+
npm run build
103+
104+
- name: Set up Nextcloud
105+
env:
106+
DB_PORT: 4444
107+
run: |
108+
mkdir data
109+
./occ maintenance:install --verbose --database=pgsql --database-name=nextcloud --database-host=127.0.0.1 \
110+
--database-port=$DB_PORT --database-user=root --database-pass=rootpassword \
111+
--admin-user "$NEXTCLOUD_USER" --admin-pass "$NEXTCLOUD_PASS"
112+
./occ config:system:set ratelimit.protection.enabled --value=false --type=boolean
113+
./occ app:enable --force ${{ env.APP_NAME }}
114+
115+
- name: Run Nextcloud
116+
run: PHP_CLI_SERVER_WORKERS=4 php -S 127.0.0.1:8080 &
117+
118+
- name: Install integration test Python deps
119+
run: python3 -m pip install -r apps/${{ env.APP_NAME }}/tests/exapp_integration/requirements.txt
120+
121+
- name: Install Playwright browser
122+
run: python3 -m playwright install --with-deps chromium
123+
124+
- name: Register manual_daemon
125+
run: php occ app_api:daemon:register manual_daemon "Manual Install" manual-install http "$APP_HOST" "$NEXTCLOUD_URL"
126+
127+
- name: Start test ExApp
128+
run: |
129+
cd apps/${{ env.APP_NAME }}/tests/exapp_integration
130+
nohup python3 _test_app.py > /tmp/test_appapi.log 2>&1 &
131+
echo $! > /tmp/test_appapi.pid
132+
for _ in $(seq 1 30); do
133+
curl -fs "http://$APP_HOST:$APP_PORT/heartbeat" >/dev/null && break
134+
sleep 1
135+
done
136+
curl -fs "http://$APP_HOST:$APP_PORT/heartbeat"
137+
138+
- name: Register test ExApp
139+
# No --wait-finish: the minimal _test_app.py /init handler returns 200
140+
# without calling back to mark init progress = 100, so --wait-finish
141+
# would loop forever. register_test_exapp.sh in the dev VM omits it for
142+
# the same reason. Register itself flips enabled=1 in DB synchronously.
143+
run: |
144+
php occ app_api:app:register "$APP_ID" manual_daemon --json-info \
145+
"{\"id\":\"$APP_ID\",\"name\":\"AppAPI Integration Test ExApp\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"port\":$APP_PORT,\"host\":\"$APP_HOST\",\"protocol\":\"http\",\"system_app\":0}"
146+
php occ app_api:app:list | grep -q "^$APP_ID .* \[enabled\]$"
147+
148+
- name: Run pytest
149+
working-directory: apps/${{ env.APP_NAME }}/tests/exapp_integration
150+
env:
151+
OCC_CMD: "php ${{ github.workspace }}/occ"
152+
run: python3 -m pytest -v
153+
154+
- name: Stop test ExApp
155+
if: always()
156+
run: |
157+
[ -f /tmp/test_appapi.pid ] && kill -15 "$(cat /tmp/test_appapi.pid)" || true
158+
159+
- name: Upload NC logs
160+
if: always()
161+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
162+
with:
163+
name: exapp-integration-nextcloud.log
164+
path: data/nextcloud.log
165+
if-no-files-found: warn
166+
167+
- name: Upload test ExApp log
168+
if: always()
169+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
170+
with:
171+
name: exapp-integration-test_appapi.log
172+
path: /tmp/test_appapi.log
173+
if-no-files-found: warn
174+
175+
tests-exapp-integration-success:
176+
permissions:
177+
contents: none
178+
runs-on: ubuntu-22.04
179+
needs: [exapp-integration]
180+
name: TestsExAppIntegration-OK
181+
steps:
182+
- run: echo "ExApp integration tests passed"

tests/exapp_integration/conftest.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,19 @@
2121
from ._client import AppAPIClient
2222

2323
NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "http://nextcloud.appapi")
24+
NEXTCLOUD_USER = os.environ.get("NEXTCLOUD_USER", "admin")
25+
NEXTCLOUD_PASS = os.environ.get("NEXTCLOUD_PASS", "admin")
2426
APP_ID = os.environ.get("APP_ID", "test_appapi")
2527
APP_VERSION = os.environ.get("APP_VERSION", "1.0.0")
2628
APP_SECRET = os.environ["APP_SECRET"]
2729
TEST_APP_URL = os.environ.get("TEST_APP_URL", "http://127.0.0.1:9009")
2830

29-
OCC = ["docker", "exec", "appapi-nextcloud-1",
30-
"sudo", "-u", "www-data", "php", "occ"]
31+
# OCC invocation. Override with `OCC_CMD` (space-separated) when running
32+
# outside the dev VM (e.g. in CI where Nextcloud is on the host via php -S).
33+
OCC = os.environ.get(
34+
"OCC_CMD",
35+
"docker exec appapi-nextcloud-1 sudo -u www-data php occ",
36+
).split()
3137

3238

3339
@pytest.fixture(scope="session")
@@ -58,6 +64,16 @@ def test_app_url() -> str:
5864
return TEST_APP_URL
5965

6066

67+
@pytest.fixture(scope="session")
68+
def nextcloud_url() -> str:
69+
return NEXTCLOUD_URL
70+
71+
72+
@pytest.fixture(scope="session")
73+
def admin_credentials() -> tuple[str, str]:
74+
return NEXTCLOUD_USER, NEXTCLOUD_PASS
75+
76+
6177
@pytest.fixture(scope="session", autouse=True)
6278
def _ensure_test_app_registered() -> None:
6379
r = subprocess.run(OCC + ["app_api:app:list"], capture_output=True, text=True, check=True)
@@ -67,3 +83,19 @@ def _ensure_test_app_registered() -> None:
6783
)
6884
if "[enabled]" not in next((line for line in r.stdout.splitlines() if APP_ID in line), ""):
6985
raise RuntimeError(f"ExApp '{APP_ID}' is registered but not enabled.")
86+
87+
88+
@pytest.fixture
89+
def logged_in_page(page, nextcloud_url, admin_credentials):
90+
"""Yield a Playwright Page authenticated as admin.
91+
92+
Uses the standard NC login form (no app password / no token plumbing) so
93+
the test exercises the same client-side bootstrap path real users hit.
94+
"""
95+
user, password = admin_credentials
96+
page.goto(f"{nextcloud_url}/index.php/login")
97+
page.get_by_label("Account name or email").fill(user)
98+
page.get_by_label("Password", exact=True).fill(password)
99+
page.get_by_role("button", name="Log in", exact=True).click()
100+
page.wait_for_url(lambda url: "/login" not in url, timeout=15_000)
101+
return page
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
fastapi
4+
pytest
5+
pytest-playwright
6+
requests
7+
uvicorn
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
"""Browser test for ExApp-registered FileActionMenu entries.
4+
5+
Regression for https://github.com/nextcloud/app_api/issues/848: an ExApp's
6+
FileActionMenu V2 entry is registered server-side (DB row exists, OCS GET
7+
returns it) but does not appear in the Files app dropdown because AppAPI's
8+
bundled @nextcloud/files version stored actions in a registry the NC server's
9+
Files app no longer reads.
10+
11+
The test registers a file action via OCS, uploads a matching test file, and
12+
drives a real browser through the Files UI to assert the entry is visible.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import requests
18+
from playwright.sync_api import Page, expect
19+
20+
from ._client import AppAPIClient
21+
22+
ACTION_NAME = "test_appapi_fileaction"
23+
ACTION_DISPLAY = "AppAPI Integration FileAction"
24+
TEST_FILE_NAME = "test_appapi_fileaction.txt"
25+
26+
27+
def _ocs_register_file_action(client: AppAPIClient) -> None:
28+
body = {
29+
"name": ACTION_NAME,
30+
"displayName": ACTION_DISPLAY,
31+
"actionHandler": "/file-action",
32+
"icon": "",
33+
# mime is matched by substring against node mime; "text" hits text/plain.
34+
"mime": "text",
35+
"permissions": 31,
36+
"order": 0,
37+
}
38+
r = client.request(
39+
"POST",
40+
"/ocs/v1.php/apps/app_api/api/v2/ui/files-actions-menu",
41+
json=body,
42+
)
43+
assert r.status_code == 200, f"register OCS call failed: {r.status_code} {r.text}"
44+
45+
46+
def _ocs_unregister_file_action(client: AppAPIClient) -> None:
47+
client.request(
48+
"DELETE",
49+
"/ocs/v1.php/apps/app_api/api/v1/ui/files-actions-menu",
50+
json={"name": ACTION_NAME},
51+
)
52+
53+
54+
def _webdav_put(nextcloud_url: str, user: str, password: str, name: str, content: bytes) -> None:
55+
r = requests.put(
56+
f"{nextcloud_url}/remote.php/dav/files/{user}/{name}",
57+
auth=(user, password),
58+
data=content,
59+
headers={"OCS-APIRequest": "true"},
60+
timeout=30,
61+
)
62+
assert r.status_code in (201, 204), f"WebDAV PUT failed: {r.status_code} {r.text}"
63+
64+
65+
def _webdav_delete(nextcloud_url: str, user: str, password: str, name: str) -> None:
66+
requests.delete(
67+
f"{nextcloud_url}/remote.php/dav/files/{user}/{name}",
68+
auth=(user, password),
69+
headers={"OCS-APIRequest": "true"},
70+
timeout=30,
71+
)
72+
73+
74+
def test_file_action_visible_in_files_menu(
75+
client: AppAPIClient,
76+
logged_in_page: Page,
77+
nextcloud_url: str,
78+
admin_credentials: tuple[str, str],
79+
) -> None:
80+
user, password = admin_credentials
81+
82+
_ocs_register_file_action(client)
83+
_webdav_put(nextcloud_url, user, password, TEST_FILE_NAME, b"hello 848")
84+
try:
85+
page = logged_in_page
86+
page.goto(f"{nextcloud_url}/index.php/apps/files/files")
87+
88+
# The Files app renders rows lazily (virtualised list). Wait for the
89+
# specific row keyed by the data-cy attribute to land in the DOM
90+
# before clicking on its Actions button.
91+
file_row = page.locator(
92+
f'[data-cy-files-list-row-name="{TEST_FILE_NAME}"]'
93+
).first
94+
file_row.wait_for(state="visible", timeout=15_000)
95+
file_row.get_by_role("button", name="Actions").click()
96+
97+
expect(
98+
page.get_by_role("menuitem", name=ACTION_DISPLAY)
99+
).to_be_visible(timeout=5_000)
100+
finally:
101+
_webdav_delete(nextcloud_url, user, password, TEST_FILE_NAME)
102+
_ocs_unregister_file_action(client)

tests/exapp_integration/test_lifecycle.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
callback (otherwise the OCC enable command itself would fail).
99
"""
1010

11+
import os
1112
import subprocess
1213

13-
OCC = ["docker", "exec", "appapi-nextcloud-1",
14-
"sudo", "-u", "www-data", "php", "occ"]
14+
OCC = os.environ.get(
15+
"OCC_CMD",
16+
"docker exec appapi-nextcloud-1 sudo -u www-data php occ",
17+
).split()
1518

1619

1720
def _is_enabled(app_id: str) -> bool:

0 commit comments

Comments
 (0)