Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions charts/opnc-integration/scripts/openproject/migrate
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/bash -e
# Override of /app/docker/prod/migrate.
# Helm starts seeder + web/worker together; wait-for-db's rails runner can create
# ar_internal_metadata before schema:load, so structure.sql fails with
# "relation already exists". Wipe public schema when uninitialized, then load.

DB_URL=$(echo "$DATABASE_URL" | cut -d? -f1)

SCHEMA_LOADED=$(psql -qtAX -v ON_ERROR_STOP=1 "$DB_URL" -c \
"SELECT to_regclass('schema_migrations') IS NOT NULL")
MIG_COUNT=0
if [[ "$SCHEMA_LOADED" = "t" ]]; then
MIG_COUNT=$(psql -qtAX -v ON_ERROR_STOP=1 "$DB_URL" -c \
"SELECT COUNT(*)::text FROM schema_migrations")
fi

if [[ "$SCHEMA_LOADED" = "f" ]] || [[ "${MIG_COUNT}" -eq 0 ]]; then
echo "Initializing database"
psql -v ON_ERROR_STOP=1 "$DB_URL" -c \
"DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO CURRENT_USER; GRANT ALL ON SCHEMA public TO public;"
DISABLE_DATABASE_ENVIRONMENT_CHECK=1 bundle exec rake db:schema:load
else
echo "Executing database migrations..."
bundle exec rake db:migrate
fi
11 changes: 9 additions & 2 deletions charts/opnc-integration/scripts/openproject/setup-op-source.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ if [[ "$OP_USE_LOCAL_SOURCE" == "true" ]]; then
fi

PKG_TO_INSTALL=""
BUILD_DEPS="gcc make openssl"
# openproject:*-slim has no git; clone path needs it. gcc/make come via build-essential.
BUILD_DEPS="gcc make openssl git"
for pkg in $BUILD_DEPS; do
if ! which "$pkg" >/dev/null 2>&1; then
PKG_TO_INSTALL="$PKG_TO_INSTALL $pkg"
Expand All @@ -35,6 +36,7 @@ if [[ -n "$PKG_TO_INSTALL" ]]; then
echo "[INFO] Installing build dependencies: $PKG_TO_INSTALL"
apt update >/dev/null
apt install -y --no-install-recommends \
git \
ruby-dev \
build-essential \
libyaml-dev \
Expand All @@ -47,10 +49,15 @@ echo "[INFO] Building OpenProject from source..."
mkdir -p "$APP_PATH" && cd "$APP_PATH"

if [[ -n "$OP_GIT_SOURCE_BRANCH" ]] && [[ "$OP_USE_LOCAL_SOURCE" != "true" ]] && [[ -z $(find "$APP_PATH" -mindepth 1 -print -quit) ]]; then
if ! command -v git >/dev/null 2>&1; then
echo "[ERROR] git is required to clone OpenProject source but is not installed."
exit 1
fi
echo "[INFO] Verifying branch '${OP_GIT_SOURCE_BRANCH}' exists in opf/openproject..."
OP_REPO_URL="https://github.com/opf/openproject"
if ! git ls-remote --exit-code --heads "${OP_REPO_URL}" "refs/heads/${OP_GIT_SOURCE_BRANCH}" >/dev/null 2>&1; then
near_misses=$(git ls-remote --heads "${OP_REPO_URL}" "*${OP_GIT_SOURCE_BRANCH}*" 2>/dev/null | awk '{print $2}' | sed 's|refs/heads/||' | head -5 | tr '\n' ' ')
# || true: under set -e / pipefail a missing branch must still print [ERROR], not abort silently.
near_misses=$(git ls-remote --heads "${OP_REPO_URL}" "*${OP_GIT_SOURCE_BRANCH}*" 2>/dev/null | awk '{print $2}' | sed 's|refs/heads/||' | head -5 | tr '\n' ' ') || true
if [[ -n "${near_misses}" ]]; then
echo "[ERROR] Branch '${OP_GIT_SOURCE_BRANCH}' not found in opf/openproject. Did you mean: ${near_misses}?"
else
Expand Down
42 changes: 42 additions & 0 deletions charts/opnc-integration/scripts/openproject/wait-for-db
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/bin/bash
# Override of /app/docker/prod/wait-for-db.
# Upstream boots Rails (rails runner), which creates ar_internal_metadata and
# races the seeder's db:schema:load. Poll with psql only until seed has run.
set -euo pipefail

