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
61 changes: 55 additions & 6 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,46 @@
task docker:up-all
no_output_timeout: 10m

# Wait for services to be ready (includes build completion and restart detection)
# Wait for core services to be ready (MySQL + web app). Discourse is started
# via docker:up-all but its readiness is checked separately (non-blocking).
# docker_run.sh runs npm install + npx playwright install + migrate:fresh --seed
# on every startup, which can take 20-25 min on cold CI machines. We poll
# directly here with a 25-minute ceiling rather than using the Taskfile helper
# (which only allows 10 min) to avoid blocking on that limit.
- run:
name: Wait for services
command: |
task docker:wait-for-services-all
# Wait for MySQL first
echo "Waiting for MySQL..."
for i in $(seq 1 60); do
docker exec restarters_db mysqladmin ping -h localhost -u root -ps3cr3t --silent >/dev/null 2>&1 && echo "✓ MySQL ready" && break

Check failure on line 74 in .circleci/config.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure this MySQL password gets changed and removed from the code.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7fOAcTQpWSLry8BBFt&open=AZ7fOAcTQpWSLry8BBFt&pullRequest=880

Check failure

Code scanning / SonarCloud

MySQL database passwords should not be disclosed High

Make sure this MySQL password gets changed and removed from the code. See more on SonarQube Cloud
[ $i -eq 60 ] && echo "❌ MySQL not ready" && exit 1
echo " MySQL attempt $i/60..."; sleep 5
done

# Wait up to 35 minutes for the web app. docker_run.sh does composer
# install + npm install + migrate:fresh + seed on every startup.
# Also detect crash-loops: if the container restarts, bail immediately
# and print logs so we can diagnose the failure.
echo "Waiting for web application (up to 35 min)..."
initial_restart=$(docker inspect --format='{{.RestartCount}}' restarters 2>/dev/null || echo "0")
for i in $(seq 1 420); do
current_restart=$(docker inspect --format='{{.RestartCount}}' restarters 2>/dev/null || echo "0")
if [ "$current_restart" -gt "$initial_restart" ]; then
echo "❌ Container restarted ($initial_restart → $current_restart) — crash-loop detected, showing logs:"
docker logs restarters --tail=100 2>&1 || true
exit 1
fi
curl -f -s http://localhost:8001 >/dev/null 2>&1 && echo "✓ Web app ready after $((i*5))s" && break
if [ $i -eq 420 ]; then
echo "❌ Web app not ready after 35 minutes — container logs:"
docker logs restarters --tail=100 2>&1 || true
exit 1
fi
[ $((i % 12)) -eq 0 ] && echo " Still waiting... ($((i*5))s elapsed)"
sleep 5
done
no_output_timeout: 38m

# Setup database and application
- run:
Expand All @@ -82,15 +117,29 @@
# Generate additional Laravel artifacts for testing
docker exec restarters php artisan l5-swagger:generate

# Setup Discourse API
# Setup Discourse API (PostgreSQL must be ready; Discourse web app not required)
- run:
name: Setup Discourse
command: |
# Wait for PostgreSQL (separate from Discourse web app - starts faster)
echo "Waiting for PostgreSQL..."
attempt=0
until docker exec postgresql pg_isready -U postgres >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ $attempt -ge 60 ]; then
echo "PostgreSQL did not become ready in time - skipping Discourse setup"
exit 0
fi
echo " PostgreSQL not ready, waiting... (attempt $attempt/60)"
sleep 5
done
echo "✓ PostgreSQL is ready"

# Add API key to Discourse - run directly on PostgreSQL container
docker exec postgresql psql -U postgres -c "INSERT INTO api_keys (id, user_id, created_by_id, created_at, updated_at, allowed_ips, hidden, last_used_at, revoked_at, description, key_hash, truncated_key) VALUES (1, NULL, 1, '2021-10-25 13:56:20.033338', '2021-10-25 13:56:20.033338', NULL, false, NULL, NULL, 'Restarters', 'd89e9dfacfb611fbaf004807648187ce7ed474df44dcb0ada230fab5c8dd6a5b', '9fd7');" bitnami_discourse
# Configure Discourse settings
docker exec restarters php artisan discourse:setting personal_message_enabled_groups 10

# Configure Discourse settings (only if Discourse web app is up)
docker exec restarters php artisan discourse:setting personal_message_enabled_groups 10 || echo "Warning: discourse:setting failed - Discourse may not be fully started"

# Run PHPUnit tests
- run:
Expand Down
11 changes: 8 additions & 3 deletions docker_run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,14 @@
npm install --legacy-peer-deps
npm rebuild node-sass

