Skip to content

Commit 207d196

Browse files
committed
fix
1 parent 5d8d87f commit 207d196

14 files changed

Lines changed: 452 additions & 20 deletions

File tree

‎.devcontainer/devcontainer.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "Codebattle",
33
"dockerComposeFile": "../compose.yml",
44
"service": "app",
5+
"runServices": ["app", "runner-js", "runner-python"],
56
"workspaceFolder": "/workspace",
67
"remoteUser": "codebattle",
78
"// updateRemoteUserUID": "Must stay false: bin/dev always runs as the image uid 1000, and the _build/deps/node_modules/plts volumes are owned by it. Letting the editor remap the user to the host uid makes those volumes unwritable from the editor container.",

‎.env.example‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
CODEBATTLE_PORT=4000
22
CODEBATTLE_RUNNER_PORT=4001
3+
CODEBATTLE_DEV_RUNNER_LANGS=js,python
34
CODEBATTLE_VITE_PORT=8080
45
CODEBATTLE_DB_HOSTNAME=db
56
CODEBATTLE_DB_USERNAME=postgres

‎Makefile‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
BUILDX_OUTPUT ?= --load
22
DEV := ./bin/dev
3-
DEV_COMPOSE := docker compose --project-name codebattle-devcontainer --project-directory . --file compose.yml
3+
DEV_COMPOSE := docker compose --project-name codebattle-devcontainer --project-directory . --file compose.yml --profile '*'
44

5-
.PHONY: dev-up dev-build dev-rebuild dev-stop dev-down dev-logs dev-shell dev-exec
5+
.PHONY: dev-up dev-build dev-rebuild dev-stop dev-down dev-logs dev-shell dev-exec dev-runner-pull
66

77
dev-up:
88
$(DEV) true
99

10+
dev-runner-pull:
11+
@if [ "$${CODEBATTLE_DEVCONTAINER:-}" = "1" ]; then \
12+
echo "Run 'make dev-runner-pull' from the host."; \
13+
exit 2; \
14+
fi
15+
$(DEV) --pull-runners
16+
1017
dev-build:
1118
@if [ "$${CODEBATTLE_DEVCONTAINER:-}" = "1" ]; then \
1219
echo "Already inside the dev container; no image build is needed."; \
@@ -18,6 +25,7 @@ dev-rebuild: dev-build
1825
@if [ "$${CODEBATTLE_DEVCONTAINER:-}" != "1" ]; then \
1926
$(DEV_COMPOSE) up --detach --force-recreate app; \
2027
fi
28+
$(DEV) true
2129

2230
dev-stop:
2331
@if [ "$${CODEBATTLE_DEVCONTAINER:-}" = "1" ]; then \

‎README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,31 @@ Runner image build and publish targets still execute through the host container
8787
engine because they orchestrate other containers. Treat those targets as
8888
privileged operations and use them only with trusted code.
8989

90+
Solution checks use the Zig HTTP runner in separate language containers on the
91+
Compose network. `make dev-up` starts JavaScript and Python runners by default;
92+
`make server` starts Phoenix and Vite. The runner services are privileged to
93+
create their per-request sandboxes, while the app container stays non-root and
94+
does not receive a Docker socket. Runner ports are not published to the host.
95+
96+
Select languages with a comma-separated setting in the host `.env`:
97+
98+
```dotenv
99+
CODEBATTLE_DEV_RUNNER_LANGS=js,python,cpp
100+
```
101+
102+
Then run `make dev-up` and restart `make server`. The language picker uses the
103+
same allowlist. Supported slugs are `clojure`, `cpp`, `csharp`, `dart`, `elixir`,
104+
`golang`, `java`, `js`, `kotlin`, `php`, `python`, `ruby`, `rust`, `swift`, `ts`,
105+
and `zig`. A shell environment value overrides `.env`. Previously started
106+
runner containers may remain running until `make dev-down`.
107+
108+
If language images are already cached locally, refresh them with
109+
`make dev-runner-pull` on the host, then `make dev-up` and restart the server.
110+
111+
Editor devcontainer startup also starts the default JS and Python services.
112+
For a custom list, run `make dev-up` from the host before opening the container
113+
and update `.devcontainer/devcontainer.json`'s `runServices` list to match.
114+
90115
### Profile js bundle
91116