DB_URL=$(echo "${DATABASE_URL}" | cut -d? -f1)
timeout="${WAIT_FOR_DB_TIMEOUT_SECONDS:-900}"
deadline=$((SECONDS + timeout))
prev=-1
stable=0

echo "waiting for database schema via psql (timeout=${timeout}s)"
while (( SECONDS < deadline )); do
count="$(psql -qtAX "$DB_URL" -c "SELECT COUNT(*)::text FROM schema_migrations" 2>/dev/null || true)"
count="${count:-0}"
if [[ "$count" =~ ^[0-9]+$ ]] && (( count > 0 )); then
if (( count == prev )); then
stable=$((stable + 1))
else
stable=0
fi
prev=$count
if (( stable >= 2 )); then
if psql -qtAX -v ON_ERROR_STOP=1 "$DB_URL" -c "SELECT 1 FROM settings LIMIT 1" >/dev/null 2>&1; then
echo "db ready (${count} migrations, settings present)"
exit 0
fi
echo "migrations stable at ${count}; waiting for seed (settings)"
else
echo "migrations present: ${count} (waiting for stability)"
fi
else
prev=-1
stable=0
echo "schema_migrations not ready yet"
fi
sleep 4
done

echo "timed out waiting for database schema after ${timeout}s"
exit 1
4 changes: 4 additions & 0 deletions charts/opnc-integration/templates/openproject/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ metadata:
data:
entrypoint.sh: |
{{ .Files.Get "scripts/openproject/entrypoint.sh" | indent 4 }}
migrate: |
{{ .Files.Get "scripts/openproject/migrate" | indent 4 }}
wait-for-db: |
{{ .Files.Get "scripts/openproject/wait-for-db" | indent 4 }}

{{ if or .Values.openproject.gitSourceBranch .Values.openproject.localSourcePath }}
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ spec:
- name: OP_USE_LOCAL_SOURCE
value: 'true'
{{ end }}
# Angular production build uses --max_old_space_size=4096; reserve headroom for npm/ruby.
resources:
requests:
memory: 5Gi
limits:
memory: 7Gi
volumeMounts:
- name: empty-app-dir
mountPath: /app
Expand Down
10 changes: 10 additions & 0 deletions charts/pullpreview-stack/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ openproject:
requests:
memory: 512Mi

extraEnvVars:
- name: WAIT_FOR_DB_TIMEOUT_SECONDS
value: "900"

extraVolumes:
- name: entrypoint
configMap:
Expand All @@ -151,6 +155,12 @@ openproject:
- name: entrypoint
mountPath: /app/docker/prod/entrypoint.sh
subPath: entrypoint.sh
- name: entrypoint
mountPath: /app/docker/prod/migrate
subPath: migrate
- name: entrypoint
mountPath: /app/docker/prod/wait-for-db
subPath: wait-for-db