# Install Playwright for testing (system deps already in Dockerfile)
npm install -D @playwright/test
npx playwright install
# Install Playwright for testing (system deps already in Dockerfile).
# In CI, skip browser download — Playwright tests run in the dedicated
# restarters_playwright container (mcr.microsoft.com/playwright) which
# ships pre-installed browsers. Downloading here saves 10-15 min on CI.
if [ "${CIRCLECI}" != "true" ]; then

Check failure on line 85 in docker_run.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7fWaiaEq-eIVVWGYHE&open=AZ7fWaiaEq-eIVVWGYHE&pullRequest=880
npm install -D @playwright/test
npx playwright install
fi

# Start Vite dev server in the background with logging
echo "Starting Vite dev server..."
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/GroupDeviceRepairPodium.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
{{ device.counter }}
</span>
<span class="font-weight-bold align-content-center mt-2">
{{ device.name }}
{{ translatedName }}
</span>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions resources/js/components/StatsImpact.vue
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,10 @@ export default {
return null
} else if (ret.length === 1) {
// events.not_counting, groups.not_counting
const intro = this.__(langSource + '.not_counting', { count: this.stats.no_weight })
const intro = this.__(langSource + '.not_counting', { count: ret.length })
return intro + ' ' + ret[0] + '.'
} else {
const intro = this.__(langSource + '.not_counting', { count: this.stats.no_weight })
const intro = this.__(langSource + '.not_counting', { count: ret.length })
const first = ret.slice(0, -1)
const last = ret[ret.length - 1]

Expand Down
80 changes: 80 additions & 0 deletions resources/js/misc/lang.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Unit tests for lang-utils.js (pure translation / pluralisation logic).
*
* lang.js wraps these utilities but adds Vite/Sentry deps that are awkward
* to test directly. Testing the utils directly gives full coverage of the
* logic we care about.
*/
import { translateWithLocale, choiceWithLocale } from '../mixins/lang-utils.js';

// ---------------------------------------------------------------------------
// choiceWithLocale — plain "singular|plural" strings
// ---------------------------------------------------------------------------
describe('choiceWithLocale — plain singular|plural', () => {
const translations = {
en: { events: { not_counting: 'impact is|impact are' } },
};

it('returns singular form (first segment) when count is 1', () => {
expect(choiceWithLocale(translations, 'en', 'events.not_counting', 1)).toBe('impact is');
});

it('returns plural form (last segment) when count is 2', () => {
expect(choiceWithLocale(translations, 'en', 'events.not_counting', 2)).toBe('impact are');
});

it('returns plural form when count is 0', () => {
expect(choiceWithLocale(translations, 'en', 'events.not_counting', 0)).toBe('impact are');
});

it('never returns the raw pipe-delimited string', () => {
for (const count of [0, 1, 2, 10]) {
expect(choiceWithLocale(translations, 'en', 'events.not_counting', count)).not.toContain('|');
}
});
});

// ---------------------------------------------------------------------------
// choiceWithLocale — {n} and [n,m] explicit qualifiers still work
// ---------------------------------------------------------------------------
describe('choiceWithLocale — explicit {n} / [n,m] qualifiers', () => {
const translations = {
en: { test: { key: '{0} none|{1} one|[2,*] many' } },
};

it('handles {0} exact match', () => {
expect(choiceWithLocale(translations, 'en', 'test.key', 0)).toBe('none');
});

it('handles {1} exact match', () => {
expect(choiceWithLocale(translations, 'en', 'test.key', 1)).toBe('one');
});

it('handles [2,*] range match', () => {
expect(choiceWithLocale(translations, 'en', 'test.key', 5)).toBe('many');
});
});

// ---------------------------------------------------------------------------
// translateWithLocale — top-level JSON locale keys (e.g. category names)
// ---------------------------------------------------------------------------
describe('translateWithLocale — top-level locale keys', () => {
const translations = {
fr: { 'Desktop computer': 'Ordinateur de bureau' },
en: { 'Desktop computer': 'Desktop computer' },
};

it('returns the translated value for a top-level key in the requested locale', () => {
expect(translateWithLocale(translations, 'fr', 'Desktop computer')).toBe('Ordinateur de bureau');
});

it('falls back to English when the locale is not in the table', () => {
expect(translateWithLocale(translations, 'de', 'Desktop computer')).toBe('Desktop computer');
});

it('translates nested keys (e.g. partials.to_be_recycled)', () => {
const t = { fr: { partials: { to_be_recycled: ':value objet à recycler|:value objets à recycler' } } };
const result = translateWithLocale(t, 'fr', 'partials.to_be_recycled', { value: 3 });
expect(result).toContain('3');
});
});
99 changes: 99 additions & 0 deletions resources/js/mixins/lang-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Pure translation utilities — no Vite/Sentry dependencies.
* Accepts translations and locale as explicit parameters so they can be
* injected in tests without mocking import.meta.env.
*/

export function translateWithLocale(translations, locale, key, values = {}) {
const parts = key.split('.');
let translation;

if (translations[locale]) {
translation = translations[locale];
} else if (translations['en']) {
translation = translations['en'];
} else {
return key;
}

for (const part of parts) {
if (translation && typeof translation === 'object' && part in translation) {
translation = translation[part];
} else {
return key;
}
}

if (typeof translation === 'string' && Object.keys(values).length > 0) {
return translation.replace(/:(\w+)/g, (match, param) => {
return values[param] !== undefined ? values[param] : match;

Check warning on line 29 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7ezRQqZ5UXdmR_QYhP&open=AZ7ezRQqZ5UXdmR_QYhP&pullRequest=880
});
}

return typeof translation === 'string' ? translation : key;
}

export function choiceWithLocale(translations, locale, key, count, values = {}) {

Check failure on line 36 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 33 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7ezRQqZ5UXdmR_QYhQ&open=AZ7ezRQqZ5UXdmR_QYhQ&pullRequest=880
const parts = key.split('.');
let rawTranslation;

if (translations[locale]) {
rawTranslation = translations[locale];
} else if (translations['en']) {
rawTranslation = translations['en'];
} else {
return key;
}

for (const part of parts) {
if (rawTranslation && typeof rawTranslation === 'object' && part in rawTranslation) {
rawTranslation = rawTranslation[part];
} else {
return key;
}
}

if (typeof rawTranslation !== 'string') {
return key;
}

if (rawTranslation.includes('|')) {
const segments = rawTranslation.split('|');

// Default for plain "singular|plural" (no explicit {n} or [n,m] qualifiers):
// count === 1 → first segment, otherwise → last segment.
let selectedSegment = count === 1 ? segments[0] : segments[segments.length - 1];

Check warning on line 65 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `.at(…)` over `[….length - index]`.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7ezRQqZ5UXdmR_QYhR&open=AZ7ezRQqZ5UXdmR_QYhR&pullRequest=880

for (const segment of segments) {
// Match {n} syntax for exact values
const exactMatch = segment.match(/^\{(\d+)\}\s*(.*)$/);

Check warning on line 69 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7ezRQqZ5UXdmR_QYhS&open=AZ7ezRQqZ5UXdmR_QYhS&pullRequest=880

Check warning on line 69 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ77czTmTbZMs_bFxGBe&open=AZ77czTmTbZMs_bFxGBe&pullRequest=880
if (exactMatch) {
const exactNum = parseInt(exactMatch[1], 10);

Check warning on line 71 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7ezRQqZ5UXdmR_QYhU&open=AZ7ezRQqZ5UXdmR_QYhU&pullRequest=880
if (count === exactNum) {
selectedSegment = exactMatch[2];
break;
}
continue;
}

// Match [n,m] or [n,*] syntax for ranges
const rangeMatch = segment.match(/^\[(\d+),(\d+|\*)\]\s*(.*)$/);

Check warning on line 80 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7ezRQqZ5UXdmR_QYhV&open=AZ7ezRQqZ5UXdmR_QYhV&pullRequest=880

Check warning on line 80 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ77czTmTbZMs_bFxGBf&open=AZ77czTmTbZMs_bFxGBf&pullRequest=880
if (rangeMatch) {
const min = parseInt(rangeMatch[1], 10);

Check warning on line 82 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7ezRQqZ5UXdmR_QYhX&open=AZ7ezRQqZ5UXdmR_QYhX&pullRequest=880
const max = rangeMatch[2] === '*' ? Infinity : parseInt(rangeMatch[2], 10);

Check warning on line 83 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7ezRQqZ5UXdmR_QYhY&open=AZ7ezRQqZ5UXdmR_QYhY&pullRequest=880
if (count >= min && count <= max) {
selectedSegment = rangeMatch[3];
break;
}
continue;

Check warning on line 88 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant jump.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7ezRQqZ5UXdmR_QYhZ&open=AZ7ezRQqZ5UXdmR_QYhZ&pullRequest=880
}
}

return selectedSegment.replace(/:(\w+)/g, (match, param) => {
if (param === 'count') return count;
return values[param] !== undefined ? values[param] : match;

Check warning on line 94 in resources/js/mixins/lang-utils.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ7ezRQqZ5UXdmR_QYha&open=AZ7ezRQqZ5UXdmR_QYha&pullRequest=880
});
}

return translateWithLocale(translations, locale, key, { ...values, count });
}
Loading
Loading