92117
### Support
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React from 'react';
2+
import { act, fireEvent, render, screen } from '@testing-library/react';
3+
import { createActor } from 'xstate';
4+
5+
import RoomContext from '../widgets/components/RoomContext';
6+
import machines from '../widgets/machines';
7+
import EditorContainer from '../widgets/pages/game/EditorContainer';
8+
9+
const { state, dispatch, checkSolution, editorMode } = vi.hoisted(() => ({
10+
state: {
11+
user: { currentUserId: 1, users: { 1: { subscriptionType: 'premium' } } },
12+
game: {
13+
players: { 1: { id: 1, result: 'lost', isBanned: false } },
14+
gameStatus: { gameId: 10, state: 'playing', mode: 'standard' },
15+
},
16+
editor: { meta: { 1: { currentLangSlug: 'js' } } },
17+
},
18+
dispatch: vi.fn(),
19+
checkSolution: vi.fn(),
20+
editorMode: { value: 'load_active_editor' },
21+
}));
22+
23+
vi.mock('react-redux', () => ({
24+
useDispatch: () => dispatch,
25+
useSelector: (selector: (value: unknown) => unknown) => selector(state),
26+
}));
27+
28+
vi.mock('../widgets/middlewares/Room', () => ({
29+
checkGameSolution: checkSolution,
30+
connectToEditor: (service: ReturnType<typeof createActor>) => () => {
31+
service.send({ type: editorMode.value });
32+
return () => {};
33+
},
34+
}));
35+
36+
vi.mock('../widgets/pages/game/EditorToolbar', () => ({
37+
default: ({ actionBtnsProps }: any) => (
38+
<button
39+
disabled={actionBtnsProps.checkBtnStatus !== 'enabled'}
40+
onClick={actionBtnsProps.checkResult}
41+
>
42+
Check
43+
</button>
44+
),
45+
}));
46+
47+
describe('game editor solution checks', () => {
48+
beforeEach(() => {
49+
vi.clearAllMocks();
50+
state.game.gameStatus.state = 'playing';
51+
state.game.players[1].isBanned = false;
52+
editorMode.value = 'load_active_editor';
53+
});
54+
55+
const renderEditor = (mainService: ReturnType<typeof createActor>) =>
56+
render(
57+
<RoomContext.Provider value={{ mainService, taskService: mainService }}>
58+
<EditorContainer id={1} type="current_user" editorMachine={machines.editor}>
59+
{() => null}
60+
</EditorContainer>
61+
</RoomContext.Provider>,
62+
);
63+
64+
test('keeps checking available when an active game finishes', () => {
65+
const mainService = createActor(machines.game);
66+
mainService.start();
67+
mainService.send({ type: 'LOAD_GAME', payload: { state: 'playing' } });
68+
renderEditor(mainService);
69+
70+
act(() => {
71+
state.game.gameStatus.state = 'game_over';
72+
mainService.send({ type: 'user:check_complete', payload: { state: 'game_over' } });
73+
});
74+
75+
expect(screen.getByRole('button', { name: 'Check' })).toBeEnabled();
76+
fireEvent.click(screen.getByRole('button', { name: 'Check' }));
77+
expect(checkSolution).toHaveBeenCalledOnce();
78+
act(() => mainService.stop());
79+
});
80+
81+
test('allows Ctrl+Enter when reopening a live finished game', () => {
82+
state.game.gameStatus.state = 'game_over';
83+
const mainService = createActor(machines.game);
84+
mainService.start();
85+
mainService.send({ type: 'LOAD_GAME', payload: { state: 'game_over' } });
86+
renderEditor(mainService);
87+
88+
fireEvent.keyDown(window, { key: 'Enter', ctrlKey: true });
89+
expect(checkSolution).toHaveBeenCalledOnce();
90+
act(() => mainService.stop());
91+
});
92+
93+
test.each(['timeout', 'stored', 'replay', 'banned'])('disables checking for %s', (mode) => {
94+
state.game.gameStatus.state = mode === 'timeout' ? 'timeout' : 'game_over';
95+
if (mode === 'stored') editorMode.value = 'load_stored_editor';
96+
if (mode === 'banned') editorMode.value = 'load_banned_editor';
97+
const mainService = createActor(machines.game, {
98+
input: { subscriptionType: 'premium' },
99+
});
100+
mainService.start();
101+
mainService.send({ type: 'LOAD_GAME', payload: { state: state.game.gameStatus.state } });
102+
if (mode === 'replay') {
103+
mainService.send({ type: 'START_LOADING_PLAYBOOK' });
104+
mainService.send({ type: 'LOAD_PLAYBOOK', payload: {} });
105+
}
106+
renderEditor(mainService);
107+
108+
expect(screen.getByRole('button', { name: 'Check' })).toBeDisabled();
109+
fireEvent.keyDown(window, { key: 'Enter', ctrlKey: true });
110+
expect(checkSolution).not.toHaveBeenCalled();
111+
act(() => mainService.stop());
112+
});
113+
});