postgresql:
fullnameOverride: op-postgresql
Expand Down
10 changes: 5 additions & 5 deletions e2e/locators/nextcloud.json
Original file line number Diff line number Diff line change
Expand Up @@ -533,19 +533,19 @@
},
"unifiedSearchTrigger": {
"by": "locator",
"value": "button.unified-search-input__button, .unified-search-input__button"
"value": "input.unified-search-input__input[role='combobox'], [role='combobox'][aria-label*='Apps, files'], button.unified-search-input__button, .unified-search-input__button"
},
"unifiedSearchInput": {
"by": "locator",
"value": "[data-cy-unified-search-input], .unified-search-modal input.input-field__input, .unified-search-modal-root input.input-field__input"
"value": "input.unified-search-input__input, [data-cy-unified-search-input], .unified-search-modal input.input-field__input, .unified-search-modal-root input.input-field__input"
},
"unifiedSearchOpenProjectGroup": {
"by": "locator",
"value": ".unified-search-modal .result:has(h4.result-title:has-text('OpenProject')), .unified-search-modal h4.result-title:has-text('OpenProject')"
"value": "button:has-text('More from OpenProject'), [role='listbox'][aria-label*='OpenProject'], .result-title--more:has-text('OpenProject'), h4.result-title:has-text('OpenProject'), [class*='unified-search-modal'] .result:has-text('OpenProject')"
},
"unifiedSearchResultItem": {
"by": "locator",
"value": ".unified-search-modal .result:has(h4.result-title:has-text('OpenProject')) a.list-item__anchor, .unified-search-modal a.list-item__anchor[href*='openproject']"
"value": "[role='listbox'][aria-label*='OpenProject'] a.list-item__anchor, [role='listbox'][aria-label*='OpenProject'] [role='option'] a, [class*='unified-search-modal'] .result:has(.result-title--more:has-text('OpenProject')) a.list-item__anchor, [class*='unified-search-modal'] .result:has(h4.result-title:has-text('OpenProject')) a.list-item__anchor, a.list-item__anchor[href*='/work_packages/'], a.list-item__anchor[href*='/wp/']"
},
"dashboardOpenProjectWidget": {
"by": "locator",
Expand Down Expand Up @@ -668,7 +668,7 @@
},
"smartPickerResultItem": {
"by": "locator",
"value": "#work-package-smart-picker .vs__dropdown-option, .work-package-picker .vs__dropdown-option, #searchBar .vs__dropdown-option"
"value": "[role='dialog']:has-text('OpenProject work package picker') [role='option'], #work-package-smart-picker .vs__dropdown-option, .work-package-picker .vs__dropdown-option, #searchBar .vs__dropdown-option, .modal-container [role='option']"
},
"smartPickerReferenceCard": {
"by": "locator",
Expand Down
22 changes: 12 additions & 10 deletions e2e/pageobjects/nextcloud/NextcloudHeaderPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,24 @@ export class NextcloudHeaderPage extends NextcloudBasePage {
}

async openUnifiedSearch(): Promise<void> {
// NC 35+: header search is an inline combobox (no separate __button). Older NC kept the button.
const trigger = this.getLocator('unifiedSearchTrigger').first();
await trigger.waitFor({ state: 'visible', timeout: 15000 });
// NC only fires provider searches after /search/providers has returned and Vue wired them.
const providersReady = this.page.waitForResponse(
(res) =>
res.url().includes('/ocs/v2.php/search/providers') &&
!res.url().includes('/search/providers/') &&
res.ok(),
{ timeout: 15000 },
);
// Providers may load on focus (older NC) or only after the first keystroke (NC 35).
const providersReady = this.page
.waitForResponse(
(res) =>
res.url().includes('/ocs/v2.php/search/providers') &&
!res.url().includes('/search/providers/') &&
res.ok(),
{ timeout: 5000 },
)
.catch(() => undefined);
await trigger.click();
await providersReady;
const input = this.getLocator('unifiedSearchInput').first();
await input.waitFor({ state: 'visible', timeout: 15000 });
// Providers must be wired before typing or the modal sticks on "Searching...".
await expect(input).toBeEditable({ timeout: 10000 });
await providersReady;
}

async searchUnified(query: string): Promise<void> {
Expand Down
34 changes: 23 additions & 11 deletions e2e/pageobjects/nextcloud/NextcloudPersonalSettingsPage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Page } from '@playwright/test';
import { expect, Page } from '@playwright/test';
import { NextcloudBasePage } from './NextcloudBasePage';
import { getErrorMessage } from '../../utils/error-utils';
import { logInfo, logWarn } from '../../utils/logger';
Expand Down Expand Up @@ -85,28 +85,40 @@ export class NextcloudPersonalSettingsPage extends NextcloudBasePage {
if (targetState !== undefined && targetState === isChecked) {
return;
}
const label = this.getLocator(labelKey).first();
if (await label.isVisible({ timeout: 3000 }).catch(() => false)) {
await label.scrollIntoViewIfNeeded();
await label.click({ timeout: 10000 });
} else {
await checkbox.setChecked(targetState ?? !isChecked, { force: true });
}
// saveOptions() is async on the Vue watcher — wait for toast or save OCS before nav.
const toast = this.page.getByText(/OpenProject options saved/i).first();
// Hide a prior toast so the next save's toast is a fresh signal (dual-toggle race).
if (await toast.isVisible({ timeout: 500 }).catch(() => false)) {
await toast.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => undefined);
}
const saveResponse = this.page
.waitForResponse(
(res) =>
/\/ocs\/v2\.php\/apps\/integration_openproject\//i.test(res.url()) &&
res.request().method() !== 'GET' &&
res.ok(),
{ timeout: 10000 },
{ timeout: 15000 },
)
.catch(() => undefined);
const label = this.getLocator(labelKey).first();
if (await label.isVisible({ timeout: 3000 }).catch(() => false)) {
await label.scrollIntoViewIfNeeded();
await label.click({ timeout: 10000 });
} else {
await checkbox.setChecked(targetState ?? !isChecked, { force: true });
}
// saveOptions() is async on the Vue watcher — wait for toast and/or save OCS.
await Promise.race([
toast.waitFor({ state: 'visible', timeout: 10000 }),
toast.waitFor({ state: 'visible', timeout: 15000 }),
saveResponse,
]).catch(() => undefined);
await saveResponse;
// Let in-flight dual saves settle before the next toggle or reload.
await toast.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => undefined);
if (targetState !== undefined) {
await expect
.poll(async () => checkbox.isChecked(), { timeout: 10000 })
.toBe(targetState);
}
}

async toggleNavigationLink(targetState?: boolean): Promise<void> {
Expand Down
36 changes: 15 additions & 21 deletions e2e/pageobjects/nextcloud/NextcloudTextPage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Page, expect } from '@playwright/test';
import { NextcloudBasePage } from './NextcloudBasePage';
import { NextcloudFilesPage } from './NextcloudFilesPage';
import { resolveHosts } from '../../utils/env-hosts';
import { logInfo } from '../../utils/logger';
import { escapeForRegex } from '../../utils/url-helpers';

Expand Down Expand Up @@ -175,29 +176,22 @@ export class NextcloudTextPage extends NextcloudBasePage {
throw new Error(`Smart picker result visible but WP id not parseable from "${optionText}"`);
}

// Playwright clicks do not fire vue-select option:selected in this NC build.
// After proving the result row is shown, submit the same link getWorkPackageLink() emits.
const ncHost = new URL(this.page.url()).hostname;
const opHost = `${new URL(this.page.url()).protocol}//${ncHost.replace(/^nextcloud\./, 'openproject.')}`;
await this.page.evaluate(
({ id, opHost: host }) => {
const picker = document.querySelector('#work-package-smart-picker');
let el: HTMLElement | null = picker as HTMLElement | null;
while (el && !(el as unknown as { __vue__?: unknown }).__vue__) {
el = el.parentElement;
}
const vue = el && (el as unknown as { __vue__?: { onSubmit?: (link: string) => void } }).__vue__;
if (!vue?.onSubmit) {
throw new Error('Smart picker host Vue onSubmit not found');
}
vue.onSubmit(`${host.replace(/\/+$/, '')}/wp/${id}`);
},
{ id: wpId, opHost },
);
// Playwright clicks do not fire vue-select @option:selected on NC 35.
// WorkPackagePickerElement.onSubmit dispatches CustomEvent('submit', { detail: link })
// on #work-package-smart-picker — same path SearchInput uses after a real select.
const host = resolveHosts().openproject.replace(/^https?:\/\//, '').replace(/\/+$/, '');
const link = `https://${host}/wp/${wpId}`;
await this.page.evaluate((wpLink) => {
const root = document.querySelector('#work-package-smart-picker');
if (!root) {
throw new Error('Smart picker root #work-package-smart-picker not found');
}
root.dispatchEvent(new CustomEvent('submit', { detail: wpLink, bubbles: true }));
}, link);

await this.getLocator('smartPickerModal')
.first()
.waitFor({ state: 'hidden', timeout: 10000 })
.catch(() => undefined);
.waitFor({ state: 'hidden', timeout: 15000 });
}

async searchAndSelectWorkPackage(query: string): Promise<void> {
Expand Down
3 changes: 2 additions & 1 deletion e2e/scripts/publish-squash-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,8 @@ function getPublisherConfig(skipMissingAuth: boolean): PublisherConfig | undefin
iterationId ? undefined : 'SQUASH_TM_ITERATION_ID',
].filter((name): name is string => Boolean(name));

if (!apiToken && skipMissingAuth) {
// CI sets SKIP_MISSING_AUTH so ad-hoc runs without iteration/token stay green.
if (missing.length > 0 && skipMissingAuth) {
logWarn(`[Squash TM] Missing ${missing.join(', ')}. Skipping publish.`);
return undefined;
}
Expand Down
Loading
Loading