‎apps/codebattle/assets/js/__tests__/LanguagePickerView.test.tsx‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { configureStore } from '@reduxjs/toolkit';
22
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
34
import React from 'react';
45
import { Provider } from 'react-redux';
56

@@ -12,19 +13,57 @@ const langs = [
1213
{ slug: 'kotlin', name: 'kotlin', version: '2.1' },
1314
];
1415

15-
function renderPicker(currentLangSlug: string) {
16+
function renderPicker(
17+
currentLangSlug: string,
18+
availableLangs = langs,
19+
isDisabled = true,
20+
changeLang = vi.fn(),
21+
) {
1622
const store = configureStore({
17-
reducer: () => ({ editor: { langs } }),
23+
reducer: () => ({ editor: { langs: availableLangs } }),
1824
});
1925

2026
return render(
2127
<Provider store={store}>
22-
<LanguagePickerView changeLang={vi.fn()} currentLangSlug={currentLangSlug} isDisabled />
28+
<LanguagePickerView
29+
changeLang={changeLang}
30+
currentLangSlug={currentLangSlug}
31+
isDisabled={isDisabled}
32+
/>
2333
</Provider>,
2434
);
2535
}
2636

2737
describe('LanguagePickerView', () => {
38+
test('allows switching when there is exactly one alternative language', async () => {
39+
const user = userEvent.setup();
40+
const changeLang = vi.fn();
41+
renderPicker(
42+
'js',
43+
[
44+
{ slug: 'js', name: 'javascript', version: '25.4.0' },
45+
{ slug: 'python', name: 'python', version: '3.14.5' },
46+
],
47+
false,
48+
changeLang,
49+
);
50+
51+
await user.click(screen.getByRole('combobox'));
52+
await user.click(screen.getByText('Python'));
53+
54+
expect(changeLang).toHaveBeenCalledWith(
55+
expect.objectContaining({ slug: 'python' }),
56+
expect.anything(),
57+
);
58+
});
59+
60+
test('disables switching when no alternative language is available', () => {
61+
renderPicker('js', [langs[0]], false);
62+
63+
expect(screen.getByRole('button')).toBeDisabled();
64+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
65+
});
66+
2867
test('shows a saved language even when it is hidden from the selectable languages', () => {
2968
renderPicker('kotlin');
3069

‎apps/codebattle/assets/js/widgets/components/LanguagePickerView.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ function LanguagePickerView({ changeLang, currentLangSlug, isDisabled }: Languag
127127
[currentLang],
128128
);
129129

130-
if (isDisabled || options.length < 2) {
130+
if (isDisabled || options.length === 0) {
131131
return (
132132
<button className="btn btn-sm p-2" type="button" disabled>
133133
<LangTitle {...currentLang} />

‎apps/codebattle/assets/js/widgets/pages/game/EditorContainer.tsx‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
// import editorThemes from '../../config/editorThemes';
1919
import editorUserTypes from '../../config/editorUserTypes';
2020
import GameModeCodes from '../../config/gameModes';
21+
import GameStateCodes from '../../config/gameStateCodes';
2122
import {
2223
editorStateSelector,
2324
inPreviewRoomSelector,
@@ -129,9 +130,12 @@ function EditorContainer({
129130
const currentUserIsBot = useSelector(selectors.currentUserIsBotSelector);
130131
const gameId = useSelector(selectors.gameIdSelector);
131132
const gameMode = useSelector(selectors.gameModeSelector);
132-
const { tournamentId, startsAt, hideBannedPlayerControls } = useSelector(
133-
selectors.gameStatusSelector,
134-
);
133+
const {
134+
tournamentId,
135+
startsAt,
136+
hideBannedPlayerControls,
137+
state: gameState,
138+
} = useSelector(selectors.gameStatusSelector);
135139
const subscriptionType = useSelector(selectors.subscriptionTypeSelector);
136140

137141
const currentUserId = useSelector(selectors.currentUserIdSelector);
@@ -193,7 +197,11 @@ function EditorContainer({
193197
() => editorService.send({ type: 'user_check_solution' }),
194198
[editorService],
195199
);
196-
const checkResult = isActiveGame ? checkActiveTaskSolution : noop;
200+
const canCheckSolution =
201+
editorCurrent.context.editorState === 'active' &&
202+
!openedReplayer &&
203+
(isActiveGame || (isGameOver && gameState === GameStateCodes.gameOver));
204+
const checkResult = canCheckSolution ? checkActiveTaskSolution : noop;
197205

198206
const isNeedHotKeys = editorCurrent.context.type === editorUserTypes.currentUser;
199207

@@ -229,8 +237,8 @@ function EditorContainer({
229237
currentEditorLangSlug,
230238
...userSettings,
231239
showGiveUpBtn: !isTournamentGame,
232-
checkBtnStatus: isActiveGame ? userSettings.checkBtnStatus : EditorBtnStatuses.disabled,
233-
resetBtnStatus: isActiveGame ? userSettings.resetBtnStatus : EditorBtnStatuses.disabled,
240+
checkBtnStatus: canCheckSolution ? userSettings.checkBtnStatus : EditorBtnStatuses.disabled,
241+
resetBtnStatus: canCheckSolution ? userSettings.resetBtnStatus : EditorBtnStatuses.disabled,
234242
giveUpBtnStatus: isActiveGame ? userSettings.giveUpBtnStatus : EditorBtnStatuses.disabled,
235243
};
236244

‎apps/codebattle/lib/codebattle/code_check/executor/remote_zig.ex‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,15 @@ defmodule Codebattle.CodeCheck.Executor.RemoteZig do
104104
to_string(:rand.uniform(10_000_000))
105105
end
106106

107-
defp runner_url(lang) do
108-
namespace = Application.get_env(:codebattle, :k8s_namespace, "default")
109-
"http://runner-#{lang}.#{namespace}.svc/run"
110-
# "http://localhost:4040/run"
107+
@doc false
108+
def runner_url(lang) do
109+
case Application.get_env(:codebattle, :zig_runner_url_template) do
110+
nil ->
111+
namespace = Application.get_env(:codebattle, :k8s_namespace, "default")
112+
"http://runner-#{lang}.#{namespace}.svc/run"
113+
114+
template ->
115+
String.replace(template, "{lang}", lang)
116+
end
111117
end
112118
end

‎apps/codebattle/test/codebattle/code_check/executor/remote_zig_test.exs‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,40 @@
11
defmodule Codebattle.CodeCheck.Executor.RemoteZigTest do
2-
use Codebattle.DataCase, async: true
2+
use Codebattle.DataCase, async: false
33

44
alias Codebattle.CodeCheck.Checker.Token
55
alias Codebattle.CodeCheck.Executor.RemoteZig
66
alias Runner.Languages
77

8+
describe "runner_url/1" do
9+
setup do
10+
previous = Application.get_env(:codebattle, :zig_runner_url_template)
11+
12+
on_exit(fn ->
13+
if previous do
14+
Application.put_env(:codebattle, :zig_runner_url_template, previous)
15+
else
16+
Application.delete_env(:codebattle, :zig_runner_url_template)
17+
end
18+
end)
19+
20+
:ok
21+
end
22+
23+
test "routes languages to their Compose service" do
24+
Application.put_env(:codebattle, :zig_runner_url_template, "http://runner-{lang}:4040/run")
25+
26+
assert RemoteZig.runner_url("js") == "http://runner-js:4040/run"
27+
assert RemoteZig.runner_url("python") == "http://runner-python:4040/run"
28+
end
29+
30+
test "keeps Kubernetes routing when no template is configured" do
31+
Application.delete_env(:codebattle, :zig_runner_url_template)
32+
namespace = Application.get_env(:codebattle, :k8s_namespace, "default")
33+
34+
assert RemoteZig.runner_url("js") == "http://runner-js.#{namespace}.svc/run"
35+
end
36+
end
37+
838
defp build_token(lang_slug, solution_text) do
939
task = build(:task)
1040

0 commit comments

Comments
 (